Skip to main content

lumen_widget/
lib.rs

1//! Widget trait + attribute bag - the thin surface implemented by
2//! `#[derive(Widget)]` (see the sibling `lumen-widget-macros` crate).
3//!
4//! ## Why this crate
5//!
6//! Authoring a custom Lumen widget today repeats the same five-step
7//! boilerplate per primitive (hover, scroll, tooltip, transition,
8//! validation ... all of `lumen-primitives`):
9//!
10//! 1. Define a marker [`bevy_ecs::component::Component`].
11//! 2. Define a `Plugin` struct.
12//! 3. In `Plugin::build`, register N systems against one or more
13//!    [`lumen_core::tick::TickStage`] sets.
14//! 4. Tell the markup parser about the new tag. The plugin the derive
15//!    generates calls `<Type>::register()` in its `build`, publishing
16//!    `Widget::parser_tag` into the registry the parser consults after
17//!    its built-in table.
18//! 5. Provide a spawn fn that turns the element spawned for a parsed
19//!    `<tag attr="...">` into the widget: the marker + the author-specified
20//!    props.
21//!
22//! The first three steps are repeated verbatim per widget. This crate
23//! defines the minimal surface that the `Widget` derive macro
24//! implements so authors only write the unique parts: the prop /
25//! state fields and the per-tick system bodies.
26//!
27//! ## The trait
28//!
29//! ```ignore
30//! use lumen_widget::{Attributes, Widget};
31//! use lumen_widget_macros::Widget;
32//! use bevy_ecs::prelude::*;
33//!
34//! #[derive(Component, Widget, Default)]
35//! #[widget(tag = "tooltip")]
36//! pub struct Tooltip {
37//!     #[widget(prop)] pub text: String,
38//!     #[widget(state)] pub visible: bool,
39//! }
40//! ```
41//!
42//! The derive emits:
43//!
44//! - `impl Plugin for TooltipPlugin` - a zero-sized struct that the
45//!   author adds to their `App`. Default `build` body inserts the
46//!   marker resource and registers any author-supplied systems via the
47//!   `#[widget(systems = "fn1,fn2")]` attribute (forward-looking).
48//! - `impl Widget for Tooltip` - connects the marker component, the
49//!   parser tag, and the spawn glue.
50//! - A `spawn` fn that walks an [`Attributes`] bag, populates the prop
51//!   fields, and inserts the component on the supplied entity.
52//!
53//! ## Scope of v1
54//!
55//! The macro covers Plugin scaffolding + entity-spawn glue. Widget-
56//! specific systems (the `record_hover_started` / `spawn_popup` chain
57//! for tooltips) stay hand-written. A future iteration may grow the
58//! `#[widget(systems = ...)]` attribute to register them automatically.
59//!
60//! ## Parser integration
61//!
62//! The lumenc HTML parser checks its built-in tag table
63//! (`KNOWN_TAGS` in `public/lumenc/src/parse/html.rs`) first and falls back
64//! to [`is_widget_tag_registered`]. The plugin the derive generates
65//! calls `<Type>::register()` when it is added, so adding that plugin
66//! before the parser runs is enough for `<my-thing>` markup to be
67//! accepted; no edit to the built-in table is needed.
68//!
69//! The parser keeps every attribute such an element was written with, and
70//! the spawner, once it has spawned the element, looks the tag up with
71//! [`widget_spawner`] and hands the element and its [`Attributes`] to the
72//! widget's [`Widget::spawn`]. So `<my-thing greeting="hi"/>` ends up an
73//! element carrying the widget's component with `greeting` set.
74
75#![forbid(unsafe_code)]
76#![warn(missing_docs)]
77
78use std::collections::HashMap;
79use std::sync::{Mutex, OnceLock};
80
81use bevy_ecs::prelude::*;
82
83/// What builds the widget an element with a registered tag stands for: the
84/// tag's [`Widget::spawn`].
85pub type WidgetSpawnFn = fn(Entity, &Attributes, &mut World);
86
87/// Process-wide registry of user-installed widget tags. The lumenc HTML
88/// parser consults [`is_widget_tag_registered`] after its built-in
89/// `KNOWN_TAGS` miss so custom widgets registered by the host app
90/// (typically through `T::register()` emitted by the `Widget` derive)
91/// are accepted instead of rejected as `UnknownTag`, and the spawner asks
92/// [`widget_spawner`] what to build on the element it spawned for one.
93///
94/// A tag registered by name alone maps to `None`: something else (a runtime
95/// module's systems, say) gives the element its behavior.
96static REGISTERED_WIDGET_TAGS: OnceLock<Mutex<HashMap<&'static str, Option<WidgetSpawnFn>>>> =
97    OnceLock::new();
98
99fn registry() -> &'static Mutex<HashMap<&'static str, Option<WidgetSpawnFn>>> {
100    REGISTERED_WIDGET_TAGS.get_or_init(|| Mutex::new(HashMap::new()))
101}
102
103/// Register `tag` so the lumenc HTML parser accepts `<tag ...>` markup.
104///
105/// For a tag whose element needs no [`Widget`] built on it. A widget calls
106/// [`register_widget`] instead, which also says what to build. Subsequent
107/// calls with the same tag are no-ops, and never take a registered widget's
108/// spawn away. The supplied string must live for the lifetime of the
109/// process.
110pub fn register_widget_tag(tag: &'static str) {
111    if let Ok(mut map) = registry().lock() {
112        map.entry(tag).or_insert(None);
113    }
114}
115
116/// Register `W`: the lumenc HTML parser accepts `<tag ...>` for its
117/// [`Widget::parser_tag`], and every element spawned for that tag is handed,
118/// with its authored attributes, to [`Widget::spawn`].
119///
120/// Called by `Widget` derive output (`<Type>::register()`, which the
121/// generated plugin's `build` calls) at app startup, before the parser runs.
122/// Registering again is a no-op; a second widget registered for the same tag
123/// replaces the first.
124pub fn register_widget<W: Widget>() {
125    if let Ok(mut map) = registry().lock() {
126        map.insert(W::parser_tag(), Some(W::spawn));
127    }
128}
129
130/// Register a tag whose name is only known at run time, so `<tag ...>`
131/// markup is accepted.
132///
133/// The derive knows its tag at compile time and passes a `&'static str`;
134/// a name read out of a file does not exist until the file is read. This
135/// takes the name by reference, checks the set first, and leaks the string
136/// only when the tag is new - a bounded, once-per-tag cost, because the
137/// registry outlives every caller and the parser consults it for the life
138/// of the process.
139pub fn register_widget_tag_owned(tag: &str) {
140    if is_widget_tag_registered(tag) {
141        return;
142    }
143    if let Ok(mut map) = registry().lock() {
144        // Checked again under the lock: two threads racing the same new tag
145        // must leak one string, not two.
146        if !map.contains_key(tag) {
147            map.insert(Box::leak(tag.to_string().into_boxed_str()), None);
148        }
149    }
150}
151
152/// Returns `true` when `tag` was registered via [`register_widget_tag`],
153/// [`register_widget`] or [`register_widget_tag_owned`] earlier in this
154/// process. Consulted by `public/lumenc/src/parse/html.rs`'s `KNOWN_TAGS`
155/// fallback path.
156pub fn is_widget_tag_registered(tag: &str) -> bool {
157    REGISTERED_WIDGET_TAGS
158        .get()
159        .and_then(|m| m.lock().ok())
160        .is_some_and(|m| m.contains_key(tag))
161}
162
163/// The [`Widget::spawn`] registered for `tag`, if a [`Widget`] was
164/// registered for it with [`register_widget`].
165pub fn widget_spawner(tag: &str) -> Option<WidgetSpawnFn> {
166    REGISTERED_WIDGET_TAGS
167        .get()
168        .and_then(|m| m.lock().ok())
169        .and_then(|m| m.get(tag).copied().flatten())
170}
171
172/// Untyped attribute bag handed to [`Widget::spawn`]: every attribute the
173/// element was written with in markup.
174///
175/// A flat `(name, value)` map keyed by attribute name, the values as
176/// authored. The parser owns the concrete representation; this crate keeps an
177/// enum-free wrapper so authors don't pull a parser dep just to write a
178/// widget.
179#[derive(Debug, Default, Clone)]
180pub struct Attributes {
181    inner: HashMap<String, String>,
182}
183
184impl Attributes {
185    /// Build an empty attribute bag.
186    pub fn new() -> Self {
187        Self::default()
188    }
189
190    /// Insert a single attribute. Last write wins.
191    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
192        self.inner.insert(key.into(), value.into());
193        self
194    }
195
196    /// Look up an attribute by name.
197    pub fn get(&self, key: &str) -> Option<&str> {
198        self.inner.get(key).map(String::as_str)
199    }
200
201    /// Look up an attribute, falling back to the supplied default.
202    pub fn get_or<'a>(&'a self, key: &str, default: &'a str) -> &'a str {
203        self.get(key).unwrap_or(default)
204    }
205
206    /// Parse an attribute into `T` via [`std::str::FromStr`]. Returns
207    /// `None` when the attribute is absent or fails to parse.
208    pub fn parse<T: std::str::FromStr>(&self, key: &str) -> Option<T> {
209        self.get(key)?.parse().ok()
210    }
211
212    /// Number of attributes set.
213    pub fn len(&self) -> usize {
214        self.inner.len()
215    }
216
217    /// `true` when no attributes are set.
218    pub fn is_empty(&self) -> bool {
219        self.inner.is_empty()
220    }
221
222    /// Iterate over `(name, value)` pairs.
223    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
224        self.inner.iter().map(|(k, v)| (k.as_str(), v.as_str()))
225    }
226}
227
228impl<K, V> From<Vec<(K, V)>> for Attributes
229where
230    K: Into<String>,
231    V: Into<String>,
232{
233    fn from(pairs: Vec<(K, V)>) -> Self {
234        let mut a = Attributes::new();
235        for (k, v) in pairs {
236            a.set(k, v);
237        }
238        a
239    }
240}
241
242impl<K, V, const N: usize> From<[(K, V); N]> for Attributes
243where
244    K: Into<String>,
245    V: Into<String>,
246{
247    fn from(pairs: [(K, V); N]) -> Self {
248        let mut a = Attributes::new();
249        for (k, v) in pairs {
250            a.set(k, v);
251        }
252        a
253    }
254}
255
256/// Thin surface implemented by `#[derive(Widget)]`.
257///
258/// The derive emits the plugin struct + the parser-glue impl; widget
259/// authors only fill in the per-widget systems.
260pub trait Widget: Sized + Send + Sync + 'static {
261    /// Display name - defaults to the Rust type name in the derive.
262    fn name() -> &'static str;
263
264    /// Markup tag handled by the widget. The derive picks this up from
265    /// `#[widget(tag = "...")]`. `<Type>::register()` publishes it into
266    /// the registry behind [`is_widget_tag_registered`], which is what
267    /// makes the lumenc parser accept the tag.
268    fn parser_tag() -> &'static str;
269
270    /// Make `entity` this widget, reading its props out of `attrs`.
271    ///
272    /// The spawner calls this for every element whose tag the widget was
273    /// registered for ([`register_widget`]), once the element's own
274    /// components are in place and before its children spawn: `entity` is
275    /// that element and `attrs` every attribute it was written with. The
276    /// derive emits a body that parses the `#[widget(prop)]` fields out of
277    /// `attrs` over the struct's `Default` and inserts the component on
278    /// `entity`. Outside markup, spawn the entity first
279    /// (`world.spawn_empty().id()`) and hand it in.
280    fn spawn(entity: Entity, attrs: &Attributes, world: &mut World);
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    // The tag set is process-global and every test in the binary shares it,
288    // so a test asks about the tag it registered rather than the whole set,
289    // which anything running beside it can change.
290
291    #[test]
292    fn attributes_round_trip() {
293        let mut a = Attributes::new();
294        a.set("text", "Save").set("delay", "500");
295        assert_eq!(a.get("text"), Some("Save"));
296        assert_eq!(a.parse::<u32>("delay"), Some(500));
297        assert_eq!(a.parse::<u32>("missing"), None);
298        assert_eq!(a.get_or("missing", "fallback"), "fallback");
299        assert_eq!(a.len(), 2);
300        assert!(!a.is_empty());
301    }
302
303    #[test]
304    fn attributes_from_array() {
305        let a: Attributes = [("text", "Hi"), ("delay", "100")].into();
306        assert_eq!(a.get("text"), Some("Hi"));
307        assert_eq!(a.parse::<u32>("delay"), Some(100));
308    }
309
310    #[test]
311    fn an_owned_tag_registers_once_and_is_accepted() {
312        assert!(!is_widget_tag_registered("owned-test-tag"));
313        register_widget_tag_owned(&String::from("owned-test-tag"));
314        assert!(is_widget_tag_registered("owned-test-tag"));
315        // A second call for a tag already in the set is a no-op.
316        register_widget_tag_owned("owned-test-tag");
317        assert!(is_widget_tag_registered("owned-test-tag"));
318    }
319
320    #[test]
321    fn racing_registrations_of_one_tag_all_succeed() {
322        // The set is checked before the lock is taken, so two callers can
323        // both find the tag absent. Only one of them may put it in.
324        let threads: Vec<_> = (0..8)
325            .map(|_| std::thread::spawn(|| register_widget_tag_owned("owned-race-tag")))
326            .collect();
327        for t in threads {
328            t.join().expect("the registration does not panic");
329        }
330        assert!(is_widget_tag_registered("owned-race-tag"));
331    }
332
333    #[test]
334    fn attributes_from_vec() {
335        let a: Attributes = vec![
336            ("text".to_string(), "Hi".to_string()),
337            ("count".to_string(), "3".to_string()),
338        ]
339        .into();
340        assert_eq!(a.get("count"), Some("3"));
341    }
342}