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}