// theme_faces_acceptance.rs --- Themes Arc 4 stage 1 acceptance // (docs/theme-faces-framing.md, acceptance items 1–19, 24–26, 28–29, // and 32–33; the GPU routes — 20–23, 27, and 30–31 — live in // pmacs-gpu's headless suite; item 34 is a `DiagnosticStore` unit in // src/diag.rs). //! Named UI faces (`ui` / `ui.*` theme entries) + the `ThemeFacts` //! wire channel (protocol v16). //! //! Grid-path rendering drives the full `paint_frame` (mode line, //! status row, gutter, minibuffer, selection, search, diagnostics all //! paint there); keybinding claims dispatch keys per the standing //! discipline; wire claims drive a `SemanticRenderState` frame by //! frame, and the version gate exercises a real daemon. use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use pmacs::cell::{Cell, CellGrid, CellSize, Color, Style, UnderlineStyle}; use pmacs::editor::EditorState; use pmacs::protocol::{ByteRange, FrontendId, InstanceMessage, ThemeFace}; use pmacs::semantic_render::SemanticRenderState; use std::time::{Duration, Instant}; #[cfg(feature = "crdt")] mod common; // --------------------------------------------------------------------------- // Harness (compile_mode_acceptance conventions) // --------------------------------------------------------------------------- fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { KeyEvent { code, modifiers: mods, kind: KeyEventKind::Press, state: KeyEventState::NONE, } } fn ctrl(s: &mut EditorState, c: char) { s.dispatch_key( FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::CONTROL), ); } fn alt(s: &mut EditorState, c: char) { s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT)); } fn press(s: &mut EditorState, code: KeyCode) { s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); } fn type_str(s: &mut EditorState, text: &str) { for ch in text.chars() { s.dispatch_key( FrontendId::LOCAL, key(KeyCode::Char(ch), KeyModifiers::NONE), ); } } fn exec(s: &EditorState, src: &str) { s.lua_host.lua().load(src.to_string()).exec().unwrap(); } fn exec_err(s: &EditorState, src: &str) -> mlua::Error { s.lua_host .lua() .load(src.to_string()) .exec() .expect_err("chunk must error") } fn eval(s: &EditorState, src: &str) -> T { s.lua_host.lua().load(src.to_string()).eval().unwrap() } /// Fresh editor with LSP spawning disabled. fn editor() -> EditorState { let s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); s } /// Paint the FULL frame (windows + mode lines + status row / /// minibuffer) — the chrome surfaces under test all render here. fn paint_full_frame(state: &EditorState, rows: u32, cols: u32) -> Vec { let mut backing = vec![Cell::default(); (rows * cols) as usize]; let mut grid = CellGrid { cells: &mut backing, stride: cols, size: CellSize::new(rows, cols), }; let _cursor = pmacs::editor::paint_frame(state, &mut grid, CellSize::new(rows, cols)); backing } fn at(cells: &[Cell], cols: u32, row: u32, col: u32) -> &Cell { &cells[(row * cols + col) as usize] } fn row_text(cells: &[Cell], cols: u32, row: u32) -> String { (0..cols) .map(|c| match &at(cells, cols, row, c).glyph { pmacs::cell::Glyph::Char(ch) => *ch, _ => ' ', }) .collect() } // --- Wire helpers ----------------------------------------------------------- fn active_buffer(state: &EditorState) -> pmacs::buffer::BufferId { state.core.borrow().active_window().buffer_id } fn semantic(state: &EditorState) -> SemanticRenderState { let buffer_id = active_buffer(state); let mut s = SemanticRenderState::new(FrontendId::LOCAL); s.set_viewport( buffer_id, ByteRange { start: 0, end: 1 << 20, }, 0, ); s } fn theme_facts_of(msgs: &[InstanceMessage]) -> Option> { msgs.iter().find_map(|m| match m { InstanceMessage::ThemeFacts { faces } => Some(faces.clone()), _ => None, }) } fn has_style_spans(msgs: &[InstanceMessage]) -> bool { msgs.iter() .any(|m| matches!(m, InstanceMessage::StyleSpans { .. })) } fn summary_of(msgs: &[InstanceMessage]) -> Option> { msgs.iter().find_map(|m| match m { InstanceMessage::FileStyleSummary { lines, .. } => Some(lines.clone()), _ => None, }) } // --- Grammar fixtures (m4_acceptance conventions) --------------------------- fn pump_async bool>(state: &mut EditorState, predicate: F) { let deadline = Instant::now() + Duration::from_secs(5); while !predicate(state) { assert!(Instant::now() < deadline, "async pump deadline exceeded"); state.tick_async(); std::thread::sleep(Duration::from_millis(2)); } } fn current_tree_language(state: &EditorState) -> Option { let chunk = r" local buf = pmacs.window.buffer() if not buf then return nil end local tree = pmacs.parse.tree(buf) if not tree then return nil end return tree:language() "; state .lua_host .lua() .load(chunk) .eval::>() .ok() .flatten() } /// Open `path` and pump until its parse settles (highlights attached). fn open_and_wait_for_parse(path: std::path::PathBuf) -> EditorState { let mut state = EditorState::open(path).expect("open file"); exec(&state, "pmacs.lsp.config = {}"); pump_async(&mut state, |s| current_tree_language(s).is_some()); state } fn rust_fixture(dir: &tempfile::TempDir) -> std::path::PathBuf { let path = dir.path().join("faces.rs"); std::fs::write(&path, b"fn main() {}\n").expect("write fixture"); path } // --- Diagnostics fixture ----------------------------------------------------- fn diag(severity: pmacs::diag::DiagnosticSeverity) -> pmacs::diag::Diagnostic { pmacs::diag::Diagnostic { start_line: 0, start_col: 0, end_line: 0, end_col: 3, severity, message: "boom".into(), source: None, code: None, } } /// Give the active buffer a file path, publish `diags` for it, and /// attach the diagnostic overlay through the REAL Lua path /// (`pmacs.diag._attach_view` — `install_diag`), never a bare /// constructor (Q#TH9). fn attach_diags(state: &EditorState, diags: Vec) -> String { let uri: String = eval( state, r#" local buf = pmacs.window.buffer() local uri = "file:///tmp/theme_faces_diag.rs" assert(pmacs.diag._attach_view(buf, uri)) return uri "#, ); { let core = state.core.borrow(); let registry = core.registry.clone(); let mut reg = registry.borrow_mut(); let buf = reg.get_mut(core.active_buffer_id()).unwrap(); buf.set_file_path(Some(std::path::PathBuf::from("/tmp/theme_faces_diag.rs"))); } let store = state.lsp_manager.borrow().diag_store(); store .lock() .expect("diag store lock") .set(uri.clone(), diags); uri } // --- Daemon wire helpers (item 29; CRDT suites only) ------------------------- #[cfg(feature = "crdt")] fn wire_viewport( stream: &mut std::os::unix::net::UnixStream, fid: FrontendId, buffer_id: pmacs::buffer::BufferId, ) { pmacs::transport::write_message( stream, &pmacs::protocol::FrontendEvent::Viewport { frontend_id: fid, buffer_id, visible: ByteRange { start: 0, end: 4096, }, generation: 0, }, ) .expect("write Viewport"); } #[cfg(feature = "crdt")] fn wire_key( stream: &mut std::os::unix::net::UnixStream, fid: FrontendId, key: pmacs::protocol::Key, ) { pmacs::transport::write_message( stream, &pmacs::protocol::FrontendEvent::Key(pmacs::protocol::KeyEvent { frontend_id: fid, key, mods: pmacs::protocol::Modifiers::NONE, timestamp_ns: 0, }), ) .expect("write Key"); } /// Read wire messages until `pick` returns, or panic at the deadline. #[cfg(feature = "crdt")] fn wire_wait_for( stream: &mut std::os::unix::net::UnixStream, what: &str, mut pick: impl FnMut(InstanceMessage) -> Option, ) -> T { let deadline = Instant::now() + Duration::from_secs(5); while Instant::now() < deadline { if let Ok(msg) = pmacs::transport::read_message::(stream) && let Some(t) = pick(msg) { return t; } } panic!("timeout waiting for {what}"); } // --------------------------------------------------------------------------- // 1 — unset faces: byte-identical chrome; default_style never leaks // --------------------------------------------------------------------------- #[test] fn unset_faces_keep_todays_chrome_and_syntax_default_never_leaks() { let mut state = editor(); type_str(&mut state, "hello"); let (rows, cols) = (8u32, 20u32); let base = paint_full_frame(&state, rows, cols); // Today's literals, spot-pinned: mode line (window's last row) and // status row are reverse video with no colors. let mode_row = rows - 2; let status_row = rows - 1; for col in 0..cols { let m = at(&base, cols, mode_row, col); assert!(m.style.reverse, "mode line is reverse video when unthemed"); assert_eq!(m.style.fg, Color::Default); let s = at(&base, cols, status_row, col); assert!(s.style.reverse, "status row is reverse video when unthemed"); } // A loud SYNTAX default must not bleed into chrome (Q#TH4: // face resolution returns None, never default_style). exec( &state, "pmacs.theme.default { fg = 5, bg = 3, bold = true }", ); let loud = paint_full_frame(&state, rows, cols); assert_eq!(base, loud, "pmacs.theme.default must change no chrome cell"); } // --------------------------------------------------------------------------- // 2 + 3 — surface faces apply per cell; owns-surface resets to plain // --------------------------------------------------------------------------- #[test] fn surface_faces_apply_and_partial_faces_reset_to_plain() { let mut state = editor(); type_str(&mut state, "one\ntwo\nthree"); let (rows, cols) = (8u32, 20u32); let mode_row = rows - 2; let status_row = rows - 1; // Full modeline face: fg + bg, no reverse. exec( &state, r#"pmacs.theme.merge { ["ui.modeline"] = { fg = 252, bg = 236 }, ["ui.statusline"] = { fg = 4 } }"#, ); let themed = paint_full_frame(&state, rows, cols); for col in 0..cols { let m = at(&themed, cols, mode_row, col); assert_eq!(m.style.fg, Color::Indexed(252), "modeline fg at {col}"); assert_eq!(m.style.bg, Color::Indexed(236), "modeline bg at {col}"); assert!(!m.style.reverse, "a set face owns the surface: no reverse"); let s = at(&themed, cols, status_row, col); assert_eq!(s.style.fg, Color::Indexed(4), "statusline fg at {col}"); assert!(!s.style.reverse, "statusline surface resets to plain"); } // Owns-surface (Q#TH5): an fg-only modeline face still drops the // reverse video — partial faces reset the rest to plain. exec( &state, r#"pmacs.theme.merge { ["ui.modeline"] = { fg = 252 } }"#, ); let partial = paint_full_frame(&state, rows, cols); let m = at(&partial, cols, mode_row, 0); assert_eq!(m.style.fg, Color::Indexed(252)); assert_eq!(m.style.bg, Color::Default, "unset bg is plain"); assert!(!m.style.reverse, "partial face resets reverse to plain"); // Gutter face ({fg} mask) on the line-number strip. exec(&state, r#"pmacs.window.set_line_numbers("absolute")"#); exec( &state, r#"pmacs.theme.merge { ["ui.gutter"] = { fg = 13 } }"#, ); let with_gutter = paint_full_frame(&state, rows, cols); let g = at(&with_gutter, cols, 0, 0); assert_eq!(g.style.fg, Color::Indexed(13), "gutter digits recolor"); } // --------------------------------------------------------------------------- // 3 (rest) — ui.selection = {} disables the wash on the grid // --------------------------------------------------------------------------- #[test] fn empty_selection_face_disables_the_wash() { let mut state = editor(); type_str(&mut state, "alpha beta"); let (rows, cols) = (6u32, 20u32); let set_selection = |state: &EditorState| { let mut core = state.core.borrow_mut(); let win = core.active_window_mut(); win.selection = Some(pmacs::window::Selection { anchor: 0 }); win.cursor = 5; }; let clear_selection = |state: &EditorState| { state.core.borrow_mut().active_window_mut().selection = None; }; // Unset face: reverse-video wash, exactly today. set_selection(&state); let washed = paint_full_frame(&state, rows, cols); assert!( at(&washed, cols, 0, 0).style.reverse, "unthemed selection is reverse video" ); // Empty face: the wash disappears — selected cells render exactly // like unselected ones (Q#TH5, all-default overlay). exec(&state, r#"pmacs.theme.merge { ["ui.selection"] = {} }"#); let disabled = paint_full_frame(&state, rows, cols); clear_selection(&state); let unselected = paint_full_frame(&state, rows, cols); assert_eq!( disabled, unselected, "an all-default selection face must disable the wash" ); } // --------------------------------------------------------------------------- // 4 — mask enforcement: out-of-mask components are ignored // --------------------------------------------------------------------------- #[test] fn out_of_mask_components_are_ignored_on_the_grid() { let mut state = editor(); type_str(&mut state, "alpha beta"); let (rows, cols) = (8u32, 20u32); { let mut core = state.core.borrow_mut(); let win = core.active_window_mut(); win.selection = Some(pmacs::window::Selection { anchor: 0 }); win.cursor = 5; } // ui.selection mask is {bg}: fg + reverse must be ignored. exec( &state, r#"pmacs.theme.merge { ["ui.selection"] = { bg = 17 } }"#, ); let bg_only = paint_full_frame(&state, rows, cols); exec( &state, r#"pmacs.theme.merge { ["ui.selection"] = { fg = 1, reverse = true, bg = 17 } }"#, ); let with_extras = paint_full_frame(&state, rows, cols); assert_eq!( bg_only, with_extras, "fg/reverse on a wash face must render exactly as the bg-only face" ); assert_eq!( at(&bg_only, cols, 0, 0).style.bg, Color::Indexed(17), "the in-mask bg applies" ); assert_eq!( at(&bg_only, cols, 0, 0).style.fg, Color::Default, "the out-of-mask fg does not" ); // ui.gutter mask is {fg}: bg + reverse must be ignored. exec(&state, r#"pmacs.window.set_line_numbers("absolute")"#); exec( &state, r#"pmacs.theme.merge { ["ui.gutter"] = { fg = 9 } }"#, ); let fg_only = paint_full_frame(&state, rows, cols); exec( &state, r#"pmacs.theme.merge { ["ui.gutter"] = { fg = 9, bg = 1, reverse = true } }"#, ); let gutter_extras = paint_full_frame(&state, rows, cols); assert_eq!( fg_only, gutter_extras, "bg/reverse on a foreground-only site must render as the fg-only face" ); } // --------------------------------------------------------------------------- // 5 — wash faces merge over syntax-styled cells; the real search path // --------------------------------------------------------------------------- #[test] fn search_wash_faces_merge_over_syntax_and_split_active_from_lazy() { let dir = tempfile::tempdir().expect("tempdir"); // Two `fn` occurrences so a lazy and an active match coexist. let path = dir.path().join("faces.rs"); std::fs::write(&path, b"fn main() { fn_helper(); }\n").expect("write"); let mut state = open_and_wait_for_parse(path); let (rows, cols) = (8u32, 40u32); exec( &state, r#"pmacs.theme.merge { ["ui.search.match"] = { bg = 17 }, ["ui.search.match.active"] = { bg = 22 }, }"#, ); // The syntax-styled frame BEFORE any search: the per-cell fg // baseline the wash must preserve (`fn` at 0 is a keyword; the // `fn` inside `fn_helper` is function-colored — the wash must // keep each as-is, whatever the grammar decided). let before = paint_full_frame(&state, rows, cols); // The REAL path: dispatched C-s reaching ensure_search_overlay. ctrl(&mut state, 's'); type_str(&mut state, "fn"); let cells = paint_full_frame(&state, rows, cols); // Both `fn` occurrences: cols 0..2 and 12..14 on row 0. let first = at(&cells, cols, 0, 0); let second = at(&cells, cols, 0, 12); let bgs = [first.style.bg, second.style.bg]; assert!( bgs.contains(&Color::Indexed(22)), "one match is active (got {bgs:?})" ); assert!( bgs.contains(&Color::Indexed(17)), "one match is lazy (got {bgs:?})" ); // Merge semantics: each cell's syntax fg survives under the // bg-only wash (per-cell fg assertion, not any-styled-cell). for col in [0u32, 1, 12, 13] { assert_eq!( at(&cells, cols, 0, col).style.fg, at(&before, cols, 0, col).style.fg, "a bg-only wash keeps the syntax fg underneath (col {col})" ); assert_ne!( at(&before, cols, 0, col).style.bg, at(&cells, cols, 0, col).style.bg, "the wash bg landed (col {col})" ); } } // --------------------------------------------------------------------------- // 6 + 7 — diag faces on every grid surface + inheritance + empty child // --------------------------------------------------------------------------- #[test] fn diag_faces_recolor_squiggle_and_marker_with_inheritance_and_empty_child_reset() { use pmacs::diag::DiagnosticSeverity; let mut state = editor(); type_str(&mut state, "boom\nfine\n"); attach_diags( &state, vec![ diag(DiagnosticSeverity::Error), pmacs::diag::Diagnostic { start_line: 1, start_col: 0, end_line: 1, end_col: 3, severity: DiagnosticSeverity::Warning, message: "warn".into(), source: None, code: None, }, ], ); let (rows, cols) = (8u32, 20u32); // Unset: built-in severity colors (error Indexed(1) squiggle, // warning Indexed(3)). let base = paint_full_frame(&state, rows, cols); assert_eq!(at(&base, cols, 0, 0).style.underline, UnderlineStyle::Curly); assert_eq!( at(&base, cols, 0, 0).style.underline_color, Color::Indexed(1) ); assert_eq!( at(&base, cols, 1, 0).style.underline_color, Color::Indexed(3) ); // Inheritance: a themed ui.diag parent colors all severities. exec(&state, r#"pmacs.theme.merge { ["ui.diag"] = { fg = 93 } }"#); let inherited = paint_full_frame(&state, rows, cols); assert_eq!( at(&inherited, cols, 0, 0).style.underline_color, Color::Indexed(93), "error inherits ui.diag" ); assert_eq!( at(&inherited, cols, 1, 0).style.underline_color, Color::Indexed(93), "warning inherits ui.diag" ); // An exact EMPTY child blocks inheritance and resets errors to // the built-in color; warnings keep the parent's (Q#TH5, round 3 // finding 4). exec(&state, r#"pmacs.theme.merge { ["ui.diag.error"] = {} }"#); let reset = paint_full_frame(&state, rows, cols); assert_eq!( at(&reset, cols, 0, 0).style.underline_color, Color::Indexed(1), "empty child resets errors to the built-in" ); assert_eq!( at(&reset, cols, 1, 0).style.underline_color, Color::Indexed(93), "warnings still inherit" ); // An explicit colored child wins over the parent. exec( &state, r#"pmacs.theme.merge { ["ui.diag.error"] = { fg = 45 } }"#, ); let explicit = paint_full_frame(&state, rows, cols); assert_eq!( at(&explicit, cols, 0, 0).style.underline_color, Color::Indexed(45) ); } #[test] fn diag_face_recolors_the_minimap_marks_and_reships_the_summary() { use pmacs::diag::DiagnosticSeverity; let mut state = editor(); type_str(&mut state, "boom\nfine\n"); attach_diags(&state, vec![diag(DiagnosticSeverity::Error)]); let mut sem = semantic(&state); let first = sem.render_frame(&state); let lines = summary_of(&first).expect("first frame ships the summary"); assert_eq!( lines[0].underline_color, Color::Indexed(1), "unthemed mark carries the built-in error color" ); // A diag-face change with NO buffer edit re-ships the summary // with the resolved color (the minimap twin of the staleness // bite). exec( &state, r#"pmacs.theme.merge { ["ui.diag.error"] = { fg = 45 } }"#, ); let next = sem.render_frame(&state); let lines = summary_of(&next).expect("diag-face change re-ships the summary"); assert_eq!( lines[0].underline_color, Color::Indexed(45), "the mark recolors through ui.diag.error" ); // The mark is PRESENT (never Default — the diag Default policy // keeps presence representable). assert_ne!(lines[0].underline_color, Color::Default); } // --------------------------------------------------------------------------- // 8 — bare ui is a face key // --------------------------------------------------------------------------- #[test] fn bare_ui_merge_ships_the_catch_all_without_touching_spans() { let dir = tempfile::tempdir().expect("tempdir"); let state = open_and_wait_for_parse(rust_fixture(&dir)); let mut sem = semantic(&state); let first = sem.render_frame(&state); assert!(has_style_spans(&first), "grammar buffer ships spans"); assert_eq!(theme_facts_of(&first), Some(Vec::new())); exec(&state, r"pmacs.theme.merge { ui = { fg = 3 } }"); let next = sem.render_frame(&state); let facts = theme_facts_of(&next).expect("bare ui bumps face_epoch and emits"); assert_eq!(facts.len(), 12, "the catch-all resolves every stage-1 face"); assert!( facts .iter() .all(|f| f.style.fg == Color::Indexed(3) && f.name.starts_with("ui")), "each face resolved through the catch-all" ); assert!( !has_style_spans(&next), "a face key must classify as face, not syntax — no span re-emission" ); } // --------------------------------------------------------------------------- // 9 + 10 — the staleness bite + consecutive-set monotonicity // --------------------------------------------------------------------------- #[test] fn mid_session_recolor_reships_spans_without_an_edit() { let dir = tempfile::tempdir().expect("tempdir"); let state = open_and_wait_for_parse(rust_fixture(&dir)); let mut sem = semantic(&state); assert!(has_style_spans(&sem.render_frame(&state))); assert!( !has_style_spans(&sem.render_frame(&state)), "an unchanged tick is span-silent (the gate holds)" ); // Zero buffer edits; a capture recolor alone must re-ship. This // is the pre-existing GPU staleness bug's bite: pre-arc, the // StyleGate ignored the theme and this frame shipped nothing. exec(&state, r"pmacs.theme.set { keyword = { fg = 99 } }"); assert!( has_style_spans(&sem.render_frame(&state)), "a mid-session pmacs.theme.set must re-ship StyleSpans" ); } #[test] fn consecutive_sets_each_reship_and_coalesce_within_one_frame() { let dir = tempfile::tempdir().expect("tempdir"); let state = open_and_wait_for_parse(rust_fixture(&dir)); let mut sem = semantic(&state); let _ = sem.render_frame(&state); // set → observe → set → observe (round 2 finding 4's shape): each // mutation is observed by a render. Fails if wholesale // replacement resets the counters (the second set would share the // first's epoch and ship nothing). exec(&state, r"pmacs.theme.set { keyword = { fg = 99 } }"); assert!( has_style_spans(&sem.render_frame(&state)), "first set re-ships" ); exec(&state, r"pmacs.theme.set { keyword = { fg = 111 } }"); assert!( has_style_spans(&sem.render_frame(&state)), "second set re-ships" ); // Companion: two mutations inside one frame legitimately coalesce // into one emission carrying the SECOND set's color. exec(&state, r"pmacs.theme.set { keyword = { fg = 120 } }"); exec(&state, r"pmacs.theme.set { keyword = { fg = 130 } }"); let frame = sem.render_frame(&state); let span_styles: Vec