Skip to content

Perf/typing hotpath main#96

Open
luca-chen198 wants to merge 38 commits into
mainfrom
perf/typing-hotpath-main
Open

Perf/typing hotpath main#96
luca-chen198 wants to merge 38 commits into
mainfrom
perf/typing-hotpath-main

Conversation

@luca-chen198

Copy link
Copy Markdown
Member

No description provided.

luca-chen198 and others added 30 commits July 11, 2026 16:06
Prints one line per keystroke with a per-phase breakdown (wiki, backtick,
parse, activeTok, latexMap, restyle, codeSel, overscroll) plus the document
length, so typing costs that scale with file size become visible. Deep notes
count wasted layout fragments and re-rendered tables. Debug-only (compiled out
in Release; silence with env MD_PERF=0). Remove after the perf work lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six-task TDD plan to make per-keystroke cost O(1) in document length, based
on the multi-agent audit + measured PerfTrace baseline (139k doc ~12.8ms/key).

Note: /docs is gitignored in this repo; force-added onto this feature branch
per request. Drop before merging to main if the convention should hold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ance

Every keystroke re-rendered every inactive table to a fresh NSImage
(7-17ms with 10 tables). Table pixels depend only on source, font,
colors and appearance, so identical keys now reuse the cached image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
makeStorageState rescanned and rebuilt the whole document per keystroke
once any [[ existed (3.4ms at 139k chars). Edits that provably cannot
touch link syntax now splice into the previous storage string in
O(edit + links); anything ambiguous falls back to the full rebuild.
DEBUG samples every 64th keystroke against the full rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
components(separatedBy:) allocated a whole-document substring array per
keystroke just to count fences. One allocation-free scan replaces it,
and textDidChange bridges tv.string exactly once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cument head

Enumerating from documentRange.location with .ensuresLayout laid out
every fragment above the viewport on each keystroke - O(caret position).
Starting at viewportRange keeps the walk O(viewport).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The parse cache compared the whole document string on every call -
O(doc) per keystroke AND per caret move. A generation counter bumped on
every storage edit makes hits O(1), with the string compare kept as a
correctness fallback. BlockParser now reuses the UTF-16 buffer the
tokenizer already extracted instead of re-extracting it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review of the rebase onto main (clipboard pipeline) confirmed:

1. Smart-input interceptors (auto-pair, ->-arrow, Tab indent, list-exit,
   $$-wrap) suppress the typed keystroke and perform a different
   programmatic edit, but the suppressed edit's pendingEditedRange was
   left as the wiki splice descriptor - silently diverging the storage
   form (and the saved file) from the display. Refresh the descriptor
   for programmatic edits too.

2. The table-image cache keyed colors by NSColor description, which is
   not an identity (named dynamic colors collide, unnamed never hit) and
   omitted mutedText/highlightColor/latex inputs renderTable draws with.
   Key on appearance-resolved sRGB components of every color used, plus
   the latex renderer type.

3. IME composition updates (setMarkedText) mutate the storage without
   textDidChange while shouldChangeTextIn's own parse re-caches the
   pre-edit string at the current generation - same-length updates then
   restyled from a stale parse. Bump the generation in setMarkedText.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ick census

Live profiling decomposed the remaining ~6.5ms parse at 139k chars:
59% was one prefix/suffix diff scan run TWICE (BlockParser and the
tokenizer each did their own), plus a full-document String == in the
parse-cache verify that cost ~6ms per keystroke on its own.

- DocumentParseState: per-editor buffer + blocks + tokens evolve under
  ONE edit descriptor; steady-state typing splices the UTF-16 buffer in
  O(edit) and re-tokenizes only the touched block window. Trust gate:
  exactly one proposed edit per cycle; anything else (interceptor
  substitutions, IME commits, WT batches) falls back to a single shared
  scan, and a sampled DEBUG assert re-extracts every 64th keystroke.
- The state publishes each result into the static BlockParser/tokenizer
  memos, so the restyle's DocumentAST.parse hits via memcmp instead of
  re-splicing against a one-keystroke-stale cache (~1.5ms saved).
- parsedDocument's verify compares via NSString.isEqual (byte compare)
  instead of bridged String == (~6ms -> <1ms), and the selection-change
  pre-parse hands the pending descriptor through.
- Backtick census updates from the edited window only: the greedy count
  is exactly the sum of floor(runLen/3) over maximal backtick runs, so
  expanding the window through adjacent backticks composes exactly.
  Reseeded per document; IME compositions force a full rescan.
- Table image cache: resolve theme colors once per theme+appearance
  (identity-memoized), not six times per table per keystroke.

Fuzz suite: after every random edit (descriptor-driven, scan-driven,
widened descriptors) tokens must equal a from-scratch parse; census
composition fuzzed incl. cross-boundary fence merges.

Measured at 139k chars (Debug): total 10.5 -> 3.6-4.8ms per keystroke
(parse 6.0 -> 0.7-0.95, backtick 0.95 -> 0.00, restyle 3.5 -> 2.0-2.4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ote)

Debug-only PerfTrace additions for the table upward-shift hunt; remove
with PerfTrace after sign-off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ettle before caret reveals

Reverts the viewport-scoped walk (1ea2712). Every fragment's Y is the sum
of the heights above it, so leaving above/below-viewport fragments
estimate-only broke the invariant that visible geometry is settled by
draw time: TextKit estimates an invalidated table paragraph as a single
text line (~250pt short), which made content shift upward while typing,
made the height measure bistable (frame pumping 5231<->4735 per
keystroke), and fed the caret reveal wrong out-of-view verdicts that
actively scrolled up. Live-diagnosed via docH/scrollY/caret traces.

The reveal keeps one hardening from that hunt: an out-of-view verdict is
re-checked against layout settled up to the caret before scrolling, so
genuine jumps (search hits) land on the correct target.

Steady-state cost of the head walk is an enumeration over already-laid
fragments (~1ms at 139k chars); the big hot-path wins (parse splice,
census, table image cache) are layout-neutral and unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scrollJump watcher, FULL-RESTYLE/REBUILD/BIG-APPLY prints, reveal print.
Debug-only; remove with PerfTrace after sign-off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…derivations

Live profiling of the residual restyle cost (2-2.5ms at 139k, 6-9ms in
table docs) found the work was AROUND the already-scoped styler, not in it:

- The five NSImage passes (block/inline LaTeX, image links/embeds,
  tables) walked EVERY document token per keystroke even though attribute
  application clips to the candidate paragraphs. StylingContext now
  carries the scope bounds and each pass skips tokens outside them.
  Table-doc styleTables: 3.6-9ms -> 0.35-0.9ms (out-of-scope tables skip
  the render lookup and anchor build entirely).
- DocumentAST.parse's scoped-block filter became a sorted sweep over
  candidates instead of scanning every candidate per block (it went
  quadratic in formula-rich docs).
- Table parse + content-hash memoized (tableMeta) instead of recomputed
  for every table every keystroke.
- ParsedDocument gained a version stamp (bumped only on FRESH parses);
  computeActiveTokenIndices is memoized on (version, selection,
  suppressed) — it ran 3x/keystroke on identical inputs, now ~0ms.
- updateCodeBlockSelection takes the indexed code blocks from the
  classification pass (no per-call full-token filter) and dedupes
  identical emits. Key uses the FULL active-token set: a caret move into
  a standalone block above a code block toggles that block's height and
  shifts the code block — same version/scroll/width — so keying on only
  the code intersection would leave the copy-button overlay stale
  (adversarial review, confirmed 2/2).
- editedTableParagraphs filters the classified table tokens with an early
  exit; TextStylingService.normalize dedupes in one O(n) pass.

Measured (Debug): 139k doc ~4.5 -> ~3ms/keystroke; table doc styleTables
~9 -> <1ms. All 206 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…; strip session diagnostics

textDidChange bumped parseGeneration a second time per keystroke (after
shouldChangeTextIn's bump), which forced parsedDocument off its O(1)
generation hit onto the O(doc) byte-compare VERIFY (~0.7ms at 139k). For
a trusted length-changing edit the length alone already invalidated the
pre-edit cache and the selection-change re-parsed the post-edit text at
the current generation, so the bump is skipped and the hit is O(1).
Same-length and untracked edits still bump so the byte-compare keeps
catching same-length content changes; undo/IME/bypass paths unaffected
(adversarial review: only an over-strict DEBUG assert was flagged, removed).

Measured (Debug): 139k doc parse 0.7 -> 0.0ms, total ~3 -> ~2.3ms/keystroke
(short-file ~1.1ms). Also removes the temporary height/flicker/parse-timing
diagnostics added during the investigation; PerfTrace itself stays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Feed the styler's five NSImage passes (block/inline LaTeX, image embeds,
image links, tables) a pre-classified per-kind indexed-token bundle
(ClassifiedStyleTokens) built once in the parse classification, so each
pass binary-searches its own small array to the restyle scope instead of
walking every document token via enumerate+filter every keystroke.

- MarkdownStyler.StylingContext: IndexedToken, ClassifiedStyleTokens,
  scoped() binary search over the location-sorted per-kind arrays.
- Latex/Images passes iterate ctx.scoped(ctx.<kind>Indexed); styleTables
  iterates the classified table array (still a full occurrence scan).
- Raise table metadata/image cache caps above a document's table count so
  a table-dense doc stops thrashing (Belady-pessimal FIFO) each keystroke.
- Viewport-cull updateCodeBlockSelection to the visible range.
- Scope the textDidChange latex/image restyle candidates to the edit.

Retains the per-keystroke PerfTrace timing splits (styleAttributes / restyle
split / styleTables substring+meta) as WIP diagnostics on this branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…full-rebuild verifiers behind MD_PERF_VERIFY

The printed per-keystroke totals were misleading in two ways:

- The keystroke's real work starts in shouldChangeTextIn (pre-edit parse,
  smart-input interceptors — every space/Enter walks this path) and the
  first post-edit parse runs in the mid-edit selection change; both ran
  BEFORE PerfTrace.begin, so textDidChange's 'parse' span showed only the
  O(1) cache hit. The frame now opens in shouldChangeTextIn, begin()
  continues an already-open recent frame, and preParse/smartInput/
  selStates/selParse/selRestyle spans attach to it.

- Three sampled every-64th-keystroke verifier asserts (wiki splice,
  backtick census, spliced parse buffer) each ran full O(doc) rebuilds
  synchronously in the keystroke turn — periodic spikes in the very
  numbers under investigation. They are now opt-in via MD_PERF_VERIFY=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e, not an O(doc) scan per space/Enter

Every space/Enter/Tab/>/[/(/{ fell through the list handler's fast path
into `textView.string.contains("`")` — a full bridge copy + scan of the
document — and, once any backtick existed anywhere, a full tokenizer pass
via MarkdownDetection.isInsideCodeBlock(location:in:). All of it BEFORE
PerfTrace.begin, so it never showed up in the printed totals.

The coordinator already parses the pre-edit document in shouldChangeTextIn;
it now threads parsed.codeTokens down (MarkdownInputHandler → MarkdownLists)
and the handler binary answer costs O(#code tokens). Direct callers without
a parse keep the old derivation (isInsideCodeBlock: nil fallback).

Also: the Enter-at-fence-opener completion counted ``` occurrences via
components(separatedBy:) — an O(doc) substring-array allocation — and a
bridged .contains for the closing check; both are now allocation-free
NSString range(of:) scans.

Behavior locked by ListHandlerCodeContextTests (7 characterization tests
through the production delegate path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bridge per delegate event

Two O(doc)-per-keystroke costs in the delegate callbacks, both memcpy-class
but unconditional:

- shouldChangeTextIn bumped parseGeneration first and parsed the pre-edit
  text after, so the parse always missed the O(1) generation check and fell
  onto the O(doc) NSString.isEqual byte-compare. The text at that point is
  still pre-edit — identical to what the previous cycle cached — so parsing
  BEFORE the bump O(1)-hits. The bump (and the descriptor refresh) still
  runs for every proposed edit, including programmatic/WT/raw ones.

- shouldChangeTextIn and textViewDidChangeSelection re-read tv.string at
  ~15 separate sites per keystroke; each read is an O(doc) copy of the
  mutable backing store (the 40c8508 hoist only covered textDidChange).
  Both now bridge once and pass the hoisted string/NSString through
  (updateSelectionStates takes it as a parameter; the wiki snap-back
  branch keeps its explicit re-reads because it mutates the text).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… edit-scoped token lookups

textViewDidChangeSelection mapped EVERY latex/blockLatex/imageEmbed token
in the document to its paragraph on every selection change — including the
mid-keystroke one whose restyle is skipped anyway — and fed them all into
each caret-move restyle, widening scopeBounds to the whole document and
defeating the styler's per-pass culling (O(#formulas) per caret move; the
formula-rich perf docs hit this hard).

- Candidates are now built only when the restyle actually runs.
- The formula map covers only tokens inside the caret/previous-caret
  paragraphs (rendered↔raw flips of entered/left tokens were already
  handled by tokenRestyleParagraphs).
- The edit-scoped token lookups in textDidChange (latexMap, edited
  tables) and the styler's scope culling now share one binary-searched
  slice helper (MarkdownStyler.scopedSlice) instead of walking each
  per-kind array linearly from the document head.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-restyle buffer re-extraction

Every restyle ran MarkdownASTStyler → DocumentAST.parse → BlockParser.parse
with no buffer, so even a cache HIT first allocated a doc-length [unichar],
getCharacters-copied the whole document, and memcmp'd it against the seeded
cache — O(doc) per keystroke that grew with file size and showed up inside
the 'restyle' span.

ParsedDocument now carries the block list its tokens were derived from
(DocumentParseState.currentBlocks), and the restyle hands it down as
precomputedBlocks (restyleTextView → TextStylingService → MarkdownStyler →
MarkdownASTStyler → DocumentAST.parse), skipping BlockParser.parse
entirely. Direct callers without a parse (initial load, appearance change,
tests) keep the derive-it-here path.

restyleParagraphs also bridged tv.string twice per call; hoisted to one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…te for out-of-scope unique tables

styleTables visited EVERY table in the document per keystroke: a full
source substring, a dictionary lookup keyed by that string (hashing all
its bytes even on a hit), and a (token.range, .spellingState: 0) emission
— which TextStylingService applies UNCLIPPED, so every keystroke wrote
attributes over every table in the document (attribute edits can also
invalidate TextKit-2 layout fragments, coupling this into the
ensureVisibleLayout head-walk).

Equal content implies equal source length, so a table whose length is
unique in the document can never affect a duplicate's occurrence index.
Inactive, out-of-scope, unique-length tables — in a typical document, all
of them while typing prose — are now skipped before any per-table work.
Same-length tables stay on the full path to keep duplicate sourceIDs
stable; the PERF note reports skipped=N.

Locked by TableScopeSkipTests (skip, in-scope emission, duplicate
bookkeeping).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A smart-input interceptor (Enter-in-list, auto-pair, Tab indent, '->'
substitution, $$-wrap, [[-completion, fence auto-close) suppresses the
typed key and performs one programmatic edit. That left pendingEditCount
at 2, so textDidChange distrusted the descriptor and fell back to a full
O(doc) backtick rescan plus a descriptorless O(doc) diff parse — on some
of the most common keystrokes there are.

The suppressed keystroke never applies, so performEdit /
insertTextProgrammatically now drop its pending count before the
programmatic edit registers; shouldChangeTextIn refreshes the descriptor
for that edit (60faf0b), which describes the applied transition exactly,
and the cycle stays a trusted single tracked edit.

InterceptorTrustTests pin the trust flag per interceptor (via a
Debug-only debugLastEditWasTrusted diagnostic); the pre-existing
InterceptorStorageSyncTests prove storage correctness on the now-trusted
path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…full-reparsing every keystroke

Typing INSIDE a fenced code or $$ block was the biggest remaining
per-keystroke cliff: incrementalParse unconditionally bailed whenever the
±1-block window contained a fencedCode/blockLatex block, so every
keystroke in a code block ran computeBlocks over the whole document plus
a fullTokens pass over every block.

The bail is redundant with the existing guards: the ±3 delimiter check
(old AND new buffer) already bails on any edit that creates, destroys, or
touches a ```/$$ pairing, and an edit that un-closes a block by adding
trailing chars on its closer line makes the reparsed block reach the
window end — the trailing guard bails there. Interior edits reparse the
whole opaque block inside the window, which is sound because windows are
whole blocks.

Pinned by FenceInteriorIncrementalTests: direct splice equivalence for
fence + $$ interiors, the un-closing edge, and a 4-seed fence-heavy
differential fuzz (300 random edits each) against the from-scratch parse,
on top of the existing ParseIncrementalEquivalenceTests suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilar-tables doc skipped nothing

Live measurement (682k chars, 533 near-identical tables) showed skipped=0
with substring+meta at 3.35ms/keystroke: the unique-length heuristic never
fires when tables share length classes, which generated/templated tables
almost always do.

A sourceID is only CONSUMED by a table that renders this pass (inactive +
in scope), and equal content implies equal length — so only tables sharing
a length with a RENDERING table need their hash for occurrence stability.
Typing prose renders no table, so now every table skips (styledRanges and
the unclipped spellingState writes drop with it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ate split

The incremental machinery itself showed up at selParse=6.77ms in a 682k-char
document: the affected-block window in BlockParser.incrementalParse and the
touched-block scan in incrementalTokens walked the block list linearly —
O(#blocks) client code per keystroke (tens of thousands of blocks at 100k
words, -Onone). Blocks tile in order, so both are binary searches now.

Also: PerfTrace note with the parse-state split (buffer / blocks / tokens,
splice-vs-FULL mode, block+token counts) so the next live measurement
pinpoints what remains — the O(#tokens) widen/prefix/suffix passes are the
known Phase-3 (relative ranges) target.

Covered by the existing differential fuzz suites (ParseIncremental-
EquivalenceTests + FenceInteriorIncrementalTests, 250-300 random edits per
seed against ground truth).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rs flipped past the ±3 window

Adversarial review of a479348 found (and reproduced standalone) a real
divergence: isBlockLatexOpen matches the TRIMMED line prefix, so an edit
in the leading whitespace of an indented '   $$' opener flips its pairing
while sitting more than 3 chars from the literal $$ — the old ±3 guard
passed, the window splice kept the stale block, and the wrong parse
persisted through the seeded caches (both insert and delete directions).

hasBlockDelimiter now expands the scan to the full LINES the edit touches
(delimiters are line-classified), with a capped boundary walk that reports
a delimiter when the cap is hit (conservative full parse). Pinned by two
regression tests with the exact repro; the fence-heavy fuzz doc gained
indented $$ blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lbacks + overscroll detail + other=

The 682k live frames grew to ~47ms with ~32ms outside every span. New
DEBUG-only accounting to pin it down:

- PerfTrace.accumulate sums repeated in-AppKit work under one +label
  (caret reveal, spell-checker callbacks) and end() prints other= (total
  minus everything measured).
- scrollRangeToVisible and its doc-start→caret settle layout accumulate
  as +reveal / +revealSettle — the settle is O(caret depth) REAL layout
  and runs only on out-of-view verdicts, invisible until now.
- setSpellingState accumulates as +spellCb (full-document contains scans
  per checker callback).
- recalcOverscroll notes fullLayout/hChanged/osChanged per keystroke — a
  persistent fullLayout=1 with flipping heights is the bistable-height
  loop paying a FULL document ensureLayout inside the overscroll span.
- selActive/selAuto/selCtx spans + classify note close the selection-
  change accounting gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…scan

other=30ms survives with the engine back at the fast state — the cost sits
BETWEEN our delegate callbacks, not in them. @shouldOut/@selIn/@selOut/
@didin checkpoints (offsets from frame start) locate which AppKit gap eats
it: edit application (shouldOut→selIn), or the didChangeText machinery
(selOut→didIn). The wide-table presence scan (a full attribute-run walk on
every restyle when the doc has NO wide table) gets a ⏱ stamp when >0.3ms —
it runs between keystrokes and contributes felt lag without appearing in
any frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mulated spans

The checkpoints located the missing ~25ms precisely: between shouldOut and
selIn — AppKit's edit application (replaceCharacters → processEditing →
TextKit-2 sync/layout). Our only code in that window besides O(1) hooks is
the layout-fragment provider, which pays a font-metrics lookup + paragraph
style + NSDictionary per created fragment; if invalidation makes TextKit
recreate hundreds of fragments per keystroke, that's the gap. +fragProv=…(×n)
will show it. Accumulated spans now print call counts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n, fix CI cache collision

- Reduce the five typing-perf test files to their main behaviors (fence/$$
  interior splice + the indented-$$ regression; interceptor trust flag;
  table scope-skip; precomputed blocks; list-handler code context). The
  removed edge/variant cases and the redundant fuzz are covered by the
  pre-existing ParseIncrementalEquivalenceTests + InterceptorStorageSyncTests.
- Fix the failing CI test (TableImageCacheTests.secondRequestIsServedFromCache):
  the old TableScopeSkipTests rendered the same '| alpha | beta |' table,
  warming the shared static tableImageCache so the cache test's first request
  was no longer a fresh render under parallel CI ordering. The trimmed table
  tests use unique sources (kappa/lambda, mu/nu).
- Delete the implementation plan (docs/superpowers/plans/2026-07-07-typing-perf-hotpath.md).

217 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
luca-chen198 and others added 8 commits July 13, 2026 21:21
…g sideways

Long sentences in a cell used to force the whole table wide (one line per
cell), pushing every table into the horizontal-scroll overlay. Now, when
the natural column widths exceed the available container width, columns
share the width proportionally (floored at ~3.5em each, never above their
natural width) and cells word-wrap onto extra lines — Obsidian-style.
Rows size individually to their tallest wrapped cell.

- renderTable/tableImage take availableWidth; it is part of the image
  cache key (layout depends on it), so appearance/width changes render
  fresh while repeated same-width restyles stay cached.
- Tables whose per-column floors genuinely don't fit (many columns) still
  render wider than the container and keep the scrollable-overlay path.
- Known limit: without a reading column, a window resize doesn't re-render
  already-styled non-wide tables until their next restyle (the fixed-width
  reading column — Nodes — is unaffected).

TableWrappingTests pin: wraps to width, height grows when narrower, small
tables keep natural width, width is a cache-key dimension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, horizontal scroll when minimums don't fit

Refines the cell-wrapping layout per the CSS automatic table layout
algorithm (W3C 17.5.2.2): column min = widest whitespace-separated
segment (measured per segment — a too-narrow boundingRect emergency-
breaks INSIDE words and understates the minimum), column max = one-line
width. Available width is distributed proportionally to (max−min) above
the minimums; when even the minimums don't fit (many-column tables), the
table stays wide and the horizontal-scroll overlay takes over. Demo
gains a Tables section showing both cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… dot under rendered tables

Live-diagnosed (🔴 SPELL value=1 inTable=true APPLIED text="Setapp"):
the checker painted red squiggles onto words in table SOURCE, which is
collapsed to a ~1pt line under the rendered image — showing up as a
stray red dot below the table. Tables were the one rendered token kind
missing from isInsideSpellcheckSuppressedToken (code/latex/links were
covered); the per-keystroke doc-wide spellingState scrub that used to
hide this went away with the table-skip optimization.

The suspected typing-lag from the earlier attempt at this fix was since
proven to be TextKit-internal element shifting, unrelated to this path
(+spellCb accumulate shows the callback cost directly if ever in doubt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The overlay is a control surface (rendered table image + horizontal
scroller), not text, but the text view's tracking areas are not
occlusion-aware — super kept setting the I-beam through the overlay on
every mouse move. Mirrors the existing task-checkbox suppression branch
(skip super entirely; setting the arrow after it flickers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The one-line .table fix stays (6efe6b7); the temp 🔴 SPELL logging served
its purpose (live-proved the squiggle-on-collapsed-table-source cause).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…croller

Only the scroller strip is a control surface — over the rendered table
image the normal text-cursor behavior stays.

Co-Authored-By: Claude Fable 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.

1 participant