From fe65fddae5b50d12d042521e5161e3983d5a1b80 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 15 Jul 2026 17:20:50 +0100 Subject: [PATCH] test(font): GPU acceptance for apply_font_facts + caret substrate Twenty-one new headless tests covering the GPU-side acceptance items of docs/gpu-set-font-framing.md (9-14, 16-19): - 9: size route re-derives metrics; (None, None) reset reproduces the never-set frame byte-for-byte - 10: 600/7200 bounds render; minibuffer dropdown windows its rows above the band at the derived row height; context menu keeps the clipped route with coherent hit geometry - 11: wrapped caret survives 16->72->6 px; an optimistic insertion that wraps a new bottom row follows immediately and its confirming CursorByte needs no second repair; overscan caret never snaps the viewport; narrowing resize re-follows via the coarse + visual-run + fold pipeline; adornment projection shifts the caret past injected text with left gravity at the anchor; the CursorByte arm follows into a wrapped run as a normalized sub-line residual; explicit scrolls clear the residual (including wheel-up at the clamp edge); the minibuffer suspends the follow decision - 4 (GPU half): BufferSnapshot resets the residual while the font preference/metrics survive -- the replacement buffer wraps too, so the EOF clamp cannot mask a leaked residual - 12: unknown + proportional families fall back; the four-style gate rejects a proportional BOLD sibling the normal-only query would accept; a valid second monospace family resolves, changes the ink, and resets cleanly; the parameterized sanitizer removes exactly the same-family proportional collision - 13: the "\0" sentinel defeats the status string-equality gates - 14: a shrinking visible slice re-declares the scoped viewport - 16: 0/599/7201/u32::MAX reject the whole message, state untouched - 17: metrics + drawable dimensions atomic on all seven buffers, resize symmetric via Buffer::size() - 18: popup rows never wrap (Wrap::None + unique line_i per run) - 19: with line numbers and a context menu open over an EMPTY buffer, the measured probe advance drives gutter reservation and menu hit width (fixture ratio exactly 1.2 vs the bundled default); reset restores the exact geometry and frame Family routing is hermetic: four generated fixture faces under pmacs-gpu/fonts/test/ (second monospace at 720/1000, a proportional, and a "Pmacs Test Family" whose NORMAL face is monospaced but whose BOLD face is proportional), built by the committed generate.py -- provenance and license in LICENSE.txt beside them. fontdb requires a PostScript name (nameID 6), which the generator sets explicitly. assemble() now routes construction through sync_buffer_dimensions too: shape_until_cursor and wrapping must use the painter's clip from the first frame -- a v16 daemon never sends the FontFacts that would sync them later (found by the CursorByte-follow test: the caret parked in the status band at surface-height dims). Every protection was bitten (temporarily reverted, its test observed failing, then restored): wire validation, CursorByte visual-run follow, painted-before gate, projection inversion, Wrap::None, four-style gate, sanitizer, status sentinel, normalize fold, scroll_by_lines residual clear, BufferSnapshot residual reset, the all-seven dimension helper, and the optimistic-completion follow. The snapshot-reset test was strengthened after its first bite did NOT fail (a short replacement buffer let cosmic's EOF clamp zero the residual anyway). PMACS_REQUIRE_GPU suite 90/90; clippy -D warnings and fmt clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- pmacs-gpu/fonts/test/LICENSE.txt | 6 + pmacs-gpu/fonts/test/PmacsTestFamily-Bold.ttf | Bin 0 -> 2256 bytes .../fonts/test/PmacsTestFamily-Regular.ttf | Bin 0 -> 2212 bytes .../fonts/test/PmacsTestMonoTwo-Regular.ttf | Bin 0 -> 2220 bytes .../test/PmacsTestProportional-Regular.ttf | Bin 0 -> 2308 bytes pmacs-gpu/fonts/test/generate.py | 138 +++ pmacs-gpu/src/main.rs | 874 ++++++++++++++++++ 7 files changed, 1018 insertions(+) create mode 100644 pmacs-gpu/fonts/test/LICENSE.txt create mode 100644 pmacs-gpu/fonts/test/PmacsTestFamily-Bold.ttf create mode 100644 pmacs-gpu/fonts/test/PmacsTestFamily-Regular.ttf create mode 100644 pmacs-gpu/fonts/test/PmacsTestMonoTwo-Regular.ttf create mode 100644 pmacs-gpu/fonts/test/PmacsTestProportional-Regular.ttf create mode 100644 pmacs-gpu/fonts/test/generate.py diff --git a/pmacs-gpu/fonts/test/LICENSE.txt b/pmacs-gpu/fonts/test/LICENSE.txt new file mode 100644 index 0000000..2531812 --- /dev/null +++ b/pmacs-gpu/fonts/test/LICENSE.txt @@ -0,0 +1,6 @@ +The Pmacs Test* font fixtures in this directory are trivial synthetic +faces (rectangle glyphs over space, digits, and a-z) generated for the +pmacs-gpu test suite by the accompanying generate.py script. They are +original to the pmacs project, contain no third-party outlines or +data, and are released under the same license as the pmacs source +tree. They are test fixtures, not usable typefaces. diff --git a/pmacs-gpu/fonts/test/PmacsTestFamily-Bold.ttf b/pmacs-gpu/fonts/test/PmacsTestFamily-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..328f7718aebeaba689e911d75c8603768f8807c8 GIT binary patch literal 2256 zcmd^=U2IfE6vxlGx7`+Wi;Kk%Z0jxHifNIxg?pjhKw3*9RgeT?XoTpdE#1m)+hmLV zh$h5nqYuUxYluG5q!0Sm4Iz=lSVDp^F=+$%pbs<>!;{4iUQ9aI|Li@xGcQEnoz4HA zng4I@Idf`-R#`J$Qv`wH^2FsiRP2hhI*?a1aQrd~Q-3&tN}d?7bBkl7qhBys;B#z*s+ zsd3s#G4NW9z0pj5=Q6DE*(*ig}$-AJh+Jp-E^KnuF${MQ90H zhHgV^kizrFp$4c0>VOVIsE>U~J8wfDK%YW-x3DzGh3awDPgajTNFmnZqnSthX(kLy zy%c4_FkB5+!Y^efVWY+I*-FpS9_pf_^g3nf9KA=M&=<5oi}VeBPe0-}^E)Yih@aqR zxQ!3;E8Nd#xWMQ6WB!~MxX9n~5Bw9dv0j(cfbRqAYHv<7 zlRaopY)==9l^vka@~>X4_*`fCD=WrtwEWdoY5?5HtAEB44Jb@zgjS4-X>Xf!H`9pOsbCzS+dEjnlWVA zCUfenA-8QZuTB}VW|KvA#1LhZC3T=eoVZPv)ow!`_+~gQ_C0GVVeaX$iBgUshb`j7 z*Z(x+q)i&u?--&_;PSiaw5;FMM7J2;etm}MUqzyH)qMviN<#b?&X+Cvq^a5fXB#~y zeFvw_%hHeGU*q_(d}g5Fofs-ic)gkYNN&1&EH_v(wHH~tv`5D%PZ=7Z0*^7xX+mps1Y@gp^ceY2od&}CTHk9HD?6J2%<%_ zD+p@SqE!?X)TTwWszolNBBBTqB%-~v%GbH`&P>v#O^X)Z$GzV@-?{fY=iWPWKmf{d z2|A1;$GW=Xh@S_jgW1yw*Pg`*3b6fw?V%|rcQ$?D@G77=fc_$tw9j-!*B03Kkh_^; zA~>ks;`s{qwv?M)D0>{p^8TxAk2*66yXJWK9Pe*pd&IRDX3>El`%Bq2rfoOb@?^XP z2u9idG&_^YF1>Fa1VR0#% zClKT8pz`}CA4{^D&b^jLl~FC{uI{yzKjkyfk44YR&!VtB&s+B1cn{^pp2PTbje&p6^_b0h`pE`b?rpP7*A%h#<1(&=muV35w$Lf4lRA=#s zm3C4a?V|S3{B$}R?T-4`Q^0*emc+Ltz9sQ3iEj-6zX00-oSFat literal 0 HcmV?d00001 diff --git a/pmacs-gpu/fonts/test/PmacsTestMonoTwo-Regular.ttf b/pmacs-gpu/fonts/test/PmacsTestMonoTwo-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c2db287a52a98348fed2bf9cb6419cff6a07c12b GIT binary patch literal 2220 zcmeHHO-NKx6#mY8l*{#0gGZ_-Q%Yf^hpVW2dvO`4d75TTE0)M$Q0NBn^(T12~o zq*aTSg|&4Nt!j~*Kq4ZDKtUo}2GJ^~@69`-(WXs{7T(9b-}$@WIrrX~0|8Kkn=oOW zzi_On3thE|5=Xvq6! zoTa?RSvQtQ&lC;^(%gTCdT%@xb;}3Ju5o`2^`3+~GmZuX=`WyeCEY~qM%y`Z2gB6Y z##58&rT6^)@eoh2h*wy~Cw#>Z2}qgjmm|_DUD78*5|>%IE%)V-Jd@}0THeVj*@P$K z+w*l;S97Q=6p@v6%X=)};zsv@&2_H+0~N` zsB)0KXrwpcMklTQ*ks!3P9;-T|8%M^HaZn|C$b*T z;n`_!R6BZ+fQu+5*}wV`Bc>^JBZVZVXPYMW(KE_k6=&?kZ+mh(a`Aq2;~()}cibHQ znKpIsL=ZzvcD+0aO1>0Gp%lqB*)GLWBBio}-LPCLq*8XuF4-+rvPY_AuhhsscF6-` zNk|S#t<=dOsn4HEUJZwv!ag?V@VM$tip`2GiYF9pMMtq!vCT(Yd2HpemB&^dTX}5d zv6aVG9y`a=u6!MerxiODyA;p(=;%2d&F^S_NAo+H-_iVz=65u|qxl`p@3aHI0MMP3 AnE(I) literal 0 HcmV?d00001 diff --git a/pmacs-gpu/fonts/test/PmacsTestProportional-Regular.ttf b/pmacs-gpu/fonts/test/PmacsTestProportional-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1542f17704338311bc58112c3a793b5e26544213 GIT binary patch literal 2308 zcmd^=U1%It6vxk*Nj7P8Q@2K|HHi~jV2)W~bcFyix#G>!s;Xmh| z^Sk$)J9l>O5))Aioh6g3=a290KTIP-M7$f(S0=JGNSUW30( zWL`|C+|zv(H4F9yWTz2mykcNJ-Gp|fv*p=mPBnw^d+^U?@)Pd1XsjFaPs2Z&b!Q8- znD?5pm{fVXiPRakIUW`jXqtG~%hGw97XaQP;D$okF3f+J< zAcg0TLhVowGzcAmP#^n}G+u|^g+7M--NMo!6KccVf3i00K^(Rjm(MIZNOPW7?ZwIU zJa64w^FEiIL>MiKYX?0>`)HVs(uC!^|ONEUQ5u9iZwsFSoh7m|ujX4Ly3S<%V7nhVLQ zP8QT_A-SQGMfFlhHgr-^&xJ(kWJL|ti4oPws@fZpJAN5Pk3MHZ#lks*I#J39$q|hh z(aqmNa#APln>Rz^pTM7#`7 z4Qiz+=|^agX2!DaM9DglDwVCVV!n_smZ$PLH*+jCIh}EfRq8{X?GZ?0lqHuYsD#7o z1f^ih=#5d4@>GC|@TXvN7?q)87%_j&dt+<$E~B+xT8?#C3ZyYVX;pK){*CMq`o8d9jWg~eMjm$Qs0sKj?{OgzB5ep7h9FFi2wiq literal 0 HcmV?d00001 diff --git a/pmacs-gpu/fonts/test/generate.py b/pmacs-gpu/fonts/test/generate.py new file mode 100644 index 0000000..7020d63 --- /dev/null +++ b/pmacs-gpu/fonts/test/generate.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Generate the four hermetic test faces for the pmacs-gpu font tests. + +The gpu-set-font acceptance suite (docs/gpu-set-font-framing.md) needs +family-routing tests that cannot depend on whatever fonts the host has +installed, so these tiny fixture faces are generated and committed: + + PmacsTestMonoTwo-Regular.ttf "Pmacs Test Mono Two" monospaced, + advance 720/1000 (JetBrains Mono is + 600/1000, so the measured advance + ratio is exactly 1.2) + PmacsTestProportional-Regular.ttf "Pmacs Test Proportional" varying + advances, not monospaced + PmacsTestFamily-Regular.ttf "Pmacs Test Family" monospaced + normal face, advance 800/1000 + PmacsTestFamily-Bold.ttf "Pmacs Test Family" BOLD and + proportional -- the same-family + collision the sanitizer removes and + the four-style monospace gate must + reject + +Every glyph is a plain rectangle (ink for frame-diff tests); coverage +is space, the digits (the ADVANCE_PROBE string), and a-z. fontdb's +`monospaced` flag reads the post table's isFixedPitch, so that is the +one bit that decides mono vs proportional here. + +Run from this directory: python3 generate.py +Requires fontTools (any recent version). +""" + +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen + +UPM = 1000 +CHARS = " 0123456789abcdefghijklmnopqrstuvwxyz" +ASCENT = 800 +DESCENT = -200 + + +def glyph_name(char): + return "uni%04X" % ord(char) + + +def rect_glyph(advance): + """A filled rectangle spanning most of the advance width.""" + pen = TTGlyphPen(None) + left = 60 + right = max(left + 40, advance - 60) + pen.moveTo((left, 0)) + pen.lineTo((right, 0)) + pen.lineTo((right, 700)) + pen.lineTo((left, 700)) + pen.closePath() + return pen.glyph() + + +def empty_glyph(): + return TTGlyphPen(None).glyph() + + +def build(path, family, style, weight, bold, fixed_pitch, advance_for): + order = [".notdef"] + [glyph_name(c) for c in CHARS] + fb = FontBuilder(UPM, isTTF=True) + fb.setupGlyphOrder(order) + fb.setupCharacterMap({ord(c): glyph_name(c) for c in CHARS}) + glyphs = {".notdef": rect_glyph(600)} + metrics = {".notdef": (600, 60)} + for c in CHARS: + adv = advance_for(c) + name = glyph_name(c) + glyphs[name] = empty_glyph() if c == " " else rect_glyph(adv) + metrics[name] = (adv, 0 if c == " " else 60) + fb.setupGlyf(glyphs) + fb.setupHorizontalMetrics(metrics) + fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT) + # fontdb refuses faces without a PostScript name (nameID 6). + ps_name = (family + "-" + style).replace(" ", "") + fb.setupNameTable({"familyName": family, "styleName": style, "psName": ps_name}) + fb.setupOS2( + sTypoAscender=ASCENT, + sTypoDescender=DESCENT, + usWinAscent=ASCENT, + usWinDescent=-DESCENT, + usWeightClass=weight, + fsSelection=0x20 if bold else 0x40, # BOLD else REGULAR + ) + fb.setupPost(isFixedPitch=1 if fixed_pitch else 0) + if bold: + fb.font["head"].macStyle = 0x01 + fb.save(path) + print("wrote", path) + + +def proportional_advance(c): + if c == " ": + return 250 + if c.isdigit(): + return 500 + # A spread of widths so no two adjacent letters share one. + return 300 + (ord(c) - ord("a")) * 15 + + +build( + "PmacsTestMonoTwo-Regular.ttf", + "Pmacs Test Mono Two", + "Regular", + 400, + bold=False, + fixed_pitch=True, + advance_for=lambda c: 720, +) +build( + "PmacsTestProportional-Regular.ttf", + "Pmacs Test Proportional", + "Regular", + 400, + bold=False, + fixed_pitch=False, + advance_for=proportional_advance, +) +build( + "PmacsTestFamily-Regular.ttf", + "Pmacs Test Family", + "Regular", + 400, + bold=False, + fixed_pitch=True, + advance_for=lambda c: 800, +) +build( + "PmacsTestFamily-Bold.ttf", + "Pmacs Test Family", + "Bold", + 700, + bold=True, + fixed_pitch=False, + advance_for=proportional_advance, +) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index ea3e8d5..5880132 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -2281,6 +2281,11 @@ impl State { gutter_buffer, gutter_text_renderer, }; + // Real drawable dimensions from construction (framing Q#F6): + // wrapping and `shape_until_cursor` must use the same clip the + // painter uses, and a v16 daemon never sends the FontFacts + // that would sync them later. + state.sync_buffer_dimensions(); // Shape the initial text through the shared chunk path so the // `buffer.lines` ↔ `line_chunk_cache` invariant holds from // construction — the caret/anchor projection (framing Q#F6) @@ -9811,4 +9816,873 @@ mod tests { "a parent-only byte keeps the parent color" ); } + + // ----------------------------------------------------------------- + // Arc 4 stage 2 (gpu-set-font framing Q#F6/Q#F7): apply_font_facts + // and the visual-run caret/scroll substrate. Family routing is + // hermetic: the four generated fixture faces load beside the + // bundled font (fonts/test/, provenance in LICENSE.txt there). + // ----------------------------------------------------------------- + + const TEST_MONO_TWO: &[u8] = include_bytes!("../fonts/test/PmacsTestMonoTwo-Regular.ttf"); + const TEST_PROPORTIONAL: &[u8] = + include_bytes!("../fonts/test/PmacsTestProportional-Regular.ttf"); + const TEST_FAMILY_REGULAR: &[u8] = include_bytes!("../fonts/test/PmacsTestFamily-Regular.ttf"); + const TEST_FAMILY_BOLD: &[u8] = include_bytes!("../fonts/test/PmacsTestFamily-Bold.ttf"); + + fn load_fixture_faces(state: &mut State) { + for bytes in [ + TEST_MONO_TWO, + TEST_PROPORTIONAL, + TEST_FAMILY_REGULAR, + TEST_FAMILY_BOLD, + ] { + state.font_system.db_mut().load_font_data(bytes.to_vec()); + } + } + + fn font_facts(family: Option<&str>, size_centi_px: Option) -> InstanceMessage { + InstanceMessage::FontFacts { + family: family.map(str::to_owned), + size_centi_px, + } + } + + /// Acceptance 16 — wire validation fails closed: an out-of-range + /// size rejects the WHOLE message (family included), keeps every + /// piece of current state, and re-declares nothing. + #[test] + #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums + fn font_facts_out_of_range_sizes_fail_closed() { + let Some(mut state) = headless_or_skip(320, 240, "fn main() {}") else { + return; + }; + let baseline = state.render_offscreen(); + for size in [0u32, 599, 7201, u32::MAX] { + let vp = + state.apply_attach_message(font_facts(Some("Pmacs Test Mono Two"), Some(size))); + assert!(vp.is_none(), "a rejected FontFacts must not re-declare"); + assert_eq!(state.fm.scale, 1.0, "scale untouched at size {size}"); + assert_eq!( + state.resolved_family, DEFAULT_FONT_FAMILY, + "family untouched at size {size} — the whole message is rejected" + ); + assert_eq!( + state.render_offscreen(), + baseline, + "frame byte-identical at size {size}" + ); + } + } + + /// Acceptance 9 — the size route re-derives every metric, and the + /// `(None, None)` reset reproduces the never-set frame + /// byte-for-byte within the process. + #[test] + #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums + fn font_size_applies_and_reset_is_byte_identical() { + let Some(mut state) = headless_or_skip(320, 240, "fn main() {}") else { + return; + }; + let never_set = state.render_offscreen(); + let advance_before = state.mono_advance(); + let visible_before = estimated_visible_lines(state.config.height, state.fm); + state.apply_attach_message(font_facts(None, Some(2400))); + assert!( + (state.fm.scale - 1.5).abs() < f32::EPSILON, + "2400 centi-px / 16 = 1.5" + ); + assert!( + state.mono_advance() > advance_before, + "a larger size widens the measured advance (gutter geometry)" + ); + assert!( + estimated_visible_lines(state.config.height, state.fm) < visible_before, + "a larger size fits fewer source lines" + ); + let big = state.render_offscreen(); + assert_ne!(big, never_set, "a 24px preference must change the ink"); + state.apply_attach_message(font_facts(None, None)); + assert_eq!(state.fm.scale, 1.0); + assert_eq!(state.fm.advance_ratio, 1.0); + assert_eq!( + state.render_offscreen(), + never_set, + "the (None, None) reset must reproduce the never-set frame byte-for-byte" + ); + } + + /// Acceptance 17 — metrics and REAL drawable dimensions change + /// atomically on all seven buffers, and `resize()` keeps every + /// buffer in step through the same helper (`Buffer::size()` is + /// the witness). + #[test] + fn all_seven_buffers_track_metrics_and_dimensions() { + fn assert_buffer_dims(state: &State) { + let fm = state.fm; + let width = state.config.width as f32; + let height = state.config.height as f32; + let code_metrics = Metrics::new(fm.code_font_size(), fm.code_line_height()); + let code_width = (state.text_bounds_right() as f32 - state.text_left()).max(0.0); + let code_height = (text_area_bottom(state.config.height, fm) - TEXT_TOP).max(0.0); + assert_eq!(state.buffer.metrics(), code_metrics); + assert_eq!(state.buffer.size(), (Some(code_width), Some(code_height))); + assert_eq!(state.gutter_buffer.metrics(), code_metrics); + assert_eq!(state.gutter_buffer.size(), (Some(width), Some(code_height))); + let status_metrics = Metrics::new(fm.status_font_size(), fm.status_line_height()); + for buffer in [&state.status_buffer, &state.status_left_buffer] { + assert_eq!(buffer.metrics(), status_metrics); + assert_eq!(buffer.size(), (Some(width), Some(fm.status_band_height()))); + } + assert_eq!( + state.menu_buffer.metrics(), + Metrics::new(fm.menu_font_size(), fm.menu_line_height()) + ); + assert_eq!( + state.menu_buffer.size(), + (Some(MENU_MAX_WIDTH), Some(height)) + ); + let drop_metrics = Metrics::new(fm.mb_drop_font_size(), fm.mb_drop_line_height()); + for buffer in [&state.mb_buffer, &state.completion_buffer] { + assert_eq!(buffer.metrics(), drop_metrics); + assert_eq!(buffer.size(), (Some(MB_DROP_MAX_WIDTH), Some(height))); + } + } + let Some(mut state) = headless_or_skip(320, 240, "one\ntwo\nthree\n") else { + return; + }; + state.apply_attach_message(font_facts(None, Some(2400))); + assert_buffer_dims(&state); + state.resize(500, 400); + assert_buffer_dims(&state); + } + + /// Acceptance 18 — rows stay rows: after a large size lands, no + /// popup label may wrap onto a second visual run that would + /// hit-test as the following item. + #[test] + fn popup_rows_never_wrap_after_a_font_change() { + let Some(mut state) = headless_or_skip(320, 600, "text") else { + return; + }; + state.apply_attach_message(font_facts(None, Some(7200))); + let long = "a very long label ".repeat(8); + state.completion = Some(CompletionLocal { + buffer_id: BufferId::next(), + anchor: 0, + prefix_len: 0, + rows: vec![ + CompletionPopupRow { + label: long.clone(), + kind: 3, + detail: Some(long.clone()), + }, + CompletionPopupRow { + label: long.clone(), + kind: 3, + detail: None, + }, + ], + selected: Some(0), + total: 2, + }); + state.refresh_completion_buffer(); + state.minibuffer = Some(MinibufferLocal { + prompt: "P: ".into(), + input: String::new(), + cursor: 0, + candidates: vec![long.clone(), long.clone()], + selected: Some(0), + total: 2, + }); + state.refresh_mb_buffer(); + state.menu = Some(MenuLocal { + rows: vec![ + MenuPromptRow { + label: long.clone(), + separator: false, + }, + MenuPromptRow { + label: long, + separator: false, + }, + ], + active: Some(0), + anchor_px: (10.0, 10.0), + }); + state.refresh_menu_buffer(); + for (name, buffer) in [ + ("completion", &state.completion_buffer), + ("minibuffer dropdown", &state.mb_buffer), + ("context menu", &state.menu_buffer), + ] { + assert_eq!(buffer.wrap(), Wrap::None, "{name} buffer must not wrap"); + let mut seen = std::collections::HashSet::new(); + for run in buffer.layout_runs() { + assert!( + seen.insert(run.line_i), + "{name} row {} wrapped onto a second visual run", + run.line_i + ); + } + } + } + + /// Acceptance 13 — the two string-equality status caches drop on + /// a font change, so an unchanged composed status re-shapes with + /// the new attrs on the next frame. + #[test] + #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums + fn font_change_invalidates_the_status_shaping_caches() { + let Some(mut state) = headless_or_skip(320, 240, "text") else { + return; + }; + let _ = state.render_offscreen(); + let composed_before = state.status_text.clone(); + assert!( + !composed_before.is_empty(), + "precondition: a frame composed the status readout" + ); + state.apply_attach_message(font_facts(None, Some(3200))); + assert_eq!( + state.status_text, "\0", + "the sentinel must defeat the string-equality gate" + ); + assert_eq!(state.status_left_text, "\0"); + let _ = state.render_offscreen(); + assert_eq!( + state.status_buffer.metrics().font_size, + state.fm.status_font_size(), + "the re-shaped band must carry the derived metrics" + ); + assert_eq!( + state.status_text, composed_before, + "same composed text, re-shaped anyway" + ); + } + + /// Acceptance 14 — a size that shrinks the visible line count + /// re-declares the scoped viewport from the final normalized + /// origin. + #[test] + fn font_change_redeclares_a_shrunken_viewport() { + let text = "line of text\n".repeat(40); + let Some(mut state) = headless_or_skip(320, 400, &text) else { + return; + }; + let bid = BufferId::next(); + state.current_buffer_id = Some(bid); + state.reshape(); + let declared = state.view_range; + state.last_viewport_sent = Some(declared); + let vp = state + .apply_attach_message(font_facts(None, Some(7200))) + .expect("fewer lines fit at 72px — the viewport must be re-declared"); + assert_eq!(vp.buffer_id, bid); + assert!( + vp.visible.end < declared.1, + "the re-declared range must shrink ({} vs {})", + vp.visible.end, + declared.1 + ); + } + + /// Acceptance 10 — both preference bounds render without panic, + /// the minibuffer dropdown windows its rows above the band at the + /// derived row height, and the context menu keeps its raw-anchor + /// clipped route with coherent hit geometry. + #[test] + fn extreme_sizes_render_with_contained_popups() { + let text = "line of text\n".repeat(20); + let Some(mut state) = headless_or_skip(320, 400, &text) else { + return; + }; + state.apply_attach_message(font_facts(None, Some(600))); + let _ = state.render_offscreen(); + state.apply_attach_message(font_facts(None, Some(7200))); + state.minibuffer = Some(MinibufferLocal { + prompt: "M-x ".into(), + input: String::new(), + cursor: 0, + candidates: (0..30).map(|i| format!("candidate-{i}")).collect(), + selected: Some(0), + total: 30, + }); + let (_, count) = state + .mb_visible_window() + .expect("at least one 90px row fits above the band"); + let band_top = text_area_bottom(state.config.height, state.fm); + assert!( + count >= 1 && count as f32 * state.fm.mb_drop_row_height() <= band_top, + "{count} visible rows must fit above the band at the derived row height" + ); + state.menu = Some(MenuLocal { + rows: (0..20) + .map(|i| MenuPromptRow { + label: format!("menu entry {i} with a fairly long label attached"), + separator: false, + }) + .collect(), + active: Some(0), + anchor_px: (200.0, 300.0), + }); + let px = state.render_offscreen(); + assert_eq!( + px.len(), + 320 * 400 * 4, + "the clipped route renders a full frame" + ); + assert_eq!( + state.menu_hit(205.0, 305.0), + Some((0, true)), + "hit geometry stays coherent on the clipped popup" + ); + } + + /// Acceptance 11 — a caret painted on a wrapped visual run + /// survives 16px → 72px → 6px re-wraps, with the normalized + /// scroll invariant intact throughout. + #[test] + #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums + fn wrapped_caret_survives_size_changes() { + let long = "x".repeat(180); + let text = format!("{long}\nsecond\nthird\n"); + let Some(mut state) = headless_or_skip(320, 400, &text) else { + return; + }; + let bid = BufferId::next(); + state.current_buffer_id = Some(bid); + state.own_cursor = Some(OwnCursor { + buffer_id: bid, + byte: 180, + }); + state.reshape(); + state.ensure_caret_painted(); + assert!( + state.caret_painted_in_code_clip(), + "precondition: the caret paints at the default size" + ); + state.apply_attach_message(font_facts(None, Some(7200))); + assert!( + state.caret_painted_in_code_clip(), + "the caret must survive the 16px → 72px re-wrap" + ); + assert_eq!( + state.buffer.scroll().line, + 0, + "normalized: slice-local line 0" + ); + assert_eq!( + state.buffer.scroll().horizontal, + 0.0, + "horizontal is discarded" + ); + state.apply_attach_message(font_facts(None, Some(600))); + assert!( + state.caret_painted_in_code_clip(), + "and the reverse 72px → 6px" + ); + assert_eq!(state.buffer.scroll().line, 0); + } + + /// Acceptance 11 — an overscan-only caret (shaped but below the + /// drawable window) is NOT painted, and a font change must not + /// snap the viewport back to it. + #[test] + fn overscan_caret_never_snaps_the_viewport() { + let text = "line of text\n".repeat(40); + let Some(mut state) = headless_or_skip(320, 400, &text) else { + return; + }; + let bid = BufferId::next(); + state.current_buffer_id = Some(bid); + state.reshape(); + let visible = estimated_visible_lines(state.config.height, state.fm); + let overscan_line = visible + 1; + state.own_cursor = Some(OwnCursor { + buffer_id: bid, + byte: state.current_line_starts[overscan_line], + }); + assert!( + !state.caret_painted_in_code_clip(), + "precondition: the overscan caret is not painted" + ); + let top_before = state.scroll_top; + state.apply_attach_message(font_facts(None, Some(2400))); + assert_eq!( + state.scroll_top, top_before, + "an unpainted caret must never snap the viewport" + ); + } + + /// Acceptance 11 — `resize()` applies the same painted-before + /// policy: shrinking the window re-follows a painted caret + /// through the coarse + visual-run + fold pipeline. + #[test] + #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums + fn narrowing_resize_keeps_a_wrapped_caret_painted() { + let mut text = "short\n".repeat(10); + let long = "y".repeat(100); + text.push_str(&long); + let Some(mut state) = headless_or_skip(640, 400, &text) else { + return; + }; + let bid = BufferId::next(); + state.current_buffer_id = Some(bid); + state.own_cursor = Some(OwnCursor { + buffer_id: bid, + byte: text.len() as u64, + }); + state.reshape(); + assert!( + state.caret_painted_in_code_clip(), + "precondition: everything fits at 640x400" + ); + state.resize(320, 150); + assert!( + state.caret_painted_in_code_clip(), + "the shrunken window must re-follow the caret into its wrapped run" + ); + assert_eq!(state.buffer.scroll().line, 0); + assert_eq!(state.buffer.scroll().horizontal, 0.0); + } + + /// Acceptance 11 — the caret projection inverts the chunk cache: + /// injected adornment text shifts a caret past the anchor by the + /// projected width, while a caret AT the anchor keeps left + /// gravity (before the injected text). + #[test] + fn caret_projection_accounts_for_inline_adornments() { + let Some(mut state) = headless_or_skip(320, 240, "ab\ncd") else { + return; + }; + let bid = BufferId::next(); + state.current_buffer_id = Some(bid); + state.reshape(); + state.own_cursor = Some(OwnCursor { + buffer_id: bid, + byte: 2, + }); + let x_plain = state.caret_rect().expect("caret on line 0").x; + state.own_cursor = Some(OwnCursor { + buffer_id: bid, + byte: 1, + }); + let x1_plain = state.caret_rect().expect("caret at byte 1").x; + state.current_adornments = vec![adornment(1, AdornmentPlacement::AtOffset, "hint")]; + state.reshape(); + state.own_cursor = Some(OwnCursor { + buffer_id: bid, + byte: 2, + }); + let x_projected = state.caret_rect().expect("caret past the adornment").x; + assert!( + x_projected > x_plain + 3.0 * 9.0, + "the caret must sit past the injected text ({x_projected} vs {x_plain})" + ); + state.own_cursor = Some(OwnCursor { + buffer_id: bid, + byte: 1, + }); + let x_anchor = state.caret_rect().expect("caret at the anchor").x; + assert!( + (x_anchor - x1_plain).abs() < 0.5, + "an anchor byte keeps left gravity — before the adornment \ + ({x_anchor} vs {x1_plain})" + ); + } + + /// Acceptance 11 — the `CursorByte` arm follows into a wrapped + /// continuation run (the pre-existing source-line-only hole): the + /// follow lands as a sub-line residual, normalized to slice-local + /// line 0. + #[test] + #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums + fn cursor_byte_follows_into_a_wrapped_run() { + let long = "z".repeat(400); + let Some(mut state) = headless_or_skip(320, 240, &long) else { + return; + }; + let bid = BufferId::next(); + state.current_buffer_id = Some(bid); + state.reshape(); + let _ = state.apply_attach_message(InstanceMessage::CursorByte { + buffer_id: bid, + byte_pos: 400, + }); + assert!( + state.caret_painted_in_code_clip(), + "the CursorByte arm must follow into the wrapped run" + ); + assert!( + state.code_scroll_residual > 0.0, + "the follow is a sub-line residual, not a source-line scroll" + ); + assert_eq!(state.buffer.scroll().line, 0); + assert_eq!(state.buffer.scroll().horizontal, 0.0); + } + + /// Acceptance 11 — an optimistic insertion that creates a new + /// bottom-edge wrap follows immediately (waiting a round trip + /// reads as a hitch), and the identical confirming `CursorByte` + /// needs no second repair. + #[test] + #[allow(clippy::float_cmp)] // exact: comparing a value to itself across a no-op + fn optimistic_insertion_follows_a_new_bottom_edge_wrap() { + // 320 wide → 304px drawable → 31 glyphs per row at the 9.6px + // advance; 240 high → 198px drawable → 9 rows. Exactly 9 full + // rows of text puts the caret at the painted bottom edge, and + // one more glyph starts row 10 below the clip. + let text = "q".repeat(31 * 9); + let Some(mut state) = headless_or_skip(320, 240, "") else { + return; + }; + let bid = BufferId::next(); + let doc = loro::LoroDoc::new(); + doc.get_text(LORO_TEXT_CONTAINER) + .insert(0, &text) + .expect("insert snapshot text"); + let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot { + buffer_id: bid, + crdt_snapshot: doc.export(loro::ExportMode::Snapshot).expect("export"), + }); + state.set_frontend_id(FrontendId(9001)); + state.dispatch_idle = true; + let _ = state.apply_attach_message(InstanceMessage::CursorByte { + buffer_id: bid, + byte_pos: text.len() as u64, + }); + assert!( + state.caret_painted_in_code_clip(), + "precondition: the end caret paints on the last full row" + ); + let send = state + .optimistic_crdt_insert(ProtocolKey::Char('q'), Modifiers::NONE) + .expect("the optimistic insert path is eligible"); + assert_eq!(send.buffer_id, bid); + assert!( + state.caret_painted_in_code_clip(), + "the insertion wrapped a new bottom row — the caret must follow NOW" + ); + assert!( + state.code_scroll_residual > 0.0, + "the follow is a visual-run residual" + ); + let residual = state.code_scroll_residual; + let top = state.scroll_top; + // The daemon confirms the predicted byte: `moved == false`, so + // no second repair may disturb the settled scroll. + let _ = state.apply_attach_message(InstanceMessage::CursorByte { + buffer_id: bid, + byte_pos: text.len() as u64 + 1, + }); + assert_eq!(state.code_scroll_residual, residual); + assert_eq!(state.scroll_top, top); + } + + /// Acceptance 11 — an explicit scroll clears the caret-follow + /// residual, including a wheel-up at the top clamp whose only + /// remaining motion IS the residual. + #[test] + #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums + fn explicit_scroll_clears_the_caret_follow_residual() { + let long = "w".repeat(400); + let Some(mut state) = headless_or_skip(320, 240, &long) else { + return; + }; + let bid = BufferId::next(); + state.current_buffer_id = Some(bid); + state.reshape(); + let _ = state.apply_attach_message(InstanceMessage::CursorByte { + buffer_id: bid, + byte_pos: 400, + }); + assert!( + state.code_scroll_residual > 0.0, + "precondition: residual armed" + ); + assert_eq!( + state.scroll_top, 0, + "single source line: the residual IS the scroll" + ); + let _ = state.scroll_by_lines(-1); + assert_eq!( + state.code_scroll_residual, 0.0, + "wheel-up at the clamp edge must scroll the residual away" + ); + assert!( + !state.caret_painted_in_code_clip(), + "the deep wrapped caret is off-screen again — the user owns the viewport" + ); + } + + /// Acceptance 4 (GPU half) — the caret-follow residual is + /// buffer-scoped view state: a `BufferSnapshot` resets it with + /// the rest of the view, while the font preference and derived + /// metrics survive the switch. + #[test] + #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums + fn buffer_snapshot_resets_the_residual_but_not_the_font() { + let long = "v".repeat(400); + let Some(mut state) = headless_or_skip(320, 240, &long) else { + return; + }; + let bid = BufferId::next(); + state.current_buffer_id = Some(bid); + state.reshape(); + state.apply_attach_message(font_facts(None, Some(2400))); + let _ = state.apply_attach_message(InstanceMessage::CursorByte { + buffer_id: bid, + byte_pos: 400, + }); + assert!( + state.code_scroll_residual > 0.0, + "precondition: residual armed" + ); + // The replacement buffer wraps too: a leaked residual would + // NOT be zeroed by the EOF clamp, so the assertion below can + // only pass through the snapshot arm's explicit reset. + let doc = loro::LoroDoc::new(); + doc.get_text(LORO_TEXT_CONTAINER) + .insert(0, &"u".repeat(400)) + .expect("insert snapshot text"); + let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot { + buffer_id: BufferId::next(), + crdt_snapshot: doc.export(loro::ExportMode::Snapshot).expect("export"), + }); + assert_eq!( + state.code_scroll_residual, 0.0, + "the residual is buffer-scoped view state" + ); + assert!( + (state.fm.scale - 1.5).abs() < f32::EPSILON, + "the global font preference survives the switch" + ); + assert_eq!(state.resolved_family, DEFAULT_FONT_FAMILY); + } + + /// The follow decision is suspended while the minibuffer is open: + /// its caret lives in the band, not the code area. + #[test] + fn open_minibuffer_suppresses_the_caret_follow_decision() { + let Some(mut state) = headless_or_skip(320, 240, "hello") else { + return; + }; + let bid = BufferId::next(); + state.current_buffer_id = Some(bid); + state.own_cursor = Some(OwnCursor { + buffer_id: bid, + byte: 2, + }); + state.reshape(); + assert!(state.caret_painted_in_code_clip()); + state.minibuffer = Some(MinibufferLocal { + prompt: ":".into(), + input: String::new(), + cursor: 0, + candidates: Vec::new(), + selected: None, + total: 0, + }); + assert!( + !state.caret_painted_in_code_clip(), + "an open minibuffer owns the caret — the code-area decision is false" + ); + } + + /// Acceptance 19 — family-dependent geometry on an EMPTY document: + /// the measured probe advance (not a shaped code glyph) drives the + /// gutter fallback and menu char width through the advance ratio. + #[test] + #[allow(clippy::float_cmp)] // exact: the reset re-measures the identical face + fn family_advance_ratio_scales_empty_document_geometry() { + let Some(mut state) = headless_or_skip(320, 240, "") else { + return; + }; + // Line numbers + a context menu over the EMPTY buffer: no code + // glyph has ever shaped, so any advance the geometry uses must + // come from the probe. + state.line_numbers = LineNumberMode::Absolute; + state.menu = Some(MenuLocal { + rows: vec![MenuPromptRow { + // Long enough that the estimated width sits BETWEEN + // the min/max clamps at both ratios — a short label + // pins to MENU_MIN_WIDTH and hides the scaling. + label: "a context menu entry armed here".into(), + separator: false, + }], + active: Some(0), + anchor_px: (40.0, 40.0), + }); + load_fixture_faces(&mut state); + let gutter_before = state.gutter_width_px(); + let menu_w_before = State::menu_width_px(state.menu.as_ref().unwrap(), state.fm); + let frame_before = state.render_offscreen(); + state.apply_attach_message(font_facts(Some("Pmacs Test Mono Two"), None)); + assert_eq!(state.resolved_family, "Pmacs Test Mono Two"); + // Fixture advance 720/1000 vs JetBrains Mono 600/1000 → 1.2. + assert!( + (state.fm.advance_ratio - 1.2).abs() < 0.01, + "measured selected/default ratio, got {}", + state.fm.advance_ratio + ); + assert!( + (state.mono_advance() - 11.52).abs() < 0.1, + "the measured NORMAL-face advance is authoritative on an empty \ + document, got {}", + state.mono_advance() + ); + assert!( + (state.fm.menu_char_w() - BASE_MENU_CHAR_W * 1.2).abs() < 0.05, + "menu hit width follows the ratio, got {}", + state.fm.menu_char_w() + ); + assert!( + state.gutter_width_px() > gutter_before, + "the gutter reservation follows the measured advance" + ); + assert!( + State::menu_width_px(state.menu.as_ref().unwrap(), state.fm) > menu_w_before, + "the menu hit width follows the measured advance" + ); + state.apply_attach_message(font_facts(None, None)); + assert_eq!( + state.gutter_width_px(), + gutter_before, + "reset: exact gutter" + ); + assert_eq!( + State::menu_width_px(state.menu.as_ref().unwrap(), state.fm), + menu_w_before, + "reset: exact menu width" + ); + assert_eq!( + state.render_offscreen(), + frame_before, + "reset restores the original frame" + ); + } + + /// Acceptance 12 — unresolvable and proportional families both + /// take the total fallback to the sanitized default. + #[test] + #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums + fn unresolvable_and_proportional_families_fall_back() { + let Some(mut state) = headless_or_skip(320, 240, "text") else { + return; + }; + load_fixture_faces(&mut state); + let never_set = state.render_offscreen(); + state.apply_attach_message(font_facts(Some("No Such Family Zzz"), None)); + assert_eq!(state.resolved_family, DEFAULT_FONT_FAMILY); + assert_eq!( + state.render_offscreen(), + never_set, + "the unresolvable route renders byte-identically to never-set" + ); + state.apply_attach_message(font_facts(Some("Pmacs Test Proportional"), None)); + assert_eq!( + state.resolved_family, DEFAULT_FONT_FAMILY, + "a proportional family must fall back" + ); + assert_eq!( + state.fm.advance_ratio, 1.0, + "the fallback is the default: ratio 1" + ); + assert_eq!( + state.render_offscreen(), + never_set, + "the rejected route renders byte-identically to never-set" + ); + } + + /// Acceptance 12 — the four-style gate rejects a family whose + /// BOLD sibling is proportional, exactly what a normal-only + /// query check would wave through. + #[test] + fn four_style_gate_rejects_a_proportional_bold_sibling() { + let Some(mut state) = headless_or_skip(320, 240, "text") else { + return; + }; + load_fixture_faces(&mut state); + assert!( + query_normal_face(state.font_system.db(), "Pmacs Test Family") + .and_then(|id| state.font_system.db().face(id)) + .is_some_and(|face| face.monospaced), + "precondition: the NORMAL face alone looks monospaced" + ); + assert!(state.family_is_monospace_everywhere("Pmacs Test Mono Two")); + assert!( + !state.family_is_monospace_everywhere("Pmacs Test Family"), + "the proportional BOLD sibling must fail the four-style gate" + ); + state.apply_attach_message(font_facts(Some("Pmacs Test Family"), None)); + assert_eq!(state.resolved_family, DEFAULT_FONT_FAMILY); + } + + /// Acceptance 12 — a valid second monospace family resolves, + /// changes the ink, and the `(None, None)` reset restores the + /// default frame byte-for-byte. + #[test] + fn second_monospace_family_changes_the_frame_and_reset_restores() { + let Some(mut state) = headless_or_skip(320, 240, "0123456789") else { + return; + }; + let default_frame = state.render_offscreen(); + load_fixture_faces(&mut state); + state.apply_attach_message(font_facts(Some("Pmacs Test Mono Two"), None)); + assert_eq!(state.resolved_family, "Pmacs Test Mono Two"); + assert_ne!( + state.render_offscreen(), + default_frame, + "different outlines/advances must change the ink" + ); + state.apply_attach_message(font_facts(None, None)); + assert_eq!(state.resolved_family, DEFAULT_FONT_FAMILY); + assert_eq!( + state.render_offscreen(), + default_frame, + "the reset restores the default frame byte-for-byte" + ); + } + + /// Acceptance 12 — the sanitizer (parameterized, the production + /// path) removes exactly the non-monospace same-family + /// collisions: the monospaced default and unrelated families + /// survive. + #[test] + fn sanitizer_removes_only_same_family_proportional_collisions() { + let mut db = fontdb::Database::new(); + let bold_id = *db + .load_font_source(fontdb::Source::Binary(std::sync::Arc::new( + TEST_FAMILY_BOLD, + ))) + .first() + .expect("bold fixture loads"); + let regular_id = *db + .load_font_source(fontdb::Source::Binary(std::sync::Arc::new( + TEST_FAMILY_REGULAR, + ))) + .first() + .expect("regular fixture loads"); + let unrelated_id = *db + .load_font_source(fontdb::Source::Binary(std::sync::Arc::new( + TEST_PROPORTIONAL, + ))) + .first() + .expect("proportional fixture loads"); + sanitize_font_database(&mut db, "Pmacs Test Family", regular_id); + assert!( + db.face(bold_id).is_none(), + "the proportional same-family BOLD collision is removed" + ); + assert!( + db.face(regular_id).is_some(), + "the monospaced default survives" + ); + assert!( + db.face(unrelated_id).is_some(), + "an unrelated proportional family is untouched" + ); + } }