Skip to main content

orinium_browser/engine/ui/components/
registry.rs

1//! Component registry: maps HTML tags to custom node factories.
2
3use std::collections::HashMap;
4use std::sync::{Arc, mpsc};
5
6use crate::engine::bridge::text;
7use crate::engine::layouter::normalize_whitespace;
8use crate::engine::layouter::types::{Color, ContainerStyle, TextStyle, WhiteSpace};
9use crate::engine::layouter::{DomSnapshot, NodeId};
10use crate::engine::renderer_model::Image;
11use crate::engine::ui::audio::AudioComponent;
12use crate::engine::ui::button::ButtonComponent;
13use crate::engine::ui::canvas::CanvasComponent;
14use crate::engine::ui::components::input_hidden::InputHiddenComponent;
15use crate::engine::ui::custom_node::CustomNode;
16use crate::engine::ui::image::ImageComponent;
17use crate::engine::ui::input_text::InputTextComponent;
18use crate::engine::ui::input_text::OnValueChange;
19use crate::engine::ui::select::{OnSelectChange, SelectComponent, SelectOption};
20
21/// A channel for reporting text-input value changes to the DOM owner.
22///
23/// Layout is built off the UI thread on a [`DomSnapshot`]-style arena, so the
24/// builder cannot touch the real DOM. Text inputs instead report
25/// `(snapshot node id, new value)` through this channel; the UI thread drains
26/// it and applies the value to the live tree.
27pub type DomWriteBack = mpsc::Sender<(u32, String)>;
28
29/// Context handed to a [`CustomNodeFactory`] to construct a component.
30pub struct CustomNodeContext<'a> {
31    /// The HTML tag name (e.g. `"button"`).
32    pub tag: &'a str,
33    /// Inner text of the element, if any.
34    pub media_source: Option<&'a str>,
35    /// Resolved container style (background, border, …).
36    pub container_style: &'a ContainerStyle,
37    /// Inherited text style.
38    pub text_style: &'a TextStyle,
39    /// Text measurer for text-heavy components.
40    pub measurer: Arc<dyn text::TextMeasurer>,
41    /// Decoded images keyed by `src` URL.
42    pub images: &'a HashMap<String, Image>,
43    /// Encoded audio bytes keyed by `src` URL.
44    pub audio: &'a HashMap<String, Arc<[u8]>>,
45    /// Attribute accessor.
46    pub get_attr: &'a dyn Fn(&str) -> Option<String>,
47    /// Channel + snapshot node id for value write-back (bidirectional sync).
48    pub write_back: Option<(DomWriteBack, u32)>,
49    /// Dom snapshot.
50    pub dom_snapshot: &'a DomSnapshot,
51    /// The node id.
52    pub dom_id: NodeId,
53}
54
55/// Constructs a [`CustomNode`] for a given HTML tag.
56pub trait CustomNodeFactory {
57    /// The tags this factory can construct (drives `CUSTOM_TAGS`).
58    fn tags(&self) -> &'static [&'static str];
59
60    /// Builds a node for `tag`, or `None` if the tag is not handled here.
61    fn create(&self, tag: &str, ctx: &CustomNodeContext) -> Option<Arc<dyn CustomNode>>;
62}
63
64/// A registry of [`CustomNodeFactory`]es used by the layout builder.
65#[derive(Default)]
66pub struct ComponentRegistry {
67    factories: Vec<Box<dyn CustomNodeFactory>>,
68}
69
70impl ComponentRegistry {
71    /// Creates a registry with the built-in components registered.
72    pub fn new() -> Self {
73        let mut registry = Self::default();
74        registry.register(Box::new(ButtonFactory));
75        registry.register(Box::new(AudioFactory));
76        registry.register(Box::new(ImageFactory));
77        registry.register(Box::new(CanvasFactory));
78        registry.register(Box::new(InputTextFactory));
79        registry.register(Box::new(SelectFactory));
80        registry
81    }
82
83    /// Registers a factory, replacing any factory with the same tags.
84    pub fn register(&mut self, factory: Box<dyn CustomNodeFactory>) {
85        self.factories
86            .retain(|f| factory.tags().iter().all(|tag| !f.tags().contains(tag)));
87        self.factories.push(factory);
88    }
89
90    /// All tags the registry can construct.
91    pub fn tags(&self) -> Vec<&'static str> {
92        self.factories
93            .iter()
94            .flat_map(|f| f.tags().iter().copied())
95            .collect()
96    }
97
98    /// Constructs a node for `tag`, or `None` if no factory handles it.
99    pub fn create(&self, ctx: &CustomNodeContext) -> Option<Arc<dyn CustomNode>> {
100        for factory in &self.factories {
101            if factory.tags().contains(&ctx.tag) {
102                return factory.create(ctx.tag, ctx);
103            }
104        }
105        None
106    }
107}
108
109struct AudioFactory;
110
111impl CustomNodeFactory for AudioFactory {
112    fn tags(&self) -> &'static [&'static str] {
113        &["audio"]
114    }
115
116    fn create(&self, _tag: &str, ctx: &CustomNodeContext) -> Option<Arc<dyn CustomNode>> {
117        Some(Arc::new(AudioComponent::new(
118            ctx.media_source.unwrap_or_default(),
119            ctx.media_source
120                .and_then(|source| ctx.audio.get(source))
121                .cloned(),
122        )))
123    }
124}
125
126struct ButtonFactory;
127
128impl CustomNodeFactory for ButtonFactory {
129    fn tags(&self) -> &'static [&'static str] {
130        &["button"]
131    }
132
133    fn create(&self, _tag: &str, ctx: &CustomNodeContext) -> Option<Arc<dyn CustomNode>> {
134        let default_bg = Color(240, 240, 240, 255);
135        let bg = match &ctx.container_style.background {
136            crate::engine::layouter::types::Background::Color(c) => *c,
137            _ => default_bg,
138        };
139        let label =
140            normalize_whitespace(&ctx.dom_snapshot.inner_text(ctx.dom_id), WhiteSpace::Normal);
141        Some(Arc::new(ButtonComponent::new(
142            label,
143            bg,
144            ctx.text_style.color,
145            Arc::clone(&ctx.measurer),
146        )))
147    }
148}
149
150struct ImageFactory;
151
152struct CanvasFactory;
153
154impl CustomNodeFactory for CanvasFactory {
155    fn tags(&self) -> &'static [&'static str] {
156        &["canvas"]
157    }
158
159    fn create(&self, _tag: &str, ctx: &CustomNodeContext) -> Option<Arc<dyn CustomNode>> {
160        let dimension = |name: &str, default: f32| {
161            (ctx.get_attr)(name)
162                .and_then(|value| value.parse::<f32>().ok())
163                .filter(|value| value.is_finite() && *value >= 0.0)
164                .unwrap_or(default)
165        };
166        Some(Arc::new(CanvasComponent::new(
167            dimension("width", 300.0),
168            dimension("height", 150.0),
169            &(ctx.get_attr)("data-orinium-canvas-commands").unwrap_or_default(),
170        )))
171    }
172}
173
174impl CustomNodeFactory for ImageFactory {
175    fn tags(&self) -> &'static [&'static str] {
176        &["img"]
177    }
178
179    fn create(&self, _tag: &str, ctx: &CustomNodeContext) -> Option<Arc<dyn CustomNode>> {
180        let image = (ctx.get_attr)("src")
181            .and_then(|source| ctx.images.get(&source))
182            .cloned();
183        Some(Arc::new(ImageComponent::new(
184            image,
185            (ctx.get_attr)("alt").unwrap_or_default(),
186        )))
187    }
188}
189
190struct InputTextFactory;
191
192impl CustomNodeFactory for InputTextFactory {
193    fn tags(&self) -> &'static [&'static str] {
194        &["input"]
195    }
196
197    fn create(&self, _tag: &str, ctx: &CustomNodeContext) -> Option<Arc<dyn CustomNode>> {
198        let type_ = (ctx.get_attr)("type").unwrap_or_default();
199        let value = (ctx.get_attr)("value").unwrap_or_default();
200        let placeholder = (ctx.get_attr)("placeholder").unwrap_or_default();
201
202        if type_.eq_ignore_ascii_case("hidden") {
203            return Some(Arc::new(InputHiddenComponent::new(value)));
204        }
205
206        let on_value_change = ctx.write_back.as_ref().map(|(sender, node_id)| {
207            let sender = sender.clone();
208            let node_id = *node_id;
209            Arc::new(move |new_value: &str| {
210                let _ = sender.send((node_id, new_value.to_string()));
211            }) as Arc<OnValueChange>
212        });
213
214        Some(Arc::new(if let Some(cb) = on_value_change {
215            InputTextComponent::with_on_change(value, placeholder, Arc::clone(&ctx.measurer), cb)
216        } else {
217            InputTextComponent::new(value, placeholder, Arc::clone(&ctx.measurer))
218        }))
219    }
220}
221
222struct SelectFactory;
223
224impl CustomNodeFactory for SelectFactory {
225    fn tags(&self) -> &'static [&'static str] {
226        &["select"]
227    }
228
229    fn create(&self, _tag: &str, ctx: &CustomNodeContext) -> Option<Arc<dyn CustomNode>> {
230        let value = (ctx.get_attr)("value").unwrap_or_default();
231        let disabled = (ctx.get_attr)("disabled").is_some();
232        let multiple = (ctx.get_attr)("multiple").is_some();
233        let on_change = ctx.write_back.as_ref().map(|(sender, node_id)| {
234            let sender = sender.clone();
235            let node_id = *node_id;
236            Arc::new(move |new_value: &str| {
237                let _ = sender.send((node_id, new_value.to_string()));
238            }) as Arc<OnSelectChange>
239        });
240
241        let mut options: Vec<SelectOption> = Vec::new();
242        for id in ctx.dom_snapshot.children(ctx.dom_id) {
243            let node = ctx.dom_snapshot.node(*id);
244            match node.kind.tag_name() {
245                Some("optgroup") => {
246                    let group = node.kind.get_attr("label").unwrap_or_default().to_string();
247                    let group_disabled = node.kind.has_attr("disabled");
248                    for child in ctx.dom_snapshot.children(*id) {
249                        let child_node = ctx.dom_snapshot.node(*child);
250                        if child_node.kind.tag_name() != Some("option") {
251                            continue;
252                        }
253                        options.push(SelectOption {
254                            value: child_node
255                                .kind
256                                .get_attr("value")
257                                .unwrap_or_default()
258                                .to_string(),
259                            label: normalize_whitespace(
260                                &ctx.dom_snapshot.inner_text(*child),
261                                WhiteSpace::Pre,
262                            ),
263                            selected: child_node.kind.has_attr("selected"),
264                            disabled: group_disabled || child_node.kind.has_attr("disabled"),
265                            group: Some(group.clone()),
266                        });
267                    }
268                }
269                Some("option") => options.push(SelectOption {
270                    value: node.kind.get_attr("value").unwrap_or_default().to_string(),
271                    label: normalize_whitespace(
272                        &ctx.dom_snapshot.inner_text(*id),
273                        WhiteSpace::Normal,
274                    ),
275                    selected: node.kind.has_attr("selected"),
276                    disabled: node.kind.has_attr("disabled"),
277                    group: None,
278                }),
279                _ => {}
280            }
281        }
282
283        Some(Arc::new(if let Some(cb) = on_change {
284            SelectComponent::with_on_change(
285                options,
286                &value,
287                Arc::clone(&ctx.measurer),
288                cb,
289                disabled,
290                multiple,
291            )
292        } else {
293            SelectComponent::new(
294                options,
295                &value,
296                Arc::clone(&ctx.measurer),
297                disabled,
298                multiple,
299            )
300        }))
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::engine::bridge::text::FallbackTextMeasurer;
308    use crate::engine::html::parser::DomTree;
309    use crate::engine::html::parser::Parser as HtmlParser;
310    use crate::engine::layouter::DomSnapshot;
311    use crate::engine::layouter::types::TextFlowStyle;
312    use crate::engine::ui::custom_node::PointerEvent;
313
314    fn tree(html: &str) -> DomTree {
315        HtmlParser::new(html).parse()
316    }
317
318    fn empty_snapshot() -> DomSnapshot {
319        let dom = tree("<html></html>");
320        let (snapshot, _dom_refs) = DomSnapshot::from_tree(&dom.root);
321        snapshot
322    }
323
324    #[test]
325    fn registry_builds_known_components() {
326        let registry = ComponentRegistry::new();
327        let mut attrs = HashMap::new();
328        attrs.insert("value".to_string(), "abc".to_string());
329        attrs.insert("placeholder".to_string(), "ph".to_string());
330        attrs.insert("src".to_string(), "img.png".to_string());
331        let container_style = ContainerStyle::default();
332        let text_style = TextStyle::default();
333        let images = HashMap::new();
334        let audio = HashMap::new();
335        let get_attr = |name: &str| attrs.get(name).cloned();
336        let measurer: Arc<dyn text::TextMeasurer> = Arc::new(FallbackTextMeasurer);
337
338        let dom_snapshot = &{
339            let dom = tree(
340                "<html><button></button><input /><img /><select><option value=\"opt\">Option</option></select></html>",
341            );
342            let (snapshot, _dom_refs) = DomSnapshot::from_tree(&dom.root);
343            snapshot
344        };
345
346        let html_id = dom_snapshot.node(dom_snapshot.roots()[0]).children[0];
347
348        let button_id = dom_snapshot.children(html_id)[0];
349        let input_id = dom_snapshot.children(html_id)[1];
350        let img_id = dom_snapshot.children(html_id)[2];
351        let select_id = dom_snapshot.children(html_id)[3];
352
353        let button = registry
354            .create(&CustomNodeContext {
355                tag: "button",
356                media_source: None,
357                container_style: &container_style,
358                text_style: &text_style,
359                measurer: Arc::clone(&measurer),
360                images: &images,
361                audio: &audio,
362                get_attr: &get_attr,
363                write_back: None,
364                dom_snapshot,
365                dom_id: button_id,
366            })
367            .unwrap();
368        assert_eq!(button.role(), Some("button"));
369
370        let input = registry
371            .create(&CustomNodeContext {
372                tag: "input",
373                media_source: None,
374                container_style: &container_style,
375                text_style: &text_style,
376                measurer: Arc::clone(&measurer),
377                images: &images,
378                audio: &audio,
379                get_attr: &get_attr,
380                write_back: None,
381                dom_snapshot,
382                dom_id: input_id,
383            })
384            .unwrap();
385        assert_eq!(input.role(), Some("textbox"));
386        assert_eq!(input.value(), Some("abc".to_string()));
387
388        let img = registry
389            .create(&CustomNodeContext {
390                tag: "img",
391                media_source: None,
392                container_style: &container_style,
393                text_style: &text_style,
394                measurer: Arc::clone(&measurer),
395                images: &images,
396                audio: &audio,
397                get_attr: &get_attr,
398                write_back: None,
399                dom_snapshot,
400                dom_id: img_id,
401            })
402            .unwrap();
403        assert_eq!(img.role(), None);
404
405        let select = registry
406            .create(&CustomNodeContext {
407                tag: "select",
408                media_source: None,
409                container_style: &container_style,
410                text_style: &text_style,
411                measurer: Arc::clone(&measurer),
412                images: &images,
413                audio: &audio,
414                get_attr: &get_attr,
415                write_back: None,
416                dom_snapshot,
417                dom_id: select_id,
418            })
419            .unwrap();
420        assert_eq!(select.role(), Some("combobox"));
421        assert_eq!(select.value(), Some("opt".to_string()));
422    }
423
424    #[test]
425    fn select_parses_optgroup_and_disabled() {
426        fn find(snapshot: &DomSnapshot, id: NodeId, tag: &str) -> Option<NodeId> {
427            if snapshot.node(id).kind.tag_name() == Some(tag) {
428                return Some(id);
429            }
430            snapshot
431                .children(id)
432                .iter()
433                .find_map(|&c| find(snapshot, c, tag))
434        }
435
436        let registry = ComponentRegistry::new();
437        let mut attrs = HashMap::new();
438        attrs.insert("value".to_string(), "b".to_string());
439        attrs.insert("disabled".to_string(), String::new());
440        let container_style = ContainerStyle::default();
441        let text_style = TextStyle::default();
442        let images: HashMap<String, Image> = HashMap::new();
443        let audio = HashMap::new();
444        let get_attr = |name: &str| attrs.get(name).cloned();
445
446        let dom_snapshot = &{
447            let dom = tree(
448                "<html><select value=\"b\" disabled><optgroup label=\"Fruits\" disabled><option value=\"a\" selected>Apple</option><option value=\"b\" disabled>Banana</option></optgroup><option value=\"c\">Cherry</option></select></html>",
449            );
450            let (snapshot, _dom_refs) = DomSnapshot::from_tree(&dom.root);
451            snapshot
452        };
453        let select_id = find(dom_snapshot, dom_snapshot.roots()[0], "select").unwrap();
454
455        let select = registry
456            .create(&CustomNodeContext {
457                tag: "select",
458                media_source: None,
459                container_style: &container_style,
460                text_style: &text_style,
461                measurer: Arc::new(FallbackTextMeasurer),
462                images: &images,
463                audio: &audio,
464                get_attr: &get_attr,
465                write_back: None,
466                dom_snapshot,
467                dom_id: select_id,
468            })
469            .unwrap();
470
471        // The disabled `<select>` reports disabled and never opens a popup.
472        assert!(select.is_disabled());
473        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
474        assert!(
475            select
476                .popup(&TextStyle::default(), &TextFlowStyle::default())
477                .is_none()
478        );
479
480        // The value resolves to an option nested inside the `<optgroup>`.
481        assert_eq!(select.value(), Some("b".to_string()));
482        assert_eq!(select.label(), Some("Banana".to_string()));
483    }
484
485    #[test]
486    fn select_optgroup_options_are_grouped() {
487        fn find(snapshot: &DomSnapshot, id: NodeId, tag: &str) -> Option<NodeId> {
488            if snapshot.node(id).kind.tag_name() == Some(tag) {
489                return Some(id);
490            }
491            snapshot
492                .children(id)
493                .iter()
494                .find_map(|&c| find(snapshot, c, tag))
495        }
496
497        let registry = ComponentRegistry::new();
498        let attrs: HashMap<String, String> = HashMap::new();
499        let container_style = ContainerStyle::default();
500        let text_style = TextStyle::default();
501        let images: HashMap<String, Image> = HashMap::new();
502        let audio = HashMap::new();
503        let get_attr = |name: &str| attrs.get(name).cloned();
504
505        let dom_snapshot = &{
506            let dom = tree(
507                "<html><select><optgroup label=\"Fruits\"><option value=\"a\">Apple</option></optgroup><option value=\"b\">Banana</option></select></html>",
508            );
509            let (snapshot, _dom_refs) = DomSnapshot::from_tree(&dom.root);
510            snapshot
511        };
512        let select_id = find(dom_snapshot, dom_snapshot.roots()[0], "select").unwrap();
513
514        let select = registry
515            .create(&CustomNodeContext {
516                tag: "select",
517                media_source: None,
518                container_style: &container_style,
519                text_style: &text_style,
520                measurer: Arc::new(FallbackTextMeasurer),
521                images: &images,
522                audio: &audio,
523                get_attr: &get_attr,
524                write_back: None,
525                dom_snapshot,
526                dom_id: select_id,
527            })
528            .unwrap();
529
530        assert!(!select.is_disabled());
531        // The `<optgroup>` option is selectable and opens a popup.
532        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
533        assert!(
534            select
535                .popup(&TextStyle::default(), &TextFlowStyle::default())
536                .is_some()
537        );
538        assert_eq!(select.value(), Some("a".to_string()));
539    }
540
541    #[test]
542    fn select_parses_multiple_attribute() {
543        fn find(snapshot: &DomSnapshot, id: NodeId, tag: &str) -> Option<NodeId> {
544            if snapshot.node(id).kind.tag_name() == Some(tag) {
545                return Some(id);
546            }
547            snapshot
548                .children(id)
549                .iter()
550                .find_map(|&c| find(snapshot, c, tag))
551        }
552
553        let registry = ComponentRegistry::new();
554        let mut attrs = HashMap::new();
555        attrs.insert("multiple".to_string(), String::new());
556        let container_style = ContainerStyle::default();
557        let text_style = TextStyle::default();
558        let images: HashMap<String, Image> = HashMap::new();
559        let audio = HashMap::new();
560        let get_attr = |name: &str| attrs.get(name).cloned();
561
562        let dom_snapshot = &{
563            let dom = tree(
564                "<html><select multiple><option value=\"a\" selected>Apple</option><option value=\"b\">Banana</option><option value=\"c\" selected>Cherry</option></select></html>",
565            );
566            let (snapshot, _dom_refs) = DomSnapshot::from_tree(&dom.root);
567            snapshot
568        };
569        let select_id = find(dom_snapshot, dom_snapshot.roots()[0], "select").unwrap();
570
571        let select = registry
572            .create(&CustomNodeContext {
573                tag: "select",
574                media_source: None,
575                container_style: &container_style,
576                text_style: &text_style,
577                measurer: Arc::new(FallbackTextMeasurer),
578                images: &images,
579                audio: &audio,
580                get_attr: &get_attr,
581                write_back: None,
582                dom_snapshot,
583                dom_id: select_id,
584            })
585            .unwrap();
586
587        // Multiple selects render as a list box: no popup, comma-joined value.
588        assert_eq!(select.role(), Some("listbox"));
589        assert_eq!(select.value(), Some("a,c".to_string()));
590        assert!(
591            select
592                .popup(&TextStyle::default(), &TextFlowStyle::default())
593                .is_none()
594        );
595
596        // Clicking a row toggles it without opening a popup.
597        select.on_pointer_event(PointerEvent::Down {
598            x: 5.0,
599            y: 28.0 + 2.0,
600        });
601        assert_eq!(select.value(), Some("a,b,c".to_string()));
602    }
603
604    #[test]
605    fn registry_returns_none_for_unknown_tag() {
606        let registry = ComponentRegistry::new();
607        let attrs: HashMap<String, String> = HashMap::new();
608        let container_style = ContainerStyle::default();
609        let text_style = TextStyle::default();
610        let images: HashMap<String, Image> = HashMap::new();
611        let audio = HashMap::new();
612        let get_attr = |name: &str| attrs.get(name).cloned();
613        let ctx = CustomNodeContext {
614            tag: "video",
615            media_source: None,
616            container_style: &container_style,
617            text_style: &text_style,
618            measurer: Arc::new(FallbackTextMeasurer),
619            images: &images,
620            audio: &audio,
621            get_attr: &get_attr,
622            write_back: None,
623            dom_snapshot: &empty_snapshot(),
624            dom_id: 0,
625        };
626        assert!(registry.create(&ctx).is_none());
627    }
628
629    #[test]
630    fn register_is_idempotent_for_same_tag() {
631        let mut registry = ComponentRegistry::new();
632        let mut tags_before = registry.tags();
633        tags_before.sort();
634        registry.register(Box::new(ButtonFactory));
635        let mut tags_after = registry.tags();
636        tags_after.sort();
637        assert_eq!(tags_after, tags_before);
638    }
639}