Skip to main content

lattice_mode/
modeline.rs

1//! Modeline element model + descriptor registry (slice ML.0a).
2//!
3//! The configurable modeline (see
4//! `docs/dev/architecture/modeline.md`) is a registry of styled,
5//! positioned, optionally-interactive **elements** contributed by host
6//! built-ins, modes, and (later) plugins. This module holds the
7//! mode-facing data model + the descriptor registry.
8//!
9//! Split of concerns (mirrors `lsp_progress`): the **descriptor**
10//! ([`ModelineElement`]) is registered once and changes rarely; the
11//! **content** ([`ElementContent`]) churns and lives in the host
12//! content store, published as a render snapshot and updated over the
13//! event bus (ML.0b / ML.3). The renderers lay out zones (ML.1 / ML.2).
14//! Interaction ([`Interaction`]) is *designed here* but wired in ML.4 —
15//! shipping the field now keeps that slice additive (no model churn).
16
17use std::collections::HashMap;
18use std::sync::Arc;
19
20use arc_swap::ArcSwap;
21use lattice_core::BufferId;
22use lattice_protocol::ids::CommandId;
23
24/// Stable, namespaced element identifier — `"core.mode"`, `"lsp"`,
25/// `"<plugin-id>.<name>"`. The namespace doubles as the **owner** key
26/// (`feedback_mode_owns_its_surface`): a mode/plugin owns the elements
27/// under its namespace end to end.
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct ElementId(pub Arc<str>);
30
31impl ElementId {
32    /// Wrap a namespaced id. No validation: the namespace convention
33    /// (`<owner>.<name>`) is what teardown-by-prefix relies on, so keep to it.
34    pub fn new(id: impl Into<Arc<str>>) -> Self {
35        Self(id.into())
36    }
37    /// The id as a string slice.
38    pub fn as_str(&self) -> &str {
39        &self.0
40    }
41}
42
43/// Horizontal placement zone. `Left` fills left→right, `Right` fills
44/// right→left, `Center` sits in the gap between them and is the default
45/// zone for custom / plugin content.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Zone {
48    /// Left-aligned block (path, mode).
49    Left,
50    /// Between the left and right blocks; the default for custom content.
51    Center,
52    /// Right-aligned block (position, language, LSP status).
53    Right,
54}
55
56/// Whether an element renders on every pane (`PaneLocal`, default) or
57/// only the active pane (`Global`). `Global` carries project-wide
58/// content (clock, git branch) without per-pane duplication and without
59/// reintroducing a global chrome bar (Option A stays).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum Scope {
62    /// Rendered on every pane, with content keyed per buffer
63    /// ([`ModelineKey::Buffer`]). The default.
64    #[default]
65    PaneLocal,
66    /// Rendered on the active pane only, with one content value
67    /// ([`ModelineKey::Global`]).
68    Global,
69}
70
71/// Content-store key discriminator (ML.3). Content is keyed by
72/// `(ModelineKey, ElementId)` so a single descriptor can carry distinct
73/// content per pane: `Buffer(id)` for a [`Scope::PaneLocal`] element
74/// (resolved against the pane's buffer), `Global` for a [`Scope::Global`]
75/// element (one value, rendered only on the active pane). A producer
76/// pushes per-buffer content for each buffer it serves — e.g. each side
77/// of a split diff shows its own `+N ~M`. See
78/// `docs/dev/architecture/modeline.md` §4 (per-pane content resolution).
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80pub enum ModelineKey {
81    /// The single slot of a [`Scope::Global`] element.
82    Global,
83    /// The slot of a [`Scope::PaneLocal`] element for panes showing this
84    /// buffer.
85    Buffer(BufferId),
86}
87
88/// Theme role key for a [`Span`]. Resolved by the renderer against the
89/// `ResolvedTheme` (T-series). Kept as a string key so `lattice-mode`
90/// need not depend on the theme crate (dep-inversion, same pattern as
91/// the service registry). Unknown roles fall back to the default
92/// modeline style at render time.
93#[derive(Debug, Clone, PartialEq, Eq, Hash)]
94pub struct ModelineRole(pub Arc<str>);
95
96impl ModelineRole {
97    /// Wrap a theme role key (`"modeline.mode_item"`, …).
98    pub fn new(role: impl Into<Arc<str>>) -> Self {
99        Self(role.into())
100    }
101    /// The role key as a string slice.
102    pub fn as_str(&self) -> &str {
103        &self.0
104    }
105}
106
107/// The modeline role a *mode* tags content with when it
108/// contributes a segment to the modeline (e.g. diff-mode's `+N ~M`
109/// stats) (DX.4, BC.6). Lives in `lattice-mode` (not host) because it is the role
110/// modes reach for — `ModelineRole::new(ROLE_MODE_ITEM)` — so it belongs
111/// with the mode-contribution substrate, letting `lattice-diff` reach it
112/// without the host. The host's own element roles (`modeline.path`,
113/// `modeline.position`, `modeline.lang`, `modeline.mode`) stay host-side;
114/// the host re-exports this one so renderer style maps + `crate::modeline`
115/// call sites are unchanged.
116pub const ROLE_MODE_ITEM: &str = "modeline.mode_item";
117
118/// A styled run of text within an element's content.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct Span {
121    /// The text to paint. An empty string contributes nothing.
122    pub text: String,
123    /// Theme role the text is styled with.
124    pub role: ModelineRole,
125}
126
127impl Span {
128    /// A span of `text` styled as `role`.
129    pub fn new(text: impl Into<String>, role: ModelineRole) -> Self {
130        Self {
131            text: text.into(),
132            role,
133        }
134    }
135}
136
137/// The dynamic, frequently-updated value of an element. Empty (no
138/// non-empty span text) ⇒ the element is hidden this frame — the cheap
139/// way a producer hides itself without deregistering.
140#[derive(Debug, Clone, Default, PartialEq, Eq)]
141pub struct ElementContent {
142    /// Styled runs, painted left to right with no separator.
143    pub spans: Vec<Span>,
144}
145
146impl ElementContent {
147    /// Convenience: a single-span content.
148    pub fn text(text: impl Into<String>, role: ModelineRole) -> Self {
149        Self {
150            spans: vec![Span::new(text, role)],
151        }
152    }
153
154    /// True when there is nothing to paint (no spans, or all blank).
155    pub fn is_empty(&self) -> bool {
156        self.spans.iter().all(|s| s.text.is_empty())
157    }
158
159    /// Plain concatenated text — renderer-agnostic; used for width
160    /// estimation + tests.
161    pub fn plain(&self) -> String {
162        self.spans.iter().map(|s| s.text.as_str()).collect()
163    }
164}
165
166/// A typed event a producer (mode / plugin) publishes on the event bus
167/// to set an element's content (ML.3). The host forwarder fires the §12
168/// render-wake on arrival; the actor thread drains the event into the
169/// content store in `run_tick_pending` (single-writer). Empty `content`
170/// hides the element (the drain treats it as a [`ModelineService::clear`]).
171///
172/// This is the WIT-shaped push path: native modes and (later) plugins
173/// update content the same way, so no producer is invoked on the render
174/// path (paramount #1, §2 / §5). See
175/// `docs/dev/architecture/modeline.md` §5 (update flow), §6 (ownership).
176///
177/// # Examples
178///
179/// A producer publishes from anywhere it holds the bus — an `on_activate`
180/// hook, a spawned task — never from the render path:
181///
182/// ```
183/// use lattice_core::BufferId;
184/// use lattice_mode::{
185///     ElementContent, ElementId, ModelineElementUpdate, ModelineKey, ModelineRole,
186///     ModelineService,
187/// };
188/// use lattice_runtime::EventBus;
189///
190/// let bus = EventBus::new();
191/// let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
192/// bus.subscribe_typed::<ModelineElementUpdate>(tx); // the host forwarder
193///
194/// bus.publish_typed(ModelineElementUpdate {
195///     key: ModelineKey::Buffer(BufferId(1)),
196///     id: ElementId::new("my-plugin.status"),
197///     content: ElementContent::text("● synced", ModelineRole::new("modeline.mode_item")),
198/// });
199///
200/// // The host drains it into the content store on the actor thread.
201/// let service = ModelineService::new();
202/// service.apply(rx.try_recv().unwrap());
203/// let got = service.snapshot();
204/// let got = got.content_for(ModelineKey::Buffer(BufferId(1)), &ElementId::new("my-plugin.status"));
205/// assert_eq!(got.unwrap().plain(), "● synced");
206/// ```
207#[derive(Debug, Clone)]
208pub struct ModelineElementUpdate {
209    /// Which slot: a buffer's (pane-local element) or the global one.
210    pub key: ModelineKey,
211    /// The element whose content this sets.
212    pub id: ElementId,
213    /// The new content; empty clears the slot and hides the element.
214    pub content: ElementContent,
215}
216
217// ML.3: register as a typed event so the bus's `publish_typed` /
218// `subscribe_typed` API can carry it. One type, one event name —
219// subscribers receive every push and the host forwarder drains them.
220lattice_protocol::register_event!(
221    ModelineElementUpdate,
222    "modeline.element-update",
223    "A producer (mode / plugin) set an element's modeline content.",
224    "lattice-mode",
225);
226
227/// Hover payload — a GPUI tooltip; ignored in the terminal (no hover).
228/// Realized in ML.4.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct HoverSpec {
231    /// The tooltip body.
232    pub content: ElementContent,
233}
234
235/// Interaction spec — **designed in ML.0, behaviour wired in ML.4**.
236/// `on_click` is dispatched through the host action registry; the
237/// handler body lives in the registering mode/plugin crate
238/// (`feedback_mode_owns_its_surface`,
239/// `feedback_effect_vocabulary_is_host_boundary`) — the host is only a
240/// router. `hover` is GPUI-only.
241#[derive(Debug, Clone, Default)]
242pub struct Interaction {
243    /// Command dispatched when the element is clicked; `None` for no click
244    /// behaviour.
245    pub on_click: Option<CommandId>,
246    /// Tooltip shown on hover (GPUI only); `None` for none.
247    pub hover: Option<HoverSpec>,
248}
249
250/// Static descriptor for a modeline element. Registered once into the
251/// [`ModelineRegistry`]; its [`ElementContent`] lives separately in the
252/// host content store and updates over the event bus (ML.3).
253#[derive(Debug, Clone)]
254pub struct ModelineElement {
255    /// Namespaced identity; also the content-store key.
256    pub id: ElementId,
257    /// Which block of the modeline the element sits in.
258    pub zone: Zone,
259    /// Order within the zone (see [`ModelineRegistry::zone_ordered`]).
260    pub priority: i32,
261    /// Every pane (per-buffer content) or the active pane only.
262    pub scope: Scope,
263    /// Designed now; honoured by the renderer in ML.4.
264    pub interaction: Option<Interaction>,
265}
266
267impl ModelineElement {
268    /// Minimal descriptor: pane-local, no interaction.
269    pub fn new(id: ElementId, zone: Zone, priority: i32) -> Self {
270        Self {
271            id,
272            zone,
273            priority,
274            scope: Scope::PaneLocal,
275            interaction: None,
276        }
277    }
278
279    /// Builder: set the [`Scope`].
280    pub fn with_scope(mut self, scope: Scope) -> Self {
281        self.scope = scope;
282        self
283    }
284
285    /// Builder: attach click / hover behaviour.
286    pub fn with_interaction(mut self, interaction: Interaction) -> Self {
287        self.interaction = Some(interaction);
288        self
289    }
290}
291
292/// Descriptor registry. Host-owned storage; modes register in
293/// `on_activate` and remove when their Guard drops (there is no
294/// `on_deactivate`); plugins via WIT, ML.6.
295/// Holds only descriptors — the churning content lives in the host
296/// content store, not here, so registration is rare and cheap.
297#[derive(Debug, Default, Clone)]
298pub struct ModelineRegistry {
299    elements: HashMap<ElementId, ModelineElement>,
300}
301
302impl ModelineRegistry {
303    /// An empty registry.
304    pub fn new() -> Self {
305        Self::default()
306    }
307
308    /// Register (or replace) a descriptor. Last-write-wins on a
309    /// duplicate id — well-defined, matching the action-handler
310    /// registry's semantics.
311    pub fn register(&mut self, element: ModelineElement) {
312        self.elements.insert(element.id.clone(), element);
313    }
314
315    /// Remove a descriptor (idempotent). Returns the removed element.
316    pub fn remove(&mut self, id: &ElementId) -> Option<ModelineElement> {
317        self.elements.remove(id)
318    }
319
320    /// The descriptor registered under `id`, if any.
321    pub fn get(&self, id: &ElementId) -> Option<&ModelineElement> {
322        self.elements.get(id)
323    }
324
325    /// Every registered id, in no particular order.
326    ///
327    /// Added for OC.3's teardown, which reverses a plugin's elements **by
328    /// namespace** rather than by a recorded token list: a plugin may register
329    /// a segment at any point in its life, so a token list collected at load
330    /// would miss a later one and leave its descriptor rendering forever with
331    /// nobody to update it. Reversing by prefix has nothing to forget — the
332    /// same reasoning the compilation parser factories are torn down by
333    /// provenance.
334    pub fn ids(&self) -> impl Iterator<Item = &ElementId> {
335        self.elements.keys()
336    }
337
338    /// Number of registered descriptors.
339    pub fn len(&self) -> usize {
340        self.elements.len()
341    }
342
343    /// True when no descriptor is registered.
344    pub fn is_empty(&self) -> bool {
345        self.elements.is_empty()
346    }
347
348    /// Descriptors in `zone`, in left-to-right visual order: ascending
349    /// by `priority` for **every** zone (ties broken by `ElementId` for
350    /// determinism). The renderer right-aligns the whole `Right` zone
351    /// block (lualine/helix model), so the highest-priority `Right`
352    /// element still lands at the far right without inverting the sort —
353    /// `priority` means the same thing (leftward → rightward) in all
354    /// three zones.
355    pub fn zone_ordered(&self, zone: Zone) -> Vec<&ModelineElement> {
356        let mut v: Vec<&ModelineElement> =
357            self.elements.values().filter(|e| e.zone == zone).collect();
358        v.sort_by(|a, b| {
359            a.priority
360                .cmp(&b.priority)
361                .then_with(|| a.id.0.cmp(&b.id.0))
362        });
363        v
364    }
365}
366
367/// A published, wait-free snapshot of the modeline state the renderer
368/// reads: descriptors + content, each an `Arc` (cheap clone). The host
369/// takes one per `build_render_state` and stores it in `RenderState`
370/// (ML.0b-2).
371#[derive(Debug, Clone, Default)]
372pub struct ModelineSnapshot {
373    /// The descriptors as of the snapshot.
374    pub registry: Arc<ModelineRegistry>,
375    /// Pushed content by `(slot, element)`. Built-in `core.*` elements are
376    /// computed host-side and are not stored here.
377    pub content: Arc<HashMap<(ModelineKey, ElementId), ElementContent>>,
378}
379
380impl ModelineSnapshot {
381    /// Content stored under an exact `(key, id)`, if any. Prefer
382    /// [`Self::resolve`] from a renderer — it derives the key from the
383    /// descriptor's scope + the pane's buffer.
384    pub fn content_for(&self, key: ModelineKey, id: &ElementId) -> Option<&ElementContent> {
385        self.content.get(&(key, id.clone()))
386    }
387
388    /// Resolve `el`'s content for the pane showing `buffer` (ML.3). A
389    /// [`Scope::PaneLocal`] descriptor keys by `Buffer(buffer)`; a
390    /// [`Scope::Global`] descriptor keys by `Global`. This is the
391    /// renderer's per-element lookup (built-ins are computed host-side
392    /// and bypass the store). Returns `None` when no producer has pushed
393    /// content for this `(scope-key, id)` — the element is hidden.
394    pub fn resolve(&self, el: &ModelineElement, buffer: BufferId) -> Option<&ElementContent> {
395        let key = match el.scope {
396            Scope::Global => ModelineKey::Global,
397            Scope::PaneLocal => ModelineKey::Buffer(buffer),
398        };
399        self.content.get(&(key, el.id.clone()))
400    }
401
402    /// Zone-ordered `(descriptor, content)` pairs for the pane showing
403    /// `buffer`, skipping elements with absent or empty content (hidden
404    /// this frame). Resolves each descriptor's content per its scope via
405    /// [`Self::resolve`]. Built-in `core.*` elements are computed
406    /// host-side, not stored, so they do NOT appear here — the renderer
407    /// iterates `registry.zone_ordered` directly and routes core ids to
408    /// the host resolver. This helper is for pushed-content tests.
409    pub fn zone(&self, zone: Zone, buffer: BufferId) -> Vec<(&ModelineElement, &ElementContent)> {
410        self.registry
411            .zone_ordered(zone)
412            .into_iter()
413            .filter_map(|el| {
414                let c = self.resolve(el, buffer)?;
415                (!c.is_empty()).then_some((el, c))
416            })
417            .collect()
418    }
419}
420
421/// Shared modeline service: descriptor registry + content store, each
422/// behind an [`ArcSwap`] for wait-free reads and lock-free updates. The
423/// host holds an `Arc` and reads [`Self::snapshot`] each
424/// `build_render_state`; modes/plugins hold the same `Arc` (via
425/// `ctx.service::<ModelineServiceHandle>()`, ML.0b-2 / ML.3) and
426/// register / remove descriptors. Content normally arrives as a
427/// [`ModelineElementUpdate`] on the event bus, which also wakes the render;
428/// calling [`update`](Self::update) directly changes the store without a
429/// wake.
430///
431/// # Examples
432///
433/// ```
434/// use lattice_core::BufferId;
435/// use lattice_mode::{
436///     ElementContent, ElementId, ModelineElement, ModelineKey, ModelineRole, ModelineService,
437///     Zone,
438/// };
439///
440/// let service = ModelineService::new();
441/// let id = ElementId::new("my-plugin.count");
442/// service.register(ModelineElement::new(id.clone(), Zone::Right, 50));
443/// service.update(
444///     ModelineKey::Buffer(BufferId(7)),
445///     id.clone(),
446///     ElementContent::text("3 todos", ModelineRole::new("modeline.mode_item")),
447/// );
448///
449/// let snap = service.snapshot();
450/// let right = snap.zone(Zone::Right, BufferId(7));
451/// assert_eq!(right[0].1.plain(), "3 todos");
452/// // Pane-local content is per buffer: another buffer's pane shows nothing.
453/// assert!(snap.zone(Zone::Right, BufferId(8)).is_empty());
454/// ```
455/// Mirrors `ActionHandlerRegistry`'s `ArcSwap` shape — content updates
456/// may arrive from a mode's spawned task on another thread, so the
457/// store must be `Sync`.
458#[derive(Debug, Default)]
459pub struct ModelineService {
460    registry: ArcSwap<ModelineRegistry>,
461    content: ArcSwap<HashMap<(ModelineKey, ElementId), ElementContent>>,
462}
463
464/// Shared handle to the [`ModelineService`].
465pub type ModelineServiceHandle = Arc<ModelineService>;
466
467impl ModelineService {
468    /// An empty service (no descriptors, no content). The host builds one
469    /// and registers it as a [`ModelineServiceHandle`].
470    pub fn new() -> Self {
471        Self::default()
472    }
473
474    /// Register (or replace) a descriptor. Owner-scoped by the `id`
475    /// namespace (§6); last-write-wins.
476    pub fn register(&self, element: ModelineElement) {
477        self.registry.rcu(|cur| {
478            let mut next = (**cur).clone();
479            next.register(element.clone());
480            next
481        });
482    }
483
484    /// Remove a descriptor (idempotent).
485    pub fn remove(&self, id: &ElementId) {
486        self.registry.rcu(|cur| {
487            let mut next = (**cur).clone();
488            next.remove(id);
489            next
490        });
491    }
492
493    /// Set an element's content under `(key, id)`. An update for an id
494    /// with no descriptor is harmless — it simply won't render until one
495    /// is registered. `key` selects the pane (`Buffer`) or global slot
496    /// (§4 per-pane content resolution).
497    pub fn update(&self, key: ModelineKey, id: ElementId, content: ElementContent) {
498        // Equivalence-gate: only swap the content `Arc` when the value
499        // actually changes. An unconditional `rcu` re-allocates the map
500        // (and a fresh `Arc`) on every call — wasteful, and it breaks the
501        // content-`Arc` pointer as a change signal for the §12 paint gate
502        // (`build_render_state` re-applies the diff element on every
503        // publish via `sync_diff_modeline_element`, and the §12 forwarder
504        // re-pushes badges). Single-writer (actor thread), so the
505        // load-then-rcu has no harmful TOCTOU.
506        if self.content.load().get(&(key, id.clone())) == Some(&content) {
507            return;
508        }
509        self.content.rcu(|cur| {
510            let mut next = (**cur).clone();
511            next.insert((key, id.clone()), content.clone());
512            next
513        });
514    }
515
516    /// Clear an element's content under `(key, id)` (hides it). Idempotent.
517    pub fn clear(&self, key: ModelineKey, id: &ElementId) {
518        // Equivalence-gate (see `update`): a clear of an already-absent
519        // entry must not churn the content `Arc`.
520        if !self.content.load().contains_key(&(key, id.clone())) {
521            return;
522        }
523        self.content.rcu(|cur| {
524            let mut next = (**cur).clone();
525            next.remove(&(key, id.clone()));
526            next
527        });
528    }
529
530    /// Apply a pushed [`ModelineElementUpdate`] (the actor-thread drain's
531    /// per-event step, ML.3): empty content clears the slot (hidden),
532    /// non-empty content sets it. Routing empty → clear keeps the store
533    /// from accumulating dead entries as producers toggle visibility.
534    pub fn apply(&self, update: ModelineElementUpdate) {
535        if update.content.is_empty() {
536            self.clear(update.key, &update.id);
537        } else {
538            self.update(update.key, update.id, update.content);
539        }
540    }
541
542    /// Wait-free snapshot for the renderer (two `Arc` clones).
543    pub fn snapshot(&self) -> ModelineSnapshot {
544        ModelineSnapshot {
545            registry: self.registry.load_full(),
546            content: self.content.load_full(),
547        }
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    fn el(id: &str, zone: Zone, priority: i32) -> ModelineElement {
556        ModelineElement::new(ElementId::new(id), zone, priority)
557    }
558
559    #[test]
560    fn register_get_len() {
561        let mut r = ModelineRegistry::new();
562        assert!(r.is_empty());
563        r.register(el("core.mode", Zone::Left, 0));
564        r.register(el("lsp", Zone::Right, 10));
565        assert_eq!(r.len(), 2);
566        assert_eq!(r.get(&ElementId::new("lsp")).unwrap().zone, Zone::Right);
567        assert!(r.get(&ElementId::new("missing")).is_none());
568    }
569
570    #[test]
571    fn register_replaces_duplicate_id() {
572        let mut r = ModelineRegistry::new();
573        r.register(el("lsp", Zone::Left, 0));
574        r.register(el("lsp", Zone::Right, 5)); // same id, new descriptor
575        assert_eq!(r.len(), 1);
576        let e = r.get(&ElementId::new("lsp")).unwrap();
577        assert_eq!(e.zone, Zone::Right);
578        assert_eq!(e.priority, 5);
579    }
580
581    #[test]
582    fn remove_is_idempotent() {
583        let mut r = ModelineRegistry::new();
584        r.register(el("lsp", Zone::Left, 0));
585        assert!(r.remove(&ElementId::new("lsp")).is_some());
586        assert!(r.remove(&ElementId::new("lsp")).is_none());
587        assert!(r.is_empty());
588    }
589
590    #[test]
591    fn zone_ordered_ascending_in_every_zone() {
592        let mut r = ModelineRegistry::new();
593        r.register(el("l.b", Zone::Left, 20));
594        r.register(el("l.a", Zone::Left, 10));
595        r.register(el("r.b", Zone::Right, 20));
596        r.register(el("r.a", Zone::Right, 10));
597        r.register(el("c", Zone::Center, 0));
598
599        // Left-to-right visual order = ascending priority in ALL zones;
600        // the renderer right-aligns the Right block, so r.b (priority
601        // 20) still paints at the far right.
602        let order = |z| -> Vec<String> {
603            r.zone_ordered(z)
604                .iter()
605                .map(|e| e.id.as_str().to_string())
606                .collect()
607        };
608        assert_eq!(order(Zone::Left), ["l.a", "l.b"]);
609        assert_eq!(order(Zone::Right), ["r.a", "r.b"]);
610        assert_eq!(order(Zone::Center), ["c"]);
611    }
612
613    #[test]
614    fn zone_ordered_ties_broken_by_id() {
615        let mut r = ModelineRegistry::new();
616        r.register(el("b", Zone::Left, 5));
617        r.register(el("a", Zone::Left, 5));
618        let left: Vec<&str> = r
619            .zone_ordered(Zone::Left)
620            .iter()
621            .map(|e| e.id.as_str())
622            .collect();
623        assert_eq!(left, ["a", "b"]); // tie → id ascending
624    }
625
626    #[test]
627    fn content_empty_and_plain() {
628        let role = ModelineRole::new("modeline.normal");
629        assert!(ElementContent::default().is_empty());
630        let c = ElementContent {
631            spans: vec![Span::new("lsp ", role.clone()), Span::new("✓", role)],
632        };
633        assert!(!c.is_empty());
634        assert_eq!(c.plain(), "lsp ✓");
635    }
636
637    #[test]
638    fn descriptor_builders() {
639        let e = ModelineElement::new(ElementId::new("x"), Zone::Center, 0)
640            .with_scope(Scope::Global)
641            .with_interaction(Interaction::default());
642        assert_eq!(e.scope, Scope::Global);
643        assert!(e.interaction.is_some());
644        assert_eq!(e.zone, Zone::Center);
645    }
646
647    fn bid(n: u32) -> BufferId {
648        BufferId(n)
649    }
650
651    #[test]
652    fn service_register_update_snapshot() {
653        let svc = ModelineService::new();
654        svc.register(el("lsp", Zone::Right, 0));
655        let role = ModelineRole::new("modeline.normal");
656        svc.update(
657            ModelineKey::Buffer(bid(7)),
658            ElementId::new("lsp"),
659            ElementContent::text("lsp ✓", role),
660        );
661        let snap = svc.snapshot();
662        let right = snap.zone(Zone::Right, bid(7));
663        assert_eq!(right.len(), 1);
664        assert_eq!(right[0].0.id.as_str(), "lsp");
665        assert_eq!(right[0].1.plain(), "lsp ✓");
666        assert_eq!(
667            snap.content_for(ModelineKey::Buffer(bid(7)), &ElementId::new("lsp"))
668                .unwrap()
669                .plain(),
670            "lsp ✓"
671        );
672    }
673
674    /// ML.3: content is keyed per buffer. A PaneLocal descriptor's
675    /// pushed content shows only on the pane whose buffer matches;
676    /// Global content shows on every pane (resolved via the descriptor's
677    /// scope).
678    #[test]
679    fn service_content_is_per_buffer_keyed() {
680        let svc = ModelineService::new();
681        let role = ModelineRole::new("r");
682        // PaneLocal `diff` element, content pushed for buffer 1 only.
683        svc.register(el("diff", Zone::Left, 0));
684        svc.update(
685            ModelineKey::Buffer(bid(1)),
686            ElementId::new("diff"),
687            ElementContent::text("+3", role.clone()),
688        );
689        // Global `clock` element.
690        svc.register(
691            ModelineElement::new(ElementId::new("clock"), Zone::Right, 0).with_scope(Scope::Global),
692        );
693        svc.update(
694            ModelineKey::Global,
695            ElementId::new("clock"),
696            ElementContent::text("12:00", role),
697        );
698
699        let snap = svc.snapshot();
700        // Pane on buffer 1: sees its diff + the global clock.
701        assert_eq!(snap.zone(Zone::Left, bid(1)).len(), 1, "diff on its buffer");
702        assert_eq!(snap.zone(Zone::Right, bid(1)).len(), 1, "global clock");
703        // Pane on buffer 2: no diff content keyed for it; clock global.
704        assert!(
705            snap.zone(Zone::Left, bid(2)).is_empty(),
706            "diff hidden on other buffer"
707        );
708        assert_eq!(
709            snap.zone(Zone::Right, bid(2)).len(),
710            1,
711            "clock still global"
712        );
713    }
714
715    /// ML.3: `apply` routes empty content to a clear, non-empty to an
716    /// update — the actor-thread drain's per-event step.
717    #[test]
718    fn service_apply_routes_empty_to_clear() {
719        let svc = ModelineService::new();
720        svc.register(el("lsp", Zone::Right, 0));
721        let role = ModelineRole::new("r");
722        svc.apply(ModelineElementUpdate {
723            key: ModelineKey::Buffer(bid(1)),
724            id: ElementId::new("lsp"),
725            content: ElementContent::text("lsp ⟳", role),
726        });
727        assert_eq!(svc.snapshot().zone(Zone::Right, bid(1)).len(), 1);
728        // Empty content via apply → cleared (hidden).
729        svc.apply(ModelineElementUpdate {
730            key: ModelineKey::Buffer(bid(1)),
731            id: ElementId::new("lsp"),
732            content: ElementContent::default(),
733        });
734        assert!(svc.snapshot().zone(Zone::Right, bid(1)).is_empty());
735    }
736
737    #[test]
738    fn service_empty_or_absent_content_is_hidden() {
739        let svc = ModelineService::new();
740        svc.register(el("lsp", Zone::Right, 0));
741        // descriptor exists but no content yet → hidden
742        assert!(svc.snapshot().zone(Zone::Right, bid(0)).is_empty());
743        // explicit empty content → still hidden
744        svc.update(
745            ModelineKey::Buffer(bid(0)),
746            ElementId::new("lsp"),
747            ElementContent::default(),
748        );
749        assert!(svc.snapshot().zone(Zone::Right, bid(0)).is_empty());
750    }
751
752    #[test]
753    fn service_clear_then_remove() {
754        let svc = ModelineService::new();
755        let role = ModelineRole::new("r");
756        svc.register(el("x", Zone::Left, 0));
757        svc.update(
758            ModelineKey::Buffer(bid(0)),
759            ElementId::new("x"),
760            ElementContent::text("hi", role),
761        );
762        assert_eq!(svc.snapshot().zone(Zone::Left, bid(0)).len(), 1);
763        // clear content → hidden, but descriptor remains
764        svc.clear(ModelineKey::Buffer(bid(0)), &ElementId::new("x"));
765        let snap = svc.snapshot();
766        assert!(snap.zone(Zone::Left, bid(0)).is_empty());
767        assert!(snap.registry.get(&ElementId::new("x")).is_some());
768        // remove descriptor
769        svc.remove(&ElementId::new("x"));
770        assert!(svc.snapshot().registry.get(&ElementId::new("x")).is_none());
771    }
772}