Skip to content

Lay an extension span out as a fixed-width control box (InlineSyntax.inlineBoxWidth) - #157

Draft
dylandeheer wants to merge 3 commits into
nodes-app:mainfrom
GetWeeve:feature/inline-extension-box
Draft

Lay an extension span out as a fixed-width control box (InlineSyntax.inlineBoxWidth)#157
dylandeheer wants to merge 3 commits into
nodes-app:mainfrom
GetWeeve:feature/inline-extension-box

Conversation

@dylandeheer

@dylandeheer dylandeheer commented Aug 16, 2026

Copy link
Copy Markdown

What

Adds InlineSyntax.inlineBoxWidth: CGFloat?. When set, the extension's span is
laid out as a fixed-width box instead of as text: markers and content collapse,
that many points of line space are kept in their place, and the span behaves as
one control for the caret, the selection, delete, and the pointer.

Why

An extension supplies syntax and content attributes today, which is enough for
a construct that reads as prose but not for one that reads as a control — a
citation playhead, a chip, a token. contentAttributes(theme:) applies to the
whole content range uniformly, so an embedder cannot reserve a width that is
independent of the character count. Without reserved space the only option is
to draw the control over the text, where it overlaps whatever follows and does
not wrap with the paragraph.

Reserved space alone is not enough, though: a span with no glyphs of its own is
a hole in the line. An arrow key stops a dozen times in a spot that never
moves, a click drops the caret inside the construct, one backspace shaves off a
delimiter and turns the box back into raw text mid-sentence, and the pointer
keeps the I-beam over something drawn as a button.

How

  • The reserved space rides as kerning on the span's FIRST character, so the box
    is a single advance: the line's height stays the paragraph's rather than the
    box's.
  • shrinkInactiveMarkers skips a box span — its shrink would overwrite the
    reserved advance, which sits on a marker character.
  • A box's delimiters no longer reveal at the caret. There is no text to reveal,
    and revealing them would shove the box sideways while the user types in the
    line it sits on.
  • The auto-link and incomplete-bracket text passes skip box spans, so a
    bracketed construct like [t=12.0-19.5] is not painted as a broken link.
  • The styler marks the span with .inlineExtensionBox. NativeTextView
    normalizes every selection through InlineBoxCaret, so the caret rests only
    on the box's edges and a selection takes all of it or none; backward and
    forward delete remove the whole span in one keystroke; and the pointer reads
    the span's laid-out rect as a control and shows the hand, the way the drawn
    task checkbox already does.

Compatibility

nil by default: every existing extension parses, styles, and navigates
exactly as before — covered by a plain-span counterpart in each test. The
registry fingerprint includes the new field, so two registries differing only
in it cannot share cached parses.

Test plan

  • InlineExtensionBoxTests: the width lands on the first character, the
    rest of the span collapses, a caret inside the span changes nothing in it,
    a span without the knob keeps upstream layout and caret reveal, the
    fingerprint separates the two registries, and TextKit grants exactly the
    reserved width (same document laid out at two widths).
  • InlineBoxCaretTests: arrows step over the box from either side and never
    rest inside it, a dropped caret takes the nearer edge, shift-arrow and a
    partial range take the whole box, backspace and forward delete remove it
    whole, the laid-out rect is the reserved width, and every point of that
    rect reads as a control while points beside it do not.
  • Full suite green.

Made with Cursor

dylandeheer and others added 3 commits August 16, 2026 19:14
An extension can supply syntax and content attributes, which is enough for
a construct that reads as text but not for one that reads as a control: a
citation playhead, a chip, a token. `contentAttributes(theme:)` covers the
content range uniformly, so an embedder cannot reserve a width that is
independent of the character count, and drawing the control over the text
leaves it overlapping whatever follows.

`inlineBoxWidth` collapses the construct's markers and content and keeps
that many points of line space in their place, riding as kerning on the
span's first character so the box is one advance and the line height stays
the paragraph's. The marker-shrink pass skips a box (it would overwrite the
reserved advance), the caret no longer reveals its delimiters (there is no
text to reveal, and revealing would shove the box aside mid-typing), and
the auto-link and incomplete-bracket text passes skip it, so a span like
`[t=12.0-19.5]` is not painted as a broken link.

Default nil, so every existing extension lays out exactly as before.

Co-authored-by: Cursor <cursoragent@cursor.com>
The other cases assert what the styler asks TextKit for. This one lays the
same document out at two box widths and checks the line gains exactly the
difference, so a reserved point is a laid-out point.

Co-authored-by: Cursor <cursoragent@cursor.com>
A box has no glyphs of its own, so without this the construct reads as a
hole in the line: an arrow key stops a dozen times in a spot that never
moves, a click drops the caret inside it, one backspace shaves off a
delimiter and turns the box back into raw text, and the pointer keeps the
I-beam over what is drawn as a control.

The styler marks the span with `.inlineExtensionBox`. From there the text
view normalizes every selection through it, so the caret only rests on its
edges and a selection takes all of it or none; backward and forward delete
remove the whole span in one keystroke; and the pointer reads its laid-out
rect as a control and shows the hand, as the drawn task checkbox already
does. A span without `inlineBoxWidth` is untouched on all three counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
@dylandeheer dylandeheer changed the title Lay an extension span out as a fixed-width box (InlineSyntax.inlineBoxWidth) Lay an extension span out as a fixed-width control box (InlineSyntax.inlineBoxWidth) Aug 17, 2026
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