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