Commit Graph

4 Commits

Author SHA1 Message Date
Levi Neuwirth 14c1c01043 feat(math): caret-driven suppression, the draw pass, and the slice acceptance
The two halves that touch live rendering, landed together because the
acceptance criteria that make either honest need both.

Suppression (Q#MS3/MS4/MS5/MS11). Detection runs in the per-line chunk
builder — the chunk-build path, never the edit path — and substitutes
each suppressed span's source bytes with ONE spacer chunk BEFORE tab
expansion, so a literal tab inside a span vanishes with it while tabs
outside keep their SourceTab provenance. The gate reads the EFFECTIVE
caret (own_cursor, which optimistic edits predict forward — F4's
no-flap requirement holds by construction) plus both own-selection
endpoints. Three motion paths can flip a gate without a content
change, and each now re-runs the per-line chunk compare, gated on a
one-scan "does the visible slice hold a $" check: the CursorByte arm,
finish_optimistic_edit (the text re-chunks under the OLD caret there;
without the hook a typed char rendered one keystroke stale), and the
Decorations arm — whose "no decoration change needs a reshape" premise
acquires exactly one exception, the Selection endpoints Q#MS11 made
suppression inputs.

The line-reuse predicate (acceptance 11, the #120 edge). Per-line
math state is cached in lockstep with line_chunk_cache: every detected
span with the gate bit it was built under. The scroll-reuse path
refuses a retained line whose cached bits disagree with the CURRENT
caret/selection — content is unchanged on every reuse path, so the
cached span set is authoritative and the gate bits are the only
variable. The acceptance test drives the stale-gate case through
rebuild_lines_reusing_scroll directly and fails if the gate is removed
from the predicate.

The hit map (B1'). hit_test_source_byte rebuilds its runs from a
whole-slice chunk walk, so it now reads the substitutions BACK from
the per-line caches — never re-planned under a possibly-newer caret —
keeping the map and the shaped glyphs one source of truth.

The draw pass (Q#MS6/MS7). Every MathItem::Glyph draws from its own
mini-buffer with Attrs pinned to the bundled math family (F8b), placed
at layout's exact x and the shaped line's REAL baseline; the
mini-buffer itself is positioned by the line_y cosmic-text actually
produced for it, so no font-metric rederivation can drift. Fraction
rules ride the bg quad batch after the decoration washes and under the
glyphs. Wash geometry gains Q#MS11's intersection rule: a wash
touching a suppressed span widens to the box's whole reserved
rectangle (a match strictly inside the span produced a zero-width
interval before), while the round-3 exclusive-end fix keeps a
non-intersecting wash off the box.

Acceptance (framing §5). Criteria 5-11 and 14-16 run on real pixels
through render_to_view: drawn ink where a literal-spacer control
renders none, with the before-region pixel-identical; the fraction
rule as a full-width run with operand ink both sides; caret-inside
rendering EXACTLY as math-disabled (driven through the real
CursorByte arm, which owns the refresh — a direct helper call would
not have pinned the wiring); every failure mode (unbalanced, unknown
command, $$, uncoverable glyph) pixel-equal to disabled; box clicks
snapping to the span start with the trailing edge landing after the
span; the scroll-reuse stale-gate bite; reflow confined to the
affected line with the after-text shifted by exactly the quantized
projection difference; selection gating and the whole-rectangle wash;
and the licence provenance pair. Criterion 17 is discharged
differentially: cargo tree -e features output for ttf-parser is
byte-identical with and without this crate's dependency line.

Also folded in, per the round-3 close-out: the F6 documenting test
($a$$b$ is eaten by the $$-opaque rule; one separating character
restores both spans), the depth-search bound raised 6 -> 8 so a
metric shift cannot make the "floor is dead code" expect fire with a
misleading message, the MathBox { end, .. } pattern nit, and the
active-work.md lane entry.

Named v0 approximations, deliberate: the peer-caret half of
acceptance 14 is pinned at the mapping level (unit tests), not
pixels; a soft-wrapped spacer draws its box whole at the first run's
origin (the one-rectangle model); the fit budget reads the bundled
code face even under a custom set_font family — the draw anchors to
the real shaped baseline either way, so only the fit margin is
approximate.

Clippy is CLEAN across the workspace at -D warnings for the first
time on this branch: the draw pass consumed every formerly-dead item,
and the three lints it could not fix (a test-only accessor, one doc
string, one manual midpoint) are fixed here.

Gates: cargo fmt --check; cargo clippy --workspace --all-targets
-- -D warnings; 1,815 default + 1,992 CRDT library tests; M4 121
(basedpyright skipped); 199 pmacs-gpu tests under PMACS_REQUIRE_GPU=1;
workspace sweep 3,131 across 88 suites (isolated XDG_CONFIG_HOME);
git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 22:04:35 -04:00
Levi Neuwirth cbf7782726 fix(math): review round 3 — mapping bug, whitespace defect, real MATH gaps
F1 was a real bug pinned by my own committed test. `end` in
ChunkSource::MathBox is EXCLUSIVE, so source position `end` is the first byte
AFTER the span — but the arm claimed it for the box's left edge, and the test
asserted that wrong value while calling the byte "interior". Consequences it
would have caused once overlays land: a search match starting just after a
span washes the whole box it does not intersect, violating Q#MS11; a peer
caret after the span draws at the box's left edge; caret geometry jumps
backwards. The same class existed in projected_to_source for a line-FINAL box,
where `within` clamps to the run length and the arm returned `start`
unconditionally, so a click past end-of-line landed on the span start. Both
committed hit tests put a chunk after the box, so that edge was never
exercised; there is now a test with the box last.

F2: parse_scripts peeked for the next marker without skipping whitespace, so
`x^2 _i` built a NESTED script — drawing the subscript displaced right by the
superscript's width — and `x^2 ^3` parsed where TeX errors, contradicting the
module's own "whitespace is insignificant" rule.

F3: layout is now fallible. A character the math font cannot draw used to
yield zero metrics and still emit a Glyph item, rendering tofu at zero advance
over its neighbour. Q#MS8's rule is "failure is always show the source", and
the draw pass needs a refusal signal — changed now, before that pass consumes
the API.

F4: the fraction gap was a hardcoded `thickness * 2.0` while the MATH table's
FractionNumeratorGapMin / FractionDenominatorGapMin went unread. Reading them
moved the flagship \frac{a}{b} from 0.732 to 0.867 and the fallback boundary
from depth 3 to depth 5. The round-2 review's hand-arithmetic estimate of
~0.85 was right; my 0.732 was inflated by the guess. The depth-SEARCHING test
absorbed the change without edits, which is the property it was written for.

F5: TeX's \epsilon and \phi are the lunate/symbol forms (U+03F5, U+03D5), not
U+03B5/U+03C6. Their italic mappings had to land with the seed change, since
both sit outside math_italic's U+03B1..03C9 run and would otherwise render
upright beside italic neighbours.

F7: the line-box budget derivation moved out of the test into
`line_box_budget`, so the draw pass and the acceptance test cannot compute
different splits while both stay green.

F8: the live-code clippy items are cleared. The 25 that remain are all
dead-code awaiting the draw pass.

189 pmacs-gpu tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 20:19:40 -04:00
Levi Neuwirth 8c25e0ddde feat(math): currency-guarded inline span detection
Per-line `$…$` scanning with the Pandoc guards F5 required: an opener must be
followed by a non-space, a closer must be preceded by a non-space and not
followed by a digit, and `\$` escapes. Without them "prices are $5 and $6
today" renders "5 and " as math, in exactly the grammar-less prose buffers
this scanner targets. Spans never cross a newline (Q#MS3), so callers scan one
line at a time.

`$$` is opaque, and the test that forced this is worth keeping in mind. My
first version simply refused to OPEN on `$$`, reasoning that display math
would then never match. It still did: in `$$x$$` the first `$` declines to
open, the second one opens, and the third closes it — matching the inner `$x$`,
whose interior parses perfectly well. So display math would have half-rendered
as math with a stray `$` on each side, which is precisely what acceptance 15
forbids. `$$` now neither opens nor closes and abandons any pending opener.

The test that caught it was itself nearly vacuous. It asserted "every span
found must fail to parse", which passes trivially when the matched interior is
`x`. Asserting that NO span is found is the form that actually holds the
contract; the weaker version would have shipped the bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 18:47:48 -04:00
Levi Neuwirth b9aed61e23 feat(math): LaTeX math parser for the slice subset (Tier 2)
pmacs-gpu/src/math_parse.rs — the Q#MS2 subset: characters, groups,
sub/superscripts in either order, and \frac, plus the Greek seed map.
Everything outside the subset is a typed error, which Q#MS8 turns into
"render the raw source".

The AST is semantic, not presentational: \alpha resolves to 'α' here, but the
math-italic mapping stays in layout, where a codepoint becomes a glyph.
Baking italics into the AST would make the tree disagree with the source and
would have to be unpicked by any later non-italic style context.

Two bugs the tests caught before they could reach layout, both from skipping
whitespace in the wrong place. `parse_atom` consumed it, but the ^/_ dispatch
happens in `parse_sequence` BEFORE atoms are read — so `x ^ 2` parsed the
caret as a literal character, and an all-whitespace span produced an empty
group instead of the Empty error. Whitespace is now skipped at the dispatch
point, which fixes both at one seam.

Interior `$` is rejected explicitly so `$$x$$` degrades through the error path
(acceptance 15) rather than half-rendering.

Clippy reports MathNode as dead code, which is correct and expected: the
parser has no consumer until Tier 3 layout lands. That is exactly the
condition Q#LX5 refused to ship, now enforced mechanically. It is not
suppressed; it clears when layout arrives in this same branch.

11 unit tests, no GPU or font required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 18:14:43 -04:00