lattice_plugin_host/ui_host.rs
1//! The `ui` guest→host contribution seam (OC.3 / ML.6) — a plugin-owned
2//! modeline element.
3//!
4//! **The canonical API is the WIT** (`ui.wit`); this module is only the host
5//! side of it. The rule it implements is `modeline.md` §6: whoever registers an
6//! element owns it end to end — the descriptor, the content, and (later) the
7//! interaction handlers. The host exposes generic primitives and branches on
8//! nothing, so the acid test that fragment states holds: a provider adding a
9//! modeline element needs zero `Editor::` methods and zero new host `Action`
10//! variants.
11//!
12//! ## Three things that are load-bearing and easy to get wrong
13//!
14//! **Content goes on the bus, not straight into the service.** `ModelineService`
15//! is interior-mutable, so a host function *could* just call `apply` and the
16//! content store would be correct — and nothing would repaint until the user
17//! next pressed a key. The repaint comes from the bus forwarder waking the actor
18//! (`editor_boot.rs`'s `ModelineElementUpdate` subscription → `async_landed`),
19//! which is the same reason `lattice-lsp::modeline` and `lattice-ai::mcp::status`
20//! publish rather than write. Writing directly is the "it works, but only after I
21//! hit something" bug with a plausible-looking implementation.
22//!
23//! **The descriptor goes straight into the service, not on the bus.** It is not
24//! per-frame state and has no wake to earn; the renderer reads the registry
25//! through the snapshot it takes each frame.
26//!
27//! **Ids are namespaced with the plugin's name**, like config options. Without
28//! that, `register-segment("mode")` would resolve to the id `mode`, and a plugin
29//! could register `core.mode` and silently take over a built-in element —
30//! `ModelineService::register` is last-write-wins by design.
31
32use lattice_mode::ModelineServiceHandle;
33use lattice_mode::modeline::{
34 ElementContent, ElementId, ModelineElement, ModelineElementUpdate, ModelineKey, ModelineRole,
35 Scope, Span, Zone,
36};
37use lattice_runtime::EventBus;
38use std::sync::Arc;
39
40use crate::lattice::plugin_host::types::UiZone;
41
42/// The role every plugin span carries.
43///
44/// Not a parameter, and the WIT says why: both renderers match role names
45/// against a closed five-constant set and disagree on the fallback (TUI renders
46/// unstyled, GPUI renders in the path colour), so an arbitrary role from a
47/// plugin would be a silent cross-renderer difference. `modeline.mode_item` is
48/// the one role both peers resolve identically, and it is the only role either
49/// native modeline producer uses.
50const PLUGIN_ROLE: &str = "modeline.mode_item";
51
52/// What a plugin's `ui` calls act on. `Some` only on the async spawn paths that
53/// are handed a modeline; `None` on the sync grammar store, which is what keeps
54/// the modeline off the keystroke path once the Component Model forces the
55/// import to be linked there anyway (see `ui.wit`).
56#[derive(Clone)]
57pub(crate) struct UiCtx {
58 /// The registry descriptors are registered into and removed from.
59 pub(crate) modeline: ModelineServiceHandle,
60 /// The bus content updates are published on, so the repaint wake fires.
61 pub(crate) bus: Arc<EventBus>,
62}
63
64/// Namespace a plugin's element id with its own name (`org` + `clock` →
65/// `org.clock`).
66///
67/// A plugin with no recorded name — only the minimal test constructor — gets its
68/// id unprefixed, which is fine there and reachable nowhere else.
69pub(crate) fn namespaced_id(plugin_name: Option<&str>, id: &str) -> String {
70 match plugin_name {
71 Some(name) => format!("{name}.{id}"),
72 None => id.to_string(),
73 }
74}
75
76/// True if `id` would land in the built-in namespace the renderers compute
77/// host-side (`core.*`).
78///
79/// After namespacing this can only happen for a plugin literally named `core`,
80/// which is a name a bundled plugin could plausibly be given. The check costs
81/// one comparison and closes a hijack that would otherwise be silent —
82/// registration is last-write-wins, so the built-in element would simply stop
83/// rendering with no error anywhere.
84pub(crate) fn is_builtin_namespace(id: &str) -> bool {
85 id.starts_with("core.")
86}
87
88/// The `register-segment` body: build the descriptor and hand it to the service.
89///
90/// Global scope, not per-pane, per the WIT — a plugin has no buffer to scope to
91/// and no plugin needs per-buffer yet.
92pub(crate) fn register_segment(
93 ctx: &UiCtx,
94 id: String,
95 zone: UiZone,
96 priority: i32,
97) -> Result<(), String> {
98 if is_builtin_namespace(&id) {
99 return Err(format!(
100 "{id} is in the built-in `core.` namespace; registration refused"
101 ));
102 }
103 ctx.modeline.register(
104 ModelineElement::new(ElementId::new(id), zone_from_wit(zone), priority)
105 .with_scope(Scope::Global),
106 );
107 Ok(())
108}
109
110/// The `emit-segment` body: publish content on the bus so the repaint wake fires.
111///
112/// Empty text produces empty content, which the renderer treats as "hidden" —
113/// so a plugin with nothing to say this minute needs no separate call.
114pub(crate) fn emit_segment(ctx: &UiCtx, id: String, text: String) {
115 let content = if text.is_empty() {
116 ElementContent::default()
117 } else {
118 ElementContent {
119 spans: vec![Span {
120 text,
121 role: ModelineRole::new(PLUGIN_ROLE),
122 }],
123 }
124 };
125 ctx.bus.publish_typed(ModelineElementUpdate {
126 key: ModelineKey::Global,
127 id: ElementId::new(id),
128 content,
129 });
130}
131
132/// The `clear-segment` body — `emit-segment` with nothing to show. Idempotent,
133/// including for an id that was never registered: the update lands in the
134/// content store keyed by an id no descriptor names, and the renderer, which
135/// iterates descriptors, never looks at it.
136pub(crate) fn clear_segment(ctx: &UiCtx, id: String) {
137 emit_segment(ctx, id, String::new());
138}
139
140fn zone_from_wit(z: UiZone) -> Zone {
141 match z {
142 UiZone::Left => Zone::Left,
143 UiZone::Center => Zone::Center,
144 UiZone::Right => Zone::Right,
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 #![allow(clippy::unwrap_used, clippy::panic)]
151
152 use super::*;
153 use lattice_mode::ModelineService;
154
155 fn ctx() -> (UiCtx, Arc<EventBus>) {
156 let bus = Arc::new(EventBus::new());
157 (
158 UiCtx {
159 modeline: Arc::new(ModelineService::new()),
160 bus: Arc::clone(&bus),
161 },
162 bus,
163 )
164 }
165
166 #[test]
167 fn ids_are_namespaced_by_plugin() {
168 assert_eq!(namespaced_id(Some("org"), "clock"), "org.clock");
169 assert_eq!(namespaced_id(None, "clock"), "clock");
170 }
171
172 #[test]
173 fn the_builtin_namespace_is_refused() {
174 let (c, _bus) = ctx();
175 let err = register_segment(&c, "core.mode".into(), UiZone::Left, 0)
176 .expect_err("a core.* id must not register");
177 assert!(err.contains("core."), "the refusal names the reason: {err}");
178 assert!(
179 c.modeline
180 .snapshot()
181 .registry
182 .zone_ordered(Zone::Left)
183 .is_empty(),
184 "nothing was registered — registration is last-write-wins, so a \
185 silent success here would unregister the real `core.mode`"
186 );
187 }
188
189 #[test]
190 fn a_registered_segment_is_global_scoped() {
191 let (c, _bus) = ctx();
192 register_segment(&c, "org.clock".into(), UiZone::Right, 7).unwrap();
193 let snap = c.modeline.snapshot();
194 let els = snap.registry.zone_ordered(Zone::Right);
195 assert_eq!(els.len(), 1);
196 assert_eq!(els[0].priority, 7);
197 assert!(
198 matches!(els[0].scope, Scope::Global),
199 "a plugin segment shows in every pane; it has no buffer to scope to"
200 );
201 }
202
203 #[test]
204 fn emit_publishes_on_the_bus_rather_than_writing_the_store() {
205 let (c, bus) = ctx();
206 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<ModelineElementUpdate>();
207 bus.subscribe_typed(tx);
208 emit_segment(&c, "org.clock".into(), "◷ 0:14".into());
209
210 let update = rx.try_recv().expect(
211 "the update must reach the BUS — writing the service directly leaves \
212 the content correct and the screen stale until the next keystroke",
213 );
214 assert_eq!(update.id.as_str(), "org.clock");
215 assert!(matches!(update.key, ModelineKey::Global));
216 assert_eq!(update.content.spans.len(), 1);
217 assert_eq!(update.content.spans[0].text, "◷ 0:14");
218 assert_eq!(update.content.spans[0].role.as_str(), PLUGIN_ROLE);
219 }
220
221 #[test]
222 fn clearing_publishes_empty_content_which_is_how_an_element_hides() {
223 let (c, bus) = ctx();
224 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<ModelineElementUpdate>();
225 bus.subscribe_typed(tx);
226 clear_segment(&c, "org.clock".into());
227 let update = rx.try_recv().unwrap();
228 assert!(update.content.is_empty());
229 }
230}