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}