lattice_host/modeline.rs
1//! Built-in modeline element vocabulary + shared per-pane content
2//! resolution (slice ML.1a-render).
3//!
4//! The modeline's **content strategy is common across renderers**: this
5//! module computes each built-in element's [`ElementContent`] (text +
6//! theme role) from `(pane, RenderState)`, host-side, so the TUI
7//! (ML.1a) and GPUI (ML.2) peers paint *identical* content and differ
8//! ONLY in layout / paint (and GPUI-only richness like tooltips and
9//! clicks). See `docs/dev/architecture/modeline.md` §4 (host-side
10//! computation), §8 (theme roles), §10 (cross-renderer parity).
11//!
12//! Two halves:
13//! - **descriptors** — [`register_builtin_elements`] registers the
14//! `core.*` set into the shared [`ModelineService`] once at boot
15//! (host owns the built-ins; modes/plugins own theirs, §6).
16//! - **content** — [`resolve_builtin_content`] computes per-pane
17//! built-in (`core.*`) content host-side. Mode/plugin elements (`lsp`,
18//! `diff`, …) are *pushed* over the event bus and drained into the
19//! content store (ML.3); the renderer reads them from the snapshot, so
20//! they never round-trip through this module.
21
22use std::collections::HashSet;
23
24use lattice_config::{
25 ConfigRegistry, ModelineCenter, ModelineLeft, ModelinePadding, ModelineRight,
26 ModelineSeparator, ModelineZone,
27};
28use lattice_core::ui::pane::PaneState;
29use lattice_protocol::CommandId;
30
31use lattice_mode::{
32 ElementContent, ElementId, ModelineElement, ModelineRegistry, ModelineRole, ModelineService,
33 Zone,
34};
35
36use crate::render_state::RenderState;
37
38// --- Built-in element ids ---------------------------------------------
39// Single source of truth shared by boot registration AND the renderers'
40// content resolution — no stringly-typed drift between the two sites.
41
42/// Modal-state label (`[NORMAL]`). Left zone, first.
43pub const CORE_MODE: &str = "core.mode";
44/// Buffer path + dirty marker (or a pane provider's custom label).
45pub const CORE_PATH: &str = "core.path";
46/// Cursor line:column. Right zone.
47pub const CORE_POSITION: &str = "core.position";
48/// Detected language label. Right zone, far right.
49pub const CORE_LANG: &str = "core.lang";
50/// ZP.4: zoom marker (`Z`) on the zoomed pane. Right zone, ahead of
51/// position — the marker is state about the pane, not about where the
52/// cursor is in it, so it reads better beside the mode tag's side of
53/// the row than buried past `line:col`.
54pub const CORE_ZOOM: &str = "core.zoom";
55
56// --- Theme roles ------------------------------------------------------
57// Assigned now so ML.1b is a pure theme-lookup change (the renderer
58// resolves these against `ResolvedTheme`); until then both peers fold
59// every role onto the single `pane_status_*` style.
60
61pub const ROLE_MODE: &str = "modeline.mode";
62pub const ROLE_PATH: &str = "modeline.path";
63pub const ROLE_POSITION: &str = "modeline.position";
64pub const ROLE_LANG: &str = "modeline.lang";
65/// ZP.4: the zoom marker's role. Reuses the mode role's slot in the
66/// theme rather than minting a colour: the marker is modal state about
67/// the pane, and matching the mode tag is what says so.
68pub const ROLE_ZOOM: &str = ROLE_MODE;
69// DX.4 (BC.6): `ROLE_MODE_ITEM` moved DOWN to `lattice-mode`'s modeline
70// module (it is the role *modes* tag contributed content with, so
71// `lattice-diff` can reach it without the host). Re-exported here so
72// renderer style maps (`ml::ROLE_MODE_ITEM`) + `crate::modeline` call
73// sites are unchanged; diff's import flips to `lattice_mode::modeline`
74// at DX.6.
75pub use lattice_mode::modeline::ROLE_MODE_ITEM;
76
77/// Register the host's built-in modeline descriptors. Called once at
78/// boot against the shared service (the same instance the renderers
79/// snapshot and modes reach via `ctx.service::<ModelineServiceHandle>()`).
80/// Priorities are uniform leftward→rightward in every zone; the
81/// renderer right-aligns the Right *block* (see [`Zone`]).
82pub fn register_builtin_elements(svc: &ModelineService) {
83 svc.register(ModelineElement::new(
84 ElementId::new(CORE_MODE),
85 Zone::Left,
86 0,
87 ));
88 svc.register(ModelineElement::new(
89 ElementId::new(CORE_PATH),
90 Zone::Left,
91 10,
92 ));
93 svc.register(ModelineElement::new(
94 ElementId::new(CORE_POSITION),
95 Zone::Right,
96 10,
97 ));
98 svc.register(ModelineElement::new(
99 ElementId::new(CORE_LANG),
100 Zone::Right,
101 20,
102 ));
103 // ZP.4: priority 0 puts the marker leftmost in the Right zone,
104 // ahead of `line:col`. It is absent entirely on unzoomed panes,
105 // so it costs no columns in the common case.
106 svc.register(ModelineElement::new(
107 ElementId::new(CORE_ZOOM),
108 Zone::Right,
109 0,
110 ));
111}
112
113/// Modal-state short label for the active document, read from the
114/// published [`RenderState`] (no actor crossing). Shared by the
115/// modeline resolver and the renderers' `modal_label` accessors so the
116/// vocabulary has one definition.
117pub fn modal_label(rs: &RenderState) -> &'static str {
118 use lattice_grammar::ModalState;
119 let ad = rs.active_document.load();
120 // A Terminal buffer reflects its own sub-state; the underlying
121 // modal stays Normal while terminal-insert is the discriminator.
122 if matches!(ad.buffer_kind, lattice_core::BufferKind::Terminal) {
123 return if ad.terminal_insert_active {
124 "TERMINAL-INSERT"
125 } else if ad.terminal_visual_active {
126 "TERMINAL-VISUAL"
127 } else {
128 "TERMINAL"
129 };
130 }
131 match ad.modal {
132 ModalState::Normal => "NORMAL",
133 ModalState::Insert => "INSERT",
134 ModalState::Visual(_) => "VISUAL",
135 ModalState::Select(_) => "SELECT",
136 ModalState::OperatorPending => "O-PEND",
137 ModalState::Command => "CMD",
138 ModalState::Search(_) => "SEARCH",
139 ModalState::Replace => "REPLACE",
140 ModalState::Prompt => "PROMPT",
141 }
142}
143
144/// Lean 3-letter modal label for the **modeline** (ML.5d). The full
145/// name ([`modal_label`]) is echoed in the echo area on mode *change*;
146/// the persistent modeline tag stays compact + modern (Helix-style
147/// `NOR`/`INS`/`VIS`), disambiguated by colour through the
148/// `modeline.mode` theme role. Same source-of-truth read as
149/// [`modal_label`] (published `RenderState`, no actor crossing).
150pub fn modal_label_short(rs: &RenderState) -> &'static str {
151 use lattice_grammar::ModalState;
152 let ad = rs.active_document.load();
153 if matches!(ad.buffer_kind, lattice_core::BufferKind::Terminal) {
154 return if ad.terminal_insert_active {
155 "TIN"
156 } else if ad.terminal_visual_active {
157 "TVI"
158 } else {
159 "TRM"
160 };
161 }
162 match ad.modal {
163 ModalState::Normal => "NOR",
164 ModalState::Insert => "INS",
165 ModalState::Visual(_) => "VIS",
166 ModalState::Select(_) => "SEL",
167 ModalState::OperatorPending => "OPN",
168 ModalState::Command => "CMD",
169 ModalState::Search(_) => "SEA",
170 ModalState::Replace => "REP",
171 ModalState::Prompt => "PMT",
172 }
173}
174
175/// Shorten a path to be relative to a base directory if the
176/// `path.relative` option is enabled and a base directory is set.
177fn maybe_shorten_path(path: &std::path::Path, rs: &RenderState) -> String {
178 let relative = rs
179 .options
180 .config
181 .get_typed::<lattice_config::core_options::PathRelative>()
182 .map(|v| *v)
183 .unwrap_or(false);
184 if relative
185 && let Some(ref cwd) = rs.current_dir
186 && let Ok(rel) = path.strip_prefix(cwd)
187 {
188 let s = rel.display().to_string();
189 return if s.is_empty() { ".".to_string() } else { s };
190 }
191 path.display().to_string()
192}
193
194/// The buffer-label segment for `pane`: a pane provider's custom label
195/// when one is supplied (`provider_label` — the file-tree / oil / help
196/// M.4 mechanism, resolved renderer-side and passed in so the assembly
197/// stays common), else the document path + dirty marker (or the
198/// registry name slot for non-document panes). Empty only for the
199/// genuinely-nameless case, which the caller treats as hidden.
200pub fn pane_path_segment(
201 pane: &PaneState,
202 rs: &RenderState,
203 provider_label: Option<&str>,
204) -> String {
205 if let Some(label) = provider_label {
206 return label.to_string();
207 }
208 let reg = &rs.buffers.registry;
209 // Non-document panes (Terminal, …) without a provider: name slot.
210 if !reg.contains_document(pane.buffer_id) {
211 return reg
212 .name_of(pane.buffer_id)
213 .unwrap_or_else(|| "[no buffer]".to_string());
214 }
215 let label = reg
216 .document_path(pane.buffer_id)
217 .map(|p| maybe_shorten_path(p.as_path(), rs))
218 .or_else(|| reg.name_of(pane.buffer_id))
219 .unwrap_or_else(|| "[no name]".to_string());
220 // Suppress the dirty marker for synthetics (streamed buffers the
221 // user can never save).
222 let synthetic = reg.name_of(pane.buffer_id).is_some();
223 let dirty = if !synthetic && reg.document_dirty(pane.buffer_id) {
224 " [+]"
225 } else {
226 ""
227 };
228 format!("{label}{dirty}")
229}
230
231/// Detected-language label for `pane`'s buffer (empty when no path).
232fn lang_label(pane: &PaneState, rs: &RenderState) -> &'static str {
233 rs.buffers
234 .registry
235 .document_path(pane.buffer_id)
236 .map(|p| lattice_syntax::Lang::detect_from_path(Some(p.as_path())).label())
237 .unwrap_or("")
238}
239
240/// Resolve a built-in (`core.*`) element's content for `pane`. Pure
241/// reads off the published [`RenderState`] — O(1), no allocation
242/// proportional to document size (paramount #1).
243///
244/// `is_active` gates the elements that read *active-document* global
245/// state: `core.mode` (the modal label is a single active-doc value —
246/// showing it on an inactive pane would be wrong, not just noisy) and
247/// `core.lang` (parity with the legacy footer, which omitted it on
248/// inactive panes). `provider_label` overrides `core.path` for
249/// provider-backed panes (file-tree / oil / help).
250///
251/// An empty [`ElementContent`] means "hidden this frame" — the caller
252/// skips it (the same contract [`lattice_mode::ModelineSnapshot::zone`]
253/// uses).
254pub fn resolve_builtin_content(
255 id: &str,
256 pane: &PaneState,
257 is_active: bool,
258 rs: &RenderState,
259 provider_label: Option<&str>,
260) -> ElementContent {
261 match id {
262 CORE_MODE => {
263 if is_active {
264 // Lean 3-letter tag, no brackets (ML.5d); colour comes
265 // from the `modeline.mode` role. The full mode name is
266 // echoed in the echo area on mode change instead.
267 ElementContent::text(modal_label_short(rs), ModelineRole::new(ROLE_MODE))
268 } else {
269 ElementContent::default()
270 }
271 }
272 CORE_PATH => {
273 let text = pane_path_segment(pane, rs, provider_label);
274 if text.is_empty() {
275 ElementContent::default()
276 } else {
277 ElementContent::text(text, ModelineRole::new(ROLE_PATH))
278 }
279 }
280 CORE_POSITION => {
281 // The active (focused) pane's live cursor lives in
282 // `rs.active_document` (published from `self.cursor` on every
283 // motion); the pane-tree stash `pane.cursor` is only written
284 // back when the pane goes inactive, so reading it here would
285 // freeze line/col on the focused pane. Inactive panes keep
286 // their stashed cursor.
287 let cursor = if is_active {
288 rs.active_document.load().cursor
289 } else {
290 pane.cursor
291 };
292 ElementContent::text(
293 format!("{}:{}", cursor.line + 1, cursor.byte),
294 ModelineRole::new(ROLE_POSITION),
295 )
296 }
297 CORE_LANG => {
298 let lang = if is_active { lang_label(pane, rs) } else { "" };
299 if lang.is_empty() {
300 ElementContent::default()
301 } else {
302 ElementContent::text(lang, ModelineRole::new(ROLE_LANG))
303 }
304 }
305 CORE_ZOOM => {
306 // Shown on the ZOOMED pane only, which under the
307 // zoomed-is-active invariant is the active one. Guarding on
308 // both is not redundant: `is_active` is what the renderer
309 // knows, `zoomed_index` is what the tree knows, and an
310 // inactive pane painting a zoom marker would claim the
311 // wrong thing about itself if the two ever drifted.
312 let zoomed = rs
313 .panes
314 .tree
315 .zoomed_index()
316 .and_then(|i| rs.panes.tree.leaves().get(i))
317 .is_some_and(|z| z.id == pane.id);
318 if !(zoomed && is_active) {
319 return ElementContent::default();
320 }
321 let indicator =
322 rs.resolved_option_for::<lattice_config::PaneZoomIndicator>(pane.committed_id());
323 if indicator.shows_modeline() {
324 ElementContent::text(
325 lattice_core::ui::pane::ZOOM_MARKER,
326 ModelineRole::new(ROLE_ZOOM),
327 )
328 } else {
329 ElementContent::default()
330 }
331 }
332 _ => ElementContent::default(),
333 }
334}
335
336// --- Config-driven zone layout (ML.5) -------------------------------
337// The renderers iterate THIS instead of `registry.zone_ordered(zone)`
338// so the `ui.modeline.{left,center,right}` options drive membership +
339// order, while built-in content stays computed host-side
340// (`resolve_builtin_content`) and pushed content stays read from the
341// snapshot. Shared by both peers so only paint differs (design §11).
342
343/// A pane modeline's resolved per-zone layout (ML.5): the ordered
344/// element descriptors for each zone after applying the `ui.modeline.*`
345/// config, plus the configured inter-element separator. Descriptors are
346/// borrowed from the snapshot registry (`'a`).
347pub struct ModelineLayout<'a> {
348 pub left: Vec<&'a ModelineElement>,
349 pub center: Vec<&'a ModelineElement>,
350 pub right: Vec<&'a ModelineElement>,
351 /// The effective inter-element separator the renderer inserts within
352 /// a zone — already padded: a non-blank `ui.modeline.separator` is
353 /// surrounded by a space each side (`|` → ` | `), a blank one is a
354 /// single space. So the renderer inserts it verbatim.
355 pub separator: String,
356 /// `ui.modeline.padding` — columns of blank margin at the start
357 /// (before Left) and end (after Right) of the row.
358 pub padding: usize,
359}
360
361/// Resolve `cfg` for one `zone` against `registry`. `Auto` → descriptor
362/// placement (`zone_ordered`) minus ids `claimed` by an explicit zone;
363/// explicit `Ids` → exactly those registered ids in order (unknown ids
364/// skipped + logged, never panic). Free fn (not a closure) so the
365/// `'a` borrow from `registry` is expressed cleanly.
366fn resolve_zone_descriptors<'a>(
367 registry: &'a ModelineRegistry,
368 zone: Zone,
369 cfg: &ModelineZone,
370 claimed: &HashSet<String>,
371) -> Vec<&'a ModelineElement> {
372 match cfg.ids() {
373 Some(ids) => ids
374 .iter()
375 .filter_map(|id| {
376 let el = registry.get(&ElementId::new(id.as_ref()));
377 if el.is_none() {
378 tracing::debug!(
379 target: "modeline",
380 id = id.as_ref(),
381 "ui.modeline.* references an unregistered element id; skipping"
382 );
383 }
384 el
385 })
386 .collect(),
387 None => registry
388 .zone_ordered(zone)
389 .into_iter()
390 .filter(|el| !claimed.contains(el.id.as_str()))
391 .collect(),
392 }
393}
394
395/// Read a zone option's current value (cloned out of the wait-free
396/// snapshot), defaulting to `Auto` when the registry hasn't been seeded
397/// (tests / early boot).
398fn zone_option<D>(config: &ConfigRegistry) -> ModelineZone
399where
400 D: lattice_config::OptionDecl<Value = ModelineZone>,
401{
402 config
403 .get_typed::<D>()
404 .map(|a| (*a).clone())
405 .unwrap_or_default()
406}
407
408/// Resolve the full per-zone modeline layout for a pane from the
409/// descriptor registry + the `ui.modeline.{left,center,right,separator}`
410/// typed options (ML.5; design §11).
411///
412/// Per zone:
413/// - **`Auto`** (the default) → descriptor-driven: `zone_ordered(zone)`,
414/// minus any element id claimed by an *explicit* (non-`Auto`) other
415/// zone, so a moved element never double-renders. With no config at
416/// all every zone is `Auto` ⇒ exactly the pre-ML.5 descriptor layout.
417/// - **explicit `Ids`** → exactly those registered ids, in listed order;
418/// unknown / unregistered ids are skipped + logged (`debug!`, never
419/// panic). An empty list is an explicitly-blank zone.
420///
421/// The renderer still resolves each descriptor's *content* itself
422/// (built-ins host-side via [`resolve_builtin_content`], pushed via the
423/// snapshot) and applies the `separator`; this fn owns only the
424/// membership + order decision so both peers share it.
425pub fn resolve_layout<'a>(
426 registry: &'a ModelineRegistry,
427 config: &ConfigRegistry,
428) -> ModelineLayout<'a> {
429 let left_cfg = zone_option::<ModelineLeft>(config);
430 let center_cfg = zone_option::<ModelineCenter>(config);
431 let right_cfg = zone_option::<ModelineRight>(config);
432
433 // Effective separator: a non-blank glyph is auto-padded with a
434 // space each side (so `|` renders as ` | ` — the user gives the
435 // glyph, the renderer owns the spacing, sidestepping the `:set` /
436 // TOML whitespace-trim). A blank separator is a single space.
437 let raw_sep = config
438 .get_typed::<ModelineSeparator>()
439 .map(|a| (*a).clone())
440 .unwrap_or_else(|| " ".to_string());
441 let trimmed_sep = raw_sep.trim();
442 let separator = if trimmed_sep.is_empty() {
443 " ".to_string()
444 } else {
445 format!(" {trimmed_sep} ")
446 };
447
448 // Start/end row margin (`ui.modeline.padding`, default 1; validated
449 // 0..=16). Clamp defensively in case the registry isn't seeded.
450 let padding = config
451 .get_typed::<ModelinePadding>()
452 .map(|a| (*a).max(0) as usize)
453 .unwrap_or(1);
454
455 // Ids placed by any explicit (non-Auto) zone → removed from the Auto
456 // fallback of the other zones, so moving an element into an explicit
457 // zone doesn't leave a duplicate in its descriptor zone.
458 let mut claimed: HashSet<String> = HashSet::new();
459 for cfg in [&left_cfg, ¢er_cfg, &right_cfg] {
460 if let Some(ids) = cfg.ids() {
461 claimed.extend(ids.iter().map(|id| id.as_ref().to_string()));
462 }
463 }
464
465 ModelineLayout {
466 left: resolve_zone_descriptors(registry, Zone::Left, &left_cfg, &claimed),
467 center: resolve_zone_descriptors(registry, Zone::Center, ¢er_cfg, &claimed),
468 right: resolve_zone_descriptors(registry, Zone::Right, &right_cfg, &claimed),
469 separator,
470 padding,
471 }
472}
473
474/// ML.4: one clickable region of a painted modeline row.
475///
476/// Coordinates are **absolute terminal cells**, not pane- or
477/// zone-relative, because that is the space a mouse event arrives in
478/// and translating once at record time beats translating at every
479/// hit test.
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub struct ModelineHitZone {
482 /// Terminal row the modeline occupies.
483 pub row: u16,
484 /// First column of the region.
485 pub col_start: u16,
486 /// One past the last column.
487 pub col_end: u16,
488 /// The command a click dispatches. The handler body lives in the
489 /// mode or plugin that registered the element — the host routes
490 /// and nothing more (modeline.md §6, §9).
491 pub action: CommandId,
492}
493
494/// ML.4: every clickable region on screen, rebuilt each frame.
495///
496/// This is the TUI's half of the interaction contract. The GPUI peer
497/// needs no equivalent — its elements are real `div`s with their own
498/// `on_mouse_down` listeners, so the window system does the hit test.
499/// The two peers therefore reach the same behaviour by different
500/// mechanisms, which is what modeline.md §9 specifies rather than an
501/// accident of implementation: a terminal has no element tree to
502/// attach a listener to.
503///
504/// Later zones win a tie. Nothing in the layout produces overlapping
505/// regions today, but "last write wins" is the rule a painter follows,
506/// so a hit test that disagreed with what is on screen would be the
507/// bug.
508#[derive(Debug, Clone, Default)]
509pub struct ModelineHitMap {
510 zones: Vec<ModelineHitZone>,
511}
512
513impl ModelineHitMap {
514 pub fn new() -> Self {
515 Self::default()
516 }
517
518 /// Drop every recorded region. Called at the top of each frame —
519 /// a stale map would dispatch clicks against a layout that is no
520 /// longer painted.
521 pub fn clear(&mut self) {
522 self.zones.clear();
523 }
524
525 pub fn push(&mut self, zone: ModelineHitZone) {
526 // An empty or inverted region can never be hit; keeping it
527 // would only make the map longer to walk.
528 if zone.col_end > zone.col_start {
529 self.zones.push(zone);
530 }
531 }
532
533 pub fn is_empty(&self) -> bool {
534 self.zones.is_empty()
535 }
536
537 pub fn len(&self) -> usize {
538 self.zones.len()
539 }
540
541 /// The command at `(col, row)`, if any.
542 pub fn hit(&self, col: u16, row: u16) -> Option<CommandId> {
543 self.zones
544 .iter()
545 .rev()
546 .find(|z| z.row == row && col >= z.col_start && col < z.col_end)
547 .map(|z| z.action)
548 }
549}
550
551#[cfg(test)]
552mod hit_tests {
553 use super::*;
554
555 fn zone(row: u16, start: u16, end: u16, action: u64) -> ModelineHitZone {
556 ModelineHitZone {
557 row,
558 col_start: start,
559 col_end: end,
560 action: CommandId(action),
561 }
562 }
563
564 #[test]
565 fn a_click_inside_a_region_finds_its_command() {
566 let mut map = ModelineHitMap::new();
567 map.push(zone(23, 4, 10, 7));
568 assert_eq!(map.hit(4, 23), Some(CommandId(7)), "left edge is inside");
569 assert_eq!(map.hit(9, 23), Some(CommandId(7)), "last column is inside");
570 }
571
572 #[test]
573 fn the_end_column_is_exclusive() {
574 let mut map = ModelineHitMap::new();
575 map.push(zone(23, 4, 10, 7));
576 assert_eq!(map.hit(10, 23), None, "col_end belongs to the next region");
577 assert_eq!(map.hit(3, 23), None);
578 }
579
580 #[test]
581 fn a_click_on_another_row_misses() {
582 let mut map = ModelineHitMap::new();
583 map.push(zone(23, 4, 10, 7));
584 assert_eq!(map.hit(5, 22), None);
585 assert_eq!(map.hit(5, 24), None);
586 }
587
588 #[test]
589 fn adjacent_regions_do_not_bleed() {
590 let mut map = ModelineHitMap::new();
591 map.push(zone(1, 0, 5, 100));
592 map.push(zone(1, 5, 9, 200));
593 assert_eq!(map.hit(4, 1), Some(CommandId(100)));
594 assert_eq!(map.hit(5, 1), Some(CommandId(200)));
595 }
596
597 #[test]
598 fn separate_panes_keep_separate_rows_and_columns() {
599 // A vertical split: two modelines on the same row, different
600 // column spans. Clicking one must not reach the other.
601 let mut map = ModelineHitMap::new();
602 map.push(zone(23, 2, 8, 1));
603 map.push(zone(23, 42, 48, 2));
604 assert_eq!(map.hit(5, 23), Some(CommandId(1)));
605 assert_eq!(map.hit(45, 23), Some(CommandId(2)));
606 assert_eq!(map.hit(20, 23), None, "the gap between panes is dead");
607 }
608
609 #[test]
610 fn empty_and_inverted_regions_are_not_recorded() {
611 let mut map = ModelineHitMap::new();
612 map.push(zone(1, 5, 5, 1));
613 map.push(zone(1, 9, 4, 2));
614 assert!(map.is_empty());
615 assert_eq!(map.hit(5, 1), None);
616 }
617
618 #[test]
619 fn clear_drops_the_previous_frames_regions() {
620 let mut map = ModelineHitMap::new();
621 map.push(zone(1, 0, 5, 1));
622 map.clear();
623 assert_eq!(
624 map.hit(2, 1),
625 None,
626 "a stale map would dispatch against a layout no longer painted"
627 );
628 }
629
630 #[test]
631 fn a_later_region_wins_an_overlap() {
632 let mut map = ModelineHitMap::new();
633 map.push(zone(1, 0, 10, 1));
634 map.push(zone(1, 4, 6, 2));
635 assert_eq!(
636 map.hit(5, 1),
637 Some(CommandId(2)),
638 "last painted wins, matching what is on screen"
639 );
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 // --- ML.5 resolve_layout -----------------------------------------
648
649 /// A registry holding just the four `core.*` built-ins (diff / lsp
650 /// are owner-registered at boot; not needed for layout tests).
651 fn builtin_registry() -> std::sync::Arc<ModelineRegistry> {
652 let svc = ModelineService::new();
653 register_builtin_elements(&svc);
654 svc.snapshot().registry
655 }
656
657 /// A config registry with every workspace option (incl. the real
658 /// `ui.modeline.*`) seeded at their defaults (all zones `Auto`).
659 fn modeline_config() -> ConfigRegistry {
660 let r = ConfigRegistry::new();
661 r.init_from_linkme();
662 r
663 }
664
665 fn zone_ids(els: &[&ModelineElement]) -> Vec<String> {
666 els.iter().map(|e| e.id.as_str().to_string()).collect()
667 }
668
669 /// ML.4 parity contract. Both renderer peers reach a click target
670 /// the same way — `el.interaction.on_click` on the descriptors
671 /// `resolve_layout` hands back — and differ only in what they do
672 /// with it (TUI records painted column ranges; GPUI hangs an
673 /// `on_mouse_down` on the element's own `div`). Pinning it here,
674 /// upstream of both, is what makes "both peers honour on_click" a
675 /// property of the shared resolver rather than two implementations
676 /// that happen to agree today.
677 #[test]
678 fn resolve_layout_preserves_a_declared_click_target() {
679 let svc = ModelineService::new();
680 register_builtin_elements(&svc);
681 svc.register(
682 ModelineElement::new(ElementId::new("clicky"), Zone::Right, 5).with_interaction(
683 lattice_mode::Interaction {
684 on_click: Some(CommandId(42)),
685 hover: None,
686 },
687 ),
688 );
689 let registry = svc.snapshot().registry;
690 let cfg = modeline_config();
691 let layout = resolve_layout(®istry, &cfg);
692
693 let el = layout
694 .right
695 .iter()
696 .find(|e| e.id.as_str() == "clicky")
697 .expect("the registered element is placed in its zone");
698 assert_eq!(
699 el.interaction.as_ref().and_then(|i| i.on_click),
700 Some(CommandId(42)),
701 "the descriptor both peers read must carry the click target"
702 );
703 }
704
705 /// The overwhelmingly common case: an element that declared no
706 /// interaction stays inert, so neither peer records a region or
707 /// attaches a listener for it.
708 #[test]
709 fn builtins_declare_no_click_target() {
710 let registry = builtin_registry();
711 let cfg = modeline_config();
712 let layout = resolve_layout(®istry, &cfg);
713 for el in layout.left.iter().chain(layout.right.iter()) {
714 assert!(
715 el.interaction.is_none(),
716 "{} must not be clickable by default",
717 el.id.as_str()
718 );
719 }
720 }
721
722 /// No config (all zones `Auto`) ⇒ the pre-ML.5 descriptor layout,
723 /// and the default separator is a single space.
724 #[test]
725 fn resolve_layout_auto_matches_descriptor_order() {
726 let reg = builtin_registry();
727 let cfg = modeline_config();
728 let layout = resolve_layout(®, &cfg);
729 assert_eq!(zone_ids(&layout.left), ["core.mode", "core.path"]);
730 // ZP.4: `core.zoom` sits at priority 0 — leftmost in Right,
731 // ahead of `line:col`. It resolves to empty content unless a
732 // pane is zoomed, so it costs no columns in the common case.
733 assert_eq!(
734 zone_ids(&layout.right),
735 ["core.zoom", "core.position", "core.lang"]
736 );
737 assert!(layout.center.is_empty());
738 assert_eq!(layout.separator, " ");
739 }
740
741 /// An explicit zone list reorders membership (by listed order, not
742 /// descriptor priority) — proves a live `:set` takes effect on the
743 /// next resolve.
744 #[test]
745 fn resolve_layout_explicit_list_reorders() {
746 let reg = builtin_registry();
747 let cfg = modeline_config();
748 cfg.parse_and_set_command("ui.modeline.right=core.lang,core.position")
749 .unwrap();
750 let layout = resolve_layout(®, &cfg);
751 // Listed order, not the descriptor priority order.
752 assert_eq!(zone_ids(&layout.right), ["core.lang", "core.position"]);
753 }
754
755 /// OC.3 / ML.6 — a **plugin-contributed** element rides the same layout
756 /// path a native one does, in both renderer peers, with no new renderer
757 /// code.
758 ///
759 /// This is the cross-renderer parity check for that slice, made at the layer
760 /// the peers actually share. Both `lattice-ui-tui::render::modeline_segments`
761 /// and `lattice-ui-gpui::window::modeline_row` do exactly two things per
762 /// element: take [`resolve_layout`]'s zone ordering, then branch on a
763 /// `core.` id prefix — built-ins resolved host-side, everything else looked
764 /// up with [`ModelineSnapshot::resolve`]. So proving a `Global`-scoped
765 /// non-`core.` element orders correctly and resolves for *any* pane buffer
766 /// proves it in both, and a grep for `Effect::` / renderer match arms finds
767 /// nothing to add because there is nothing kind-specific to add.
768 ///
769 /// The one place the peers genuinely diverge is the theme role: each matches
770 /// a closed five-constant set and their fallbacks differ (TUI unstyled, GPUI
771 /// the path colour). That is why the `ui` seam fixes every plugin span to
772 /// `modeline.mode_item` rather than taking a role parameter — it is the role
773 /// both peers resolve identically, and the only one either native modeline
774 /// producer uses.
775 #[test]
776 fn a_plugin_element_orders_and_resolves_like_a_native_one() {
777 use lattice_mode::modeline::{ElementContent, ModelineKey, ModelineRole, Scope, Span};
778
779 // Built through the real service, exactly as boot + `ui_host` do.
780 let svc = ModelineService::new();
781 register_builtin_elements(&svc);
782 // Shaped exactly as `ui_host::register_segment` builds it: a namespaced
783 // id, `Right`, `Global` scope.
784 svc.register(
785 ModelineElement::new(ElementId::new("org.clock"), Zone::Right, 7)
786 .with_scope(Scope::Global),
787 );
788 let reg = svc.snapshot().registry;
789 let layout = resolve_layout(®, &modeline_config());
790 assert_eq!(
791 zone_ids(&layout.right),
792 ["core.zoom", "org.clock", "core.position", "core.lang"],
793 "priority orders a plugin element among the built-ins with no \
794 special case — 7 sits below core.position's 10 and above \
795 core.zoom's 0"
796 );
797
798 // And its content resolves for whichever buffer a pane happens to show,
799 // because a plugin element is global — it has no buffer to scope to.
800 svc.update(
801 ModelineKey::Global,
802 ElementId::new("org.clock"),
803 ElementContent {
804 spans: vec![Span::new("◷ 0:14", ModelineRole::new("modeline.mode_item"))],
805 },
806 );
807 let snap = svc.snapshot();
808 let el = snap
809 .registry
810 .get(&ElementId::new("org.clock"))
811 .expect("registered above");
812 for buffer in [
813 lattice_core::BufferId(1),
814 lattice_core::BufferId(2),
815 lattice_core::BufferId(99),
816 ] {
817 assert_eq!(
818 snap.resolve(el, buffer).map(|c| c.spans[0].text.as_str()),
819 Some("◷ 0:14"),
820 "a global plugin element shows in every pane"
821 );
822 }
823 }
824
825 /// Unknown / unregistered ids in an explicit list are skipped (and
826 /// logged) — never panic.
827 #[test]
828 fn resolve_layout_skips_unknown_ids() {
829 let reg = builtin_registry();
830 let cfg = modeline_config();
831 cfg.parse_and_set_command("ui.modeline.left=core.mode,does.not.exist,core.path")
832 .unwrap();
833 let layout = resolve_layout(®, &cfg);
834 assert_eq!(zone_ids(&layout.left), ["core.mode", "core.path"]);
835 }
836
837 /// Moving an element into an explicit zone removes it from the
838 /// Auto fallback of its descriptor zone (no double-render).
839 #[test]
840 fn resolve_layout_claimed_id_drops_from_auto_zone() {
841 let reg = builtin_registry();
842 let cfg = modeline_config();
843 // Move core.position into an explicit Left; leave Right Auto.
844 cfg.parse_and_set_command("ui.modeline.left=core.mode,core.path,core.position")
845 .unwrap();
846 let layout = resolve_layout(®, &cfg);
847 assert_eq!(
848 zone_ids(&layout.left),
849 ["core.mode", "core.path", "core.position"]
850 );
851 // Right is Auto, but core.position is claimed by Left → zoom + lang.
852 assert_eq!(zone_ids(&layout.right), ["core.zoom", "core.lang"]);
853 }
854
855 /// An explicitly-empty list (`[]` / `:set ui.modeline.right=`)
856 /// blanks the zone, distinct from `Auto`.
857 #[test]
858 fn resolve_layout_empty_list_blanks_zone() {
859 let reg = builtin_registry();
860 let cfg = modeline_config();
861 cfg.parse_and_set_command("ui.modeline.right=").unwrap();
862 let layout = resolve_layout(®, &cfg);
863 assert!(layout.right.is_empty(), "explicit empty Right zone");
864 // Left untouched (still Auto / descriptor-driven).
865 assert_eq!(zone_ids(&layout.left), ["core.mode", "core.path"]);
866 }
867
868 /// A non-blank `ui.modeline.separator` is **auto-padded** with a
869 /// space on each side — the user gives the glyph, the renderer owns
870 /// the spacing (sidesteps the `:set` / TOML whitespace-trim, which
871 /// is why `|` and ` | ` previously rendered identically).
872 #[test]
873 fn resolve_layout_auto_pads_glyph_separator() {
874 let reg = builtin_registry();
875 let cfg = modeline_config();
876 cfg.parse_and_set_command("ui.modeline.separator=|")
877 .unwrap();
878 assert_eq!(resolve_layout(®, &cfg).separator, " | ");
879 // ` | ` trims to `|` then re-pads to the same — no double space.
880 cfg.parse_and_set_command("ui.modeline.separator= | ")
881 .unwrap();
882 assert_eq!(resolve_layout(®, &cfg).separator, " | ");
883 // A blank separator stays a single space (the default look).
884 cfg.parse_and_set_command("ui.modeline.separator= ")
885 .unwrap();
886 assert_eq!(resolve_layout(®, &cfg).separator, " ");
887 }
888
889 /// `ui.modeline.padding` defaults to 1 and flows into the layout.
890 #[test]
891 fn resolve_layout_reads_padding_default_and_set() {
892 let reg = builtin_registry();
893 let cfg = modeline_config();
894 assert_eq!(resolve_layout(®, &cfg).padding, 1, "default padding");
895 cfg.parse_and_set_command("ui.modeline.padding=3").unwrap();
896 assert_eq!(resolve_layout(®, &cfg).padding, 3);
897 cfg.parse_and_set_command("ui.modeline.padding=0").unwrap();
898 assert_eq!(resolve_layout(®, &cfg).padding, 0, "flush to edges");
899 }
900
901 /// Boot registers the built-ins with the spec'd zones +
902 /// priorities (modeline.md §3 / slice plan ML.1a-render).
903 #[test]
904 fn boot_registers_builtin_descriptors() {
905 let document = lattice_core::Document::empty();
906 let editor = crate::editor::Editor::boot(document);
907 let snap = editor.modeline.snapshot();
908 let reg = &snap.registry;
909 // Five `core.*` built-ins (ZP.4 added `core.zoom`) + the diff
910 // subsystem's `diff` element (ML.3b) + lattice-lsp's `lsp`
911 // element (ML.3c), all registered at boot by their owners.
912 assert_eq!(reg.len(), 7, "five core built-ins + diff + lsp");
913
914 let mode = reg.get(&ElementId::new(CORE_MODE)).unwrap();
915 assert_eq!((mode.zone, mode.priority), (Zone::Left, 0));
916 let path = reg.get(&ElementId::new(CORE_PATH)).unwrap();
917 assert_eq!((path.zone, path.priority), (Zone::Left, 10));
918 let pos = reg.get(&ElementId::new(CORE_POSITION)).unwrap();
919 assert_eq!((pos.zone, pos.priority), (Zone::Right, 10));
920 let lang = reg.get(&ElementId::new(CORE_LANG)).unwrap();
921 assert_eq!((lang.zone, lang.priority), (Zone::Right, 20));
922 // ZP.4: priority 0 in the Right zone puts the zoom marker
923 // leftmost there, ahead of `line:col`.
924 let zoom = reg.get(&ElementId::new(CORE_ZOOM)).unwrap();
925 assert_eq!((zoom.zone, zoom.priority), (Zone::Right, 0));
926 }
927
928 /// `core.mode` shows the modal label only on the active pane (it is
929 /// a single active-document read); `core.position` always shows.
930 #[test]
931 fn builtin_mode_is_active_only_position_always() {
932 let document = lattice_core::Document::empty();
933 let mut editor = crate::editor::Editor::boot(document);
934 let rs = editor.build_render_state();
935 let pane = *editor.pane_tree.active();
936
937 // ML.5d: lean 3-letter tag, no brackets.
938 let active_mode = resolve_builtin_content(CORE_MODE, &pane, true, &rs, None);
939 assert_eq!(active_mode.plain(), "NOR");
940 let inactive_mode = resolve_builtin_content(CORE_MODE, &pane, false, &rs, None);
941 assert!(inactive_mode.is_empty(), "mode hidden on inactive panes");
942
943 // Position renders regardless of active/inactive; row 1, col 0.
944 let pos = resolve_builtin_content(CORE_POSITION, &pane, false, &rs, None);
945 assert_eq!(pos.plain(), "1:0");
946 }
947
948 /// Regression: `core.position` on the ACTIVE pane tracks the live
949 /// cursor (`self.cursor` → `rs.active_document.cursor`), not the
950 /// pane-tree stash (`pane.cursor`), which is only written back when
951 /// the pane goes inactive. Before the fix the modeline froze at the
952 /// stashed position while the caret moved.
953 #[test]
954 fn builtin_position_tracks_live_cursor_on_active_pane() {
955 let document = lattice_core::Document::empty();
956 let mut editor = crate::editor::Editor::boot(document);
957 // Move the live cursor without switching panes, so the pane-tree
958 // stash stays at origin (motion doesn't sync it back).
959 editor.set_cursor(lattice_protocol::position::Position::new(1, 4));
960 let rs = editor.build_render_state();
961 let pane = *editor.pane_tree.active();
962
963 // The pane-tree stash is still at origin.
964 assert_eq!(pane.cursor, lattice_protocol::position::Position::ZERO);
965
966 // Active pane reads the live cursor: line 2 (1-based), byte 4.
967 let active = resolve_builtin_content(CORE_POSITION, &pane, true, &rs, None);
968 assert_eq!(active.plain(), "2:4");
969
970 // Inactive panes keep their own stashed cursor.
971 let inactive = resolve_builtin_content(CORE_POSITION, &pane, false, &rs, None);
972 assert_eq!(inactive.plain(), "1:0");
973 }
974
975 /// A provider label overrides `core.path`; without one, the empty
976 /// scratch document falls back to the registry name slot.
977 #[test]
978 fn builtin_path_honours_provider_label() {
979 let document = lattice_core::Document::empty();
980 let mut editor = crate::editor::Editor::boot(document);
981 let rs = editor.build_render_state();
982 let pane = *editor.pane_tree.active();
983
984 let overridden = resolve_builtin_content(CORE_PATH, &pane, true, &rs, Some("[tree] /root"));
985 assert_eq!(overridden.plain(), "[tree] /root");
986
987 // No provider: a fresh scratch buffer has no path; the segment
988 // is non-empty (a name slot or "[no name]"), never a panic.
989 let fallback = resolve_builtin_content(CORE_PATH, &pane, true, &rs, None);
990 assert!(!fallback.plain().is_empty());
991 }
992}