Skip to main content

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, &center_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, &center_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(&registry, &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(&registry, &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(&reg, &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(&reg, &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(&reg, &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(&reg, &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(&reg, &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(&reg, &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(&reg, &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(&reg, &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(&reg, &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(&reg, &cfg).padding, 1, "default padding");
895        cfg.parse_and_set_command("ui.modeline.padding=3").unwrap();
896        assert_eq!(resolve_layout(&reg, &cfg).padding, 3);
897        cfg.parse_and_set_command("ui.modeline.padding=0").unwrap();
898        assert_eq!(resolve_layout(&reg, &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}