Skip to content

Configurable bullet shapes and task checkbox style - #150

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

Configurable bullet shapes and task checkbox style#150
luisKisters wants to merge 4 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 4 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.
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