Skip to content

Directives (4/4): complete names and argument values - #159

Open
wildthink wants to merge 2 commits into
nodes-app:mainfrom
wildthink:feat/directives-completion
Open

Directives (4/4): complete names and argument values#159
wildthink wants to merge 2 commits into
nodes-app:mainfrom
wildthink:feat/directives-completion

Conversation

@wildthink

Copy link
Copy Markdown
Contributor

The last phase of the directive seam. Stacks on #158 (glyphs) — the diff below includes it; review that one first, and this is the ~1,200-line increment on top.

What it does

Autocomplete for directive names (@fo) and for their argument values (@icon(sta, @flag(jap), riding the seam the [[wiki-link]] picker already established: the engine detects the trigger, ranks the candidates, reports the anchor rect, and routes ↑/↓/↵/Esc. The embedder draws the list — no picker UI ships in the engine.

Why it can't use the AST

This is the one design point worth your attention. Mid-typing, @ico and @icon(sta are exactly what the parser REJECTS — no closing paren, no body. That's correct for styling and useless for completion.

So DirectiveCompletionScanner is a separate, forgiving backwards scan over the current line, bounded to 256 characters per caret move. It reuses the parser's boundary rule, so it can never offer a directive the parser would then refuse — the two agree on what could become a directive, and disagree only on whether it's finished yet.

Where the candidates come from

The engine owns them because it owns the registry. Names come from the registered directives; values from MarkdownDirective.valueCompletions(for:prefix:), whose default already answers anything the declared schema can — closed keyword sets and booleans.

A directive implements it only when its domain is dynamic or too large to declare. That's what makes a @flag-style command clean rather than special: the schema was already there. The demo's @flag offers every ISO region matching on code or localised country name, carrying no dataset — codes from Locale.Region, names from the user's locale.

A newly registered directive appears in the picker with no embedder change.

Scope

Changes to existing files are 47 lines across 3:

file lines
NativeTextViewWrapper.swift +27 (the four new closures)
NativeTextViewCoordinator+TextDelegate.swift +12
NativeTextViewCoordinator.swift +8

Everything else is new files: the scanner, the completion types, detection/commit, and tests.

The commit path is deliberately not applyInlineReplacement — that runs the wiki-link storage/display transform and stamps .wikiLinkID, neither of which means anything here.

Where the picker stays shut

Inside code spans and fenced blocks, on a selection rather than a caret, when not typing, mid-IME composition, in raw source mode, and after an email address. Each has a test.

Verification

  • 488 tests pass; 28 new.
  • Demo builds, with a caret-anchored picker (~60 lines) serving both names and values through one context type.

With this, #154 is the only directive work left outstanding and I'm happy to take it whenever suits you.

🤖 Generated with Claude Code

wildthink-pub and others added 2 commits August 17, 2026 12:21
Phase 3 of the directive seam. A self-contained call has no body, so until now
it parsed, claimed its span, and then rendered as its own literal source —
`@pagebreak` looked exactly like the text `@pagebreak`. This gives it
something to draw.

A directive returns a `DirectivePresentation` — an SF Symbol, replacement
text, or an NSImage — and the styler collapses the source behind it. The
mechanism is the one inline LaTeX already uses, not a new one: the characters
stay in the storage, the first carries the image plus enough kern to occupy
its width, the rest collapse to zero width via clear colour and the shrunk
marker font. `MarkdownTextLayoutFragment` draws it.

That is what keeps "markers shrink, they don't disappear" true here. Selection,
find, copy, and undo all still see the real characters, and the caret entering
the call reveals the source muted — the same flip every other construct does.

Failure is visible rather than silent: `.literal`, or a symbol name the system
doesn't know, leaves the source on screen instead of collapsing it to a gap the
user can neither see nor fix.

Rasterised glyphs are cached in an NSCache keyed by everything that determines
the pixels — presentation re-runs for every visible directive on every
keystroke, and rasterising text each time is the one part of this path
expensive enough to matter.

The parser is untouched: this is styling only, and the 4000-input corpus
fingerprint is unchanged. `DirectiveScanner`'s diff is comment-only — it
already emitted the geometry this needs, and its comments pointed forward to
this change.

DirectiveStylingTests asserted that a self-contained call renders as literal
text with nothing collapsing it. That was Phase 1 stating its own limit, and
it is exactly what this changes, so it becomes a collapse/reveal pair rather
than being deleted.

`FontDirective` and `ColorDirective` are both containers and draw no glyph, so
the engine still ships no self-contained directive. Demo/ gains `@icon`,
`@flag`, `@emoji`, and `@pagebreak` as embedder-side examples — curated data
and print semantics are app concerns. `@flag` computes its glyph from
regional-indicator scalars and carries no dataset.

463 tests pass, demo builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 4, the last of the seam. Autocomplete for directive NAMES (`@fo`) and
for their ARGUMENT VALUES (`@icon(sta`, `@flag(jap`), riding the seam the
[[wiki-link]] picker already established: the engine detects the trigger,
ranks the candidates, reports the anchor rect, and routes up/down/return/
escape. The embedder draws the list — no picker UI ships in the engine.

Autocomplete cannot read the AST. Mid-typing, `@ico` and `@icon(sta` are
exactly what the parser REJECTS — no body, no closing paren — which is correct
for styling and useless for completion. So `DirectiveCompletionScanner` is a
separate, forgiving backwards scan over the current line, bounded to 256
characters per caret move. It reuses the parser's boundary rule, so it can
never offer a directive the parser would then refuse.

The engine owns the candidates because it owns the registry. Values come from
`MarkdownDirective.valueCompletions(for:prefix:)`, whose default already
answers anything the declared schema can — closed keyword sets and booleans —
so a directive implements it only when its domain is dynamic or too large to
declare. That is what makes a `@flag`-style command clean rather than special:
the schema was already there. A newly registered directive appears in the
picker with no embedder change.

The commit path is deliberately NOT `applyInlineReplacement` — that runs the
wiki-link storage/display transform and stamps `.wikiLinkID`, neither of which
means anything here.

Pickers stay shut where they must: inside code spans and fenced blocks, on a
selection rather than a caret, when not typing, mid-IME composition, in raw
source mode, and after an email address.

Changes to existing files are 47 lines across 3; detection, commit, scanning,
and the completion types live in their own files. Demo gains a caret-anchored
picker (~60 lines) serving both names and values through one context type.

488 tests pass, demo builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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