Skip to content

Renderer: align ordered list items on the widest marker of their list - #27

Merged
b451c merged 1 commit into
b451c:mainfrom
rosekanari:fix/ordered-list-alignment
Sep 10, 2026
Merged

Renderer: align ordered list items on the widest marker of their list#27
b451c merged 1 commit into
b451c:mainfrom
rosekanari:fix/ordered-list-alignment

Conversation

@rosekanari

Copy link
Copy Markdown

Ordered list items each hang under their own marker, so their text starts at a different x. This aligns every item of a list on the widest marker of that list.

The problem

renderListItem calls applyListParagraphStyle(_:hangingUnder:) with that item’s own prefix, which delegates to applyHangingIndent(_:indent:hangingUnder:):

style.firstLineHeadIndent = indent
style.headIndent = indent + width(of: prefix)

The body font is not monospaced, so prefixes of different items have different widths. Measured at a 21 pt body:

prefix | width | vs "1. " -- | -- | -- "1. " | 18.47 pt | — "9. " | 21.95 pt | +3.49 "10. " | 31.00 pt | +12.53 "100. " | 43.68 pt | +25.21

A ten-item list is enough to see it: the text of item 10 starts 12.53 pt further right than that of item 1. Nothing overflows — no width is imposed — but nothing lines up either.

The change

render pre-scans the joined lines and, for each contiguous run of ordered items at one indent level, computes the widest prefix; every item of the run hangs under it. The run is fully known before any item is rendered, so the widest prefix is too.

renderLine and renderListItem take it as an optional parameter defaulting to nil, so unordered items, task items and every other caller are untouched.

Two files, 69 insertions, 6 deletions.

Tests

testOrderedItemsOfOneListShareTheirHangingIndent is added. It renders a twelve-item list and asserts that all items share one headIndent. It fails on unpatched main and passes with this change. The full suite passes.

testWiderOrderedMarkerHangsFurther keeps passing, and for the right reason: it renders "1. alpha" and "10. alpha" as two separate one-item documents, so each still hangs under its own — and only — marker. Its stated intent, “a wider marker has to hang further, or 10. would overlap its own text”, is preserved: within a list, every item now hangs under the widest one, so the widest never overlaps its text.

Notes

Verified against 28d86fd. Built and tested with:

xcodebuild -project QuickMD.xcodeproj -scheme QuickMD \
           -destination 'platform=macOS' test CODE_SIGNING_ALLOWED=NO

Written with Claude Code.

Happy to split, rename, or drop the added test if you would rather keep the suite as it is

The hanging indent of an ordered item was the width of that item's own
prefix. The body font is not monospaced, so "1. " and "10. " are 18.47 pt
and 31.00 pt wide at a 21 pt body, and each item's text started at a
different x — a 12.5 pt step, visible at a glance on any list that crosses
ten items.

`render` now pre-scans the joined lines and, for each contiguous run of
ordered items at one indent level, computes the widest prefix; every item
of the run hangs under it. The run is known before any rendering, so the
widest prefix is too.

`testWiderOrderedMarkerHangsFurther` keeps passing, and for the right
reason: it renders "1. alpha" and "10. alpha" as two separate one-item
documents, so each still hangs under its own — and only — marker. Its
stated intent, "a wider marker has to hang further, or 10. would overlap
its own text", is preserved: within a list, every item now hangs under the
widest one.

Adds `testOrderedItemsOfOneListShareTheirHangingIndent`, which fails on
unpatched main and passes here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b451c added a commit that referenced this pull request Sep 10, 2026
…ollow-up to #27)

PR #27 gave every ordered item of a list the same `headIndent`, which aligns
their WRAPPED lines. The first line, however, starts at `firstLineHeadIndent`
plus the item's own marker glyphs, so "1. alpha" and "10. kappa" still began
their text 12.5 pt apart — and the wrapped lines of "1." now hung 12.5 pt to
the right of its own first line.

- Push a narrower marker right by (widest − own) via `firstLineHeadIndent`:
  markers are right-aligned under the widest one and every item's text starts
  at the same x on the first line and on wrapped lines alike.
- Choose the widest marker by measured width in the body font, not by
  character count ("9. " is wider than "1. ").
- Blank lines and deeper-indented lines (loose lists, nested lists,
  continuation paragraphs) no longer end the run; nested ordered lists form
  their own runs. `orderedItem` normalises the number exactly like
  `renderListItem` ("007." → "7. ").
- `applyHangingIndent(indent:firstLineLead:hangWidth:)` is the one mechanism;
  the string overload (definition lists, bullets, task items) passes lead 0.

Tests: TextKit lays out the rendered string and reads the x of the first text
glyph per item — the visual truth, independent of which indent field does the
work. Four new tests fail on #27 alone and pass with this change; 191 total.

Known limit (pre-existing parser structure): the parser slices text buffers
into ≤30-line chunks, so a list that straddles a chunk boundary is two runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017oPbKHjsi1HB3RWgxxFTKz
@b451c
b451c merged commit 8049a64 into b451c:main Sep 10, 2026
3 checks passed
@b451c

b451c commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Merged locally with --no-ff (8049a64) and pushed to main - thank you, the run detection and the failing-then-passing test were exactly the right shape.

One follow-up on top of it (7abb18c), because the shared headIndent only aligns the wrapped lines: the first line of an item starts at firstLineHeadIndent plus its own marker glyphs, so "1. alpha" and "10. kappa" still began their text 12.5 pt apart, and the wrapped lines of "1." now hung 12.5 pt to the right of its own first line. The follow-up pushes the narrower markers right by (widest - own) via firstLineHeadIndent, so the markers are right-aligned under the widest one and every item's text shares one edge on the first line and on wrapped lines alike. It also measures the widest marker in the body font instead of counting characters ("9. " is wider than "1. "), and keeps a list together across blank lines and nested content. Tests read the x of the first text glyph per item through TextKit, so they check the visual result rather than a single indent field.

Will ship in the next release with credit in the CHANGELOG.

b451c added a commit that referenced this pull request Sep 10, 2026
Graphics follow the zoom and open in a window-filling preview (#28/#29
@arvearve), ordered lists share one text edge (#27 @rosekanari), fenced
```svg blocks render natively, the 1.8.0 layout fallback is removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017oPbKHjsi1HB3RWgxxFTKz
@b451c

b451c commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Shipped in v1.10.0: https://github.com/b451c/quickmd/releases/tag/v1.10.0 - credited in the CHANGELOG and release notes. Thank you!

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.

2 participants