Skip to content

Scope list restyles to affected items - #149

Merged
luca-chen198 merged 4 commits into
nodes-app:mainfrom
YishenTu:perf/scope-list-item-restyles
Aug 12, 2026
Merged

Scope list restyles to affected items#149
luca-chen198 merged 4 commits into
nodes-app:mainfrom
YishenTu:perf/scope-list-item-restyles

Conversation

@YishenTu

@YishenTu YishenTu commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Problem

A scoped edit inside a list still did list-wide work. MarkdownAST.parse(scopes:) narrowed the affected block, but once a scope touched a continuous list the list parser enumerated, inline-parsed, and restyled every item in that run. On a long list, an ordinary one-character edit therefore scaled with the whole list instead of the affected lines.

Ordered-list display numbering adds a correctness constraint: marker or indentation changes can affect later items and must widen the restyle run, while ordinary content edits should remain local.

Consumers mirroring native edits had a related fallback cost. NSTextStorage.didProcessEditing can report a range broadened by MarkdownEngine attribute work rather than the exact text mutation, making a safe incremental mirror fall back to replacing and restyling the full destination editor.

Solution

  • validate, sort, and merge UTF-16 scopes once, then sweep blocks and scopes monotonically
  • parse and style only physical list items intersecting ordinary edit scopes
  • preserve ordered numbering across skipped items by reseeding from source markers when scoped selection contains gaps
  • widen marker and indentation mutations that can affect downstream list structure, including paste, deletion, programmatic changes, undo, and redo paths
  • expose MarkdownTextMutation through NativeTextViewWrapper.onTextMutation, reporting the exact accepted native text edit independently of later styling ranges
  • cover malformed, overlapping, and out-of-order scopes; ordered numbering; structural edits; undo/redo; and effective attributed-value equivalence

Performance

Measured on a 128 KiB continuous task list with edits inside a middle list item. The mirrored path applies each mutation to a second editor through the public integration surface.

Path 0.12.0 median p95 This branch median p95 Improvement
Originating editor 440.408 ms 22.360 ms 19.7x faster / 94.9% lower
Mirrored second editor 2,151.451 ms 109.600 ms 19.6x faster / 94.9% lower

Validation

  • swift test — 337 tests passed
  • swift build
  • GitHub macOS CI
  • integration performance comparison against 0.12.0

Contiguous list blocks previously rebuilt and reparsed every item for a paragraph-scoped edit, making normal typing scale with the whole list. Normalize edit scopes once, retain only intersecting physical items, and preserve ordered numbering by widening structural marker edits and reseeding disjoint scopes.
Post-styling text-storage ranges can expand a one-character edit to an entire paragraph, preventing embedders from mirroring edits incrementally. Publish a completed single-edit descriptor from the coordinator's authoritative native lifecycle so consumers can preserve the exact UTF-16 transition.
@YishenTu
YishenTu marked this pull request as ready for review August 11, 2026 11:14
luca-chen198 and others added 2 commits August 12, 2026 21:34
Narrowing a scoped list node to the items its scope reached leaves two
holes the numbering walk cannot see, and both surface on a plain caret
move: the restyle covers the caret paragraph plus the one it left, and
applyStyledRanges resets exactly the region the styler is asked to
re-emit, so a number it declines to paint is visibly gone.

LEADING hole: the node can start at a LATER item, but previousItemEnd
started nil, so nothing re-seeded. With the preceding content block
having just cleared needsSeed, the item fell back to its source literal
and its overlay disappeared -- `# H` above `1./1./1.`, caret from the
heading into item 3, which then renders `1.` and stays wrong until that
item is restyled again.

TRAILING hole: contiguousEnd kept the full block range, hiding the items
the scope dropped from the tail. The gap to the next list block then
read as loose-list spacing, so a count that stopped early carried over
and painted a WRONG number over a correct literal -- `1. one/1. two`,
blank, `1. three`, caret off item 3's digits into item 1, and item 3
renders `2.`.

Also stop publishing onTextMutation for attribute-only edits. AppKit
proposes those with a nil replacement string (data detection linkifying
a phone number, Format > Font); coercing it to "" told a listener
mirroring edits that the range had been deleted, while no text moved.

Tests: both caret-move repros, the attribute-only case, and a
scoped-vs-full equivalence sweep over 21 list shapes -- every line as
its own scope and every line pair as the two-region scope a click
produces. All four fail without these changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The number an ordered item shows is positional; the digit in the file is
not. In a run written `1./1./1.` -- the spelling that lets the renderer
do the counting -- every item's source reads `1.`, so revealing it was
not "show me what I am editing" but "rename the item you are pointing
at": insert an item in the middle, click inside a marker below it, and
the `3.` you just watched appear turned back into `1.`. Select all and
every number below the insertion dropped at once.

Both reveals go, and with them the reason the selection one existed.
This marker was the only one the engine hid by COLOUR
(`foregroundColor: .clear`), and `NSTextView.selectedTextAttributes`
carries a `selectedTextColor` -- AppKit repaints every selected glyph
opaque, so the source digits came back under the highlight and collided
with the number painted over them. Revealing was the workaround. The
marker is now hidden by SIZE like every other marker here
(`hiddenMarkerFontSize`), which a selection cannot override, and the run
is kerned back out to the display marker's width so the slot, the
hanging indent and the highlight still measure the same thing. The
painter takes the view's base font instead of the run's, or it would
draw the number at 0.1pt too.

Measured by rendering offscreen and counting glyph pixels (page
background and highlight classified out), three items, one overlay each:

  colour-hidden   unselected 2169   selected 2488   (+319: the digits)
  size-hidden     unselected 2169   selected 2150

With nothing about the marker depending on the caret or the selection
any more, its crossing signal in textViewDidChangeSelection has no work
to do, and neither does the ordered-list branch of the selection-span
probe: both are removed, along with the two membership helpers that only
fed them.

Editing the digits still works -- they are the characters under the
overlay, and any edit to them re-runs the numbering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@luca-chen198 luca-chen198 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gj

@luca-chen198
luca-chen198 merged commit f6137df into nodes-app:main Aug 12, 2026
1 check passed
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