Skip to content

Configurable bullet shapes and task checkbox style - #150

Open
luisKisters wants to merge 7 commits into
nodes-app:mainfrom
luisKisters:feat/configurable-bullets-and-checkboxes
Open

Configurable bullet shapes and task checkbox style#150
luisKisters wants to merge 7 commits into
nodes-app:mainfrom
luisKisters:feat/configurable-bullets-and-checkboxes

Conversation

@luisKisters

Copy link
Copy Markdown

Bullet markers and task checkboxes are the only list rendering an embedder cannot influence today: drawBulletMarkers paints a hardcoded "•" in theme.bodyText at every nesting depth, and drawTaskCheckboxes paints the SF Symbols square / checkmark.square.fill. ListStyle carries indent and line-height knobs but nothing for the markers themselves.

This adds two configuration groups so a host app can restyle both without forking.

API

config.lists = ListStyle(
    bullets: BulletStyle(
        shapeLadder: [.filledDot, .hollowRing, .smallSquare, .triangle],
        color: .systemPurple
    ),
    taskCheckbox: TaskCheckboxStyle(
        rendering: .drawn,
        size: 15, strokeWidth: 1.5, cornerRadius: 4.5, gap: 6,
        uncheckedColor: .systemPurple,
        checkedFillColor: .systemPurple
    )
)

BulletStyle.shapeLadder is indexed by 1-based nesting depth; the last entry repeats for deeper levels, and an empty ladder falls back to .filledDot. Shapes other than .filledDot are NSBezierPath, so they stay crisp at any scale.

Defaults are unchanged

BulletStyle.default is [.filledDot], which keeps the exact existing "•" NSString draw — same font, same centring within the marker advance. TaskCheckboxStyle.default is .systemSymbol, which keeps the existing SF Symbol branch untouched. All 253 existing tests pass with no edits to them.

rendering is an explicit mode rather than an inferred self == .default, so adding a field later cannot silently re-route existing embedders between the two paths.

How depth reaches the renderer

MarkdownASTStyler.styleListItem already computes MarkdownLists.indentLevel(from:) for its indent. It now also writes that depth (1-based) onto the marker range as a bulletListLevel attribute, following the existing blockquoteLevel precedent. drawBulletMarkers reads it and defaults to depth 1 when absent. Both attributes are written together and cleared together by the existing whole-paragraph setAttributes, so a stale depth cannot survive an edit.

Draw / hit-test parity

TaskCheckboxGeometry stays the single source of truth. size(for:style:) and boxX(contentX:size:gap:) are used by both MarkdownTextLayoutFragment.drawTaskCheckboxes and NativeTextView+TaskCheckbox.taskCheckboxHit, so a configured size or gap moves the drawn box and the click target together.

Tests

Added: default-preservation for both styles, ladder clamping, the 0-based indentLevel to 1-based ladder conversion across a real nested list, and the checkbox size and gap geometry. 255 tests pass.

One thing worth your call: MarkdownLists.indentLevel(from:) is a whitespace heuristic (tabs plus spaces / 2) that does not consult lists.indentPerLevel, so 4-space-indented CommonMark resolves one level deeper than it looks. That is pre-existing behaviour and this PR does not touch it, but the shape ladder makes it visible for the first time.

luisKisters and others added 7 commits August 11, 2026 15:34
Cut BulletShape.glyph, BulletStyle.sizeScale and BulletStyle.tiered — no
embedder set them — and flatten the bullet draw switch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gap was a fixed 2pt tuned for the font-derived SF Symbol box. A
larger configured box needs a larger gap or the label reads as touching
it. Both the draw site and the click hit-test take it from the style, so
their rects still cannot drift.
…stroke

usesSystemSymbol inferred the rendering path from `self == .default`, so
an embedder could not ask for the drawn box at default metrics and any
future field would silently re-route embedders between the two paths.
Make it an explicit `rendering` mode instead.

Also lift the ring's hardcoded stroke width into the style, document the
bullet-level attribute key, and compare the whole ListStyle for the
redisplay check so a later knob cannot be missed.

Tests pin the 0-based indentLevel to 1-based ladder conversion across a
real nested list, plus the checkbox size and gap geometry.
Adds ListStyle.leadingIndent, .markerColumnWidth and .markerCenterOffset plus
BulletStyle.diameter so an embedder can put the marker column on the paragraph
margin and share one anchor between bullets and task checkboxes. The filled dot
loses its glyph branch, so all depths share one diameter and the x-height
midline; the checkbox centres on cap height and its hit-test follows the same
geometry. Defaults reproduce the previous rendering.

swift test: 258 tests, 46 suites, green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A pinned marker column measured a literal "- ", so `*` and `+` items put
their content in a different column than `-` items. Measure the item's own
marker plus its space instead.

Bullet shapes also drew at unsnapped origins while the task checkbox
already snapped to the device grid, so a small dot straddled two pixels.
Snap the shape origin the same way; the triangle apex follows the snapped
rect.
`recalcOverscroll` returned early whenever the measured content height and
the resolved overscroll were both unchanged, but in `.scrolls` the managed
frame height is `max(content, viewport - header)`. A viewport change alone
therefore left a stale frame, and the text view could end up SHORTER than
its clip view: the strip below the last line belongs to the container, so
clicking it places no caret.

Seen in NoteTakr: the first layout runs before the text view has a scroll
view (viewport 0), where the policy resolves the same 39pt overscroll as at
the real 170pt viewport, so nothing re-applied the frame and the body stayed
101pt tall inside a 170pt clip.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant