1use lattice_theme::{ColorRef, ElementName, ElementOwner, ModifierSet, StyleSpec, ThemeRegistry};
20
21use crate::{
22 Component, PluginBudget, PluginHost, PluginHostError, PluginManifest, TrustTier, arm_store,
23 classify_trap,
24};
25
26pub(crate) mod bindings {
27 wasmtime::component::bindgen!({
28 world: "theme-plugin",
29 path: "../lattice-wit/wit",
30 exports: { default: async },
34 with: {
35 "lattice:plugin-host/logging": crate::lattice::plugin_host::logging,
36 },
37 });
38}
39
40use bindings::lattice::plugin_host::theme as wit;
41
42fn color_ref_from_wit(c: wit::ColorRef) -> ColorRef {
49 match c {
50 wit::ColorRef::Palette(key) => ColorRef::Palette(key.into()),
51 wit::ColorRef::LiteralRgb(rgb) => ColorRef::Literal(lattice_theme::Color::Rgb(
52 ((rgb >> 16) & 0xff) as u8,
53 ((rgb >> 8) & 0xff) as u8,
54 (rgb & 0xff) as u8,
55 )),
56 wit::ColorRef::Default => ColorRef::Default,
57 }
58}
59
60pub fn style_spec_from_wit(spec: wit::StyleSpec) -> StyleSpec {
68 StyleSpec {
69 inherit: spec.inherit.map(ElementName::from),
70 fg: spec.fg.map(color_ref_from_wit),
71 bg: spec.bg.map(color_ref_from_wit),
72 modifiers: ModifierSet {
73 bold: spec.modifiers.bold,
74 italic: spec.modifiers.italic,
75 underline: spec.modifiers.underline,
76 dim: spec.modifiers.dim,
77 reverse: spec.modifiers.reverse,
78 },
79 scale: spec.scale,
80 family: None,
81 weight: None,
82 }
83}
84
85pub fn register_plugin_element(
91 registry: &dyn ThemeRegistry,
92 plugin_id: &str,
93 name: &str,
94 doc: &str,
95 spec: StyleSpec,
96) -> String {
97 let full = format!("{plugin_id}.{name}");
98 let doc: &'static str = Box::leak(doc.to_string().into_boxed_str());
104 registry.register(
105 ElementName::from(full.clone()),
106 ElementOwner::Plugin(plugin_id.to_string().into()),
107 spec,
108 doc,
109 );
110 full
111}
112
113pub fn set_plugin_element_override(
123 registry: &dyn ThemeRegistry,
124 plugin_id: &str,
125 name: &str,
126 spec: StyleSpec,
127) -> Result<(), String> {
128 let full = format!("{plugin_id}.{name}");
129 let element = ElementName::from(full.clone());
130 let Some(info) = registry.describe(&element) else {
131 return Err(format!(
134 "set-element-override: `{full}` is not a registered element"
135 ));
136 };
137 match &info.owner {
138 ElementOwner::Plugin(owner) if owner.as_ref() == plugin_id => {}
139 _ => {
140 return Err(format!(
141 "set-element-override: `{full}` is not owned by `{plugin_id}`"
142 ));
143 }
144 }
145 registry.set_override(element, spec);
146 Ok(())
147}
148
149impl PluginHost {
150 pub async fn spawn_theme_plugin(
158 &self,
159 component: &Component,
160 manifest: &PluginManifest,
161 tier: TrustTier,
162 budget: PluginBudget,
163 registry: &lattice_theme::ThemeRegistryHandle,
164 ) -> Result<(crate::PluginId, Vec<String>), PluginHostError> {
165 let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
166 for denied in &outcome.denied {
167 tracing::warn!(
168 plugin = %manifest.id,
169 capability = ?denied,
170 "theme plugin loaded with a withheld capability (reduced function)"
171 );
172 }
173 let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
174 let bindings =
175 bindings::ThemePlugin::instantiate_async(&mut store, component, &self.linker)
176 .await
177 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
178
179 let id = self.alloc_id();
180 store.data_mut().theme_registry = Some(registry.clone());
181 store.data_mut().log_ctx = self.log_ctx_for(id);
182
183 arm_store(&mut store, budget)?;
184 bindings
185 .call_register_theme_elements(&mut store)
186 .await
187 .map_err(|source| PluginHostError::Trap {
188 func: "register-theme-elements",
189 kind: classify_trap(&source),
190 source: source.into(),
191 })?;
192
193 let elements = std::mem::take(&mut store.data_mut().theme_contributions);
194 Ok((id, elements))
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 #![allow(clippy::unwrap_used, clippy::panic)]
201
202 use super::*;
203
204 #[test]
205 fn a_palette_reference_survives_the_crossing() {
206 let spec = style_spec_from_wit(wit::StyleSpec {
207 inherit: Some("listing.file".to_string()),
208 fg: Some(wit::ColorRef::Palette("blue".to_string())),
209 bg: None,
210 modifiers: wit::ModifierSet {
211 bold: None,
212 italic: None,
213 underline: None,
214 dim: None,
215 reverse: None,
216 },
217 scale: None,
218 });
219 assert_eq!(spec.inherit, Some(ElementName::from("listing.file")));
220 assert!(matches!(spec.fg, Some(ColorRef::Palette(_))));
223 }
224
225 #[test]
226 fn a_literal_rgb_unpacks_to_the_right_channels() {
227 let spec = style_spec_from_wit(wit::StyleSpec {
228 inherit: None,
229 fg: Some(wit::ColorRef::LiteralRgb(0x11_22_33)),
230 bg: Some(wit::ColorRef::Default),
231 modifiers: wit::ModifierSet {
232 bold: None,
233 italic: None,
234 underline: None,
235 dim: None,
236 reverse: None,
237 },
238 scale: None,
239 });
240 assert!(matches!(
242 spec.fg,
243 Some(ColorRef::Literal(lattice_theme::Color::Rgb(
244 0x11, 0x22, 0x33
245 )))
246 ));
247 assert!(matches!(spec.bg, Some(ColorRef::Default)));
248 }
249
250 #[test]
251 fn modifiers_keep_their_three_states() {
252 let spec = style_spec_from_wit(wit::StyleSpec {
253 inherit: None,
254 fg: None,
255 bg: None,
256 modifiers: wit::ModifierSet {
257 bold: Some(true),
258 italic: Some(false),
262 underline: None,
263 dim: None,
264 reverse: None,
265 },
266 scale: Some(1.5),
267 });
268 assert_eq!(spec.modifiers.bold, Some(true));
269 assert_eq!(spec.modifiers.italic, Some(false));
270 assert_eq!(spec.modifiers.underline, None);
271 assert_eq!(spec.scale, Some(1.5));
272 }
273
274 #[test]
275 fn registration_namespaces_by_plugin_id() {
276 let reg = lattice_theme::InMemoryThemeRegistry::new(lattice_theme::default_palette());
277 let full = register_plugin_element(
278 ®,
279 "treesitter-context",
280 "background",
281 "The context strip backdrop.",
282 StyleSpec::new().fg(ColorRef::Palette("overlay".into())),
283 );
284 assert_eq!(full, "treesitter-context.background");
285 assert!(
286 reg.id(&ElementName::from("treesitter-context.background"))
287 .is_some(),
288 "the namespaced element is registered, so a theme can override it \
289 and `:customize` can list it"
290 );
291 assert!(reg.id(&ElementName::from("background")).is_none());
293 }
294}
295
296#[cfg(test)]
297mod tk5_tests {
298 #![allow(clippy::unwrap_used, clippy::panic)]
299 use super::*;
300 use lattice_theme::{ColorRef, InMemoryThemeRegistry, StyleSpec, default_palette};
301
302 fn rig() -> InMemoryThemeRegistry {
303 let r = InMemoryThemeRegistry::new(default_palette());
308 register_plugin_element(
309 &r,
310 "org",
311 "todo.WAITING",
312 "a TODO state",
313 StyleSpec {
314 fg: Some(ColorRef::Palette("yellow".into())),
315 ..Default::default()
316 },
317 );
318 r
319 }
320
321 #[test]
326 fn tk5_an_override_beats_the_registered_default() {
327 let r = rig();
328 let name = ElementName::from("org.todo.WAITING".to_string());
329 let before = r.resolved().get(r.id(&name).unwrap()).fg;
330
331 set_plugin_element_override(
332 &r,
333 "org",
334 "todo.WAITING",
335 StyleSpec {
336 fg: Some(ColorRef::Palette("orange".into())),
337 ..Default::default()
338 },
339 )
340 .expect("the plugin owns this element");
341
342 let after = r.resolved().get(r.id(&name).unwrap()).fg;
343 assert_ne!(before, after, "the override must change the resolved style");
344 }
345
346 #[test]
351 fn tk5_a_plugin_cannot_override_an_element_it_does_not_own() {
352 let r = rig();
353 r.register(
354 ElementName::from_static("pane.separator"),
355 ElementOwner::Core,
356 StyleSpec::default(),
357 "a builtin",
358 );
359 let err = set_plugin_element_override(&r, "pane", "separator", StyleSpec::default())
361 .expect_err("a builtin is not the plugin's to restyle");
362 assert!(err.contains("not owned by"), "{err}");
363 }
364
365 #[test]
368 fn tk5_overriding_an_unregistered_element_says_so() {
369 let r = rig();
370 let err = set_plugin_element_override(&r, "org", "todo.NOPE", StyleSpec::default())
371 .expect_err("no such element");
372 assert!(err.contains("not a registered element"), "{err}");
373 }
374}