1use 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#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum DomWrite {
33 Value(String),
34 Checked(bool),
35}
36
37pub type DomWriteBack = mpsc::Sender<(u32, DomWrite)>;
38
39pub struct CustomNodeContext<'a> {
41 pub tag: &'a str,
43 pub media_source: Option<&'a str>,
45 pub container_style: &'a ContainerStyle,
47 pub text_style: &'a TextStyle,
49 pub measurer: Arc<dyn text::TextMeasurer>,
51 pub audio_sinks: &'a dyn AudioSinkFactory,
57 pub images: &'a HashMap<String, Image>,
59 pub audio: &'a HashMap<String, Arc<[u8]>>,
61 pub get_attr: &'a dyn Fn(&str) -> Option<String>,
63 pub write_back: Option<(DomWriteBack, u32)>,
65 pub dom_snapshot: &'a DomSnapshot,
67 pub dom_id: NodeId,
69}
70
71pub trait CustomNodeFactory {
73 fn tags(&self) -> &'static [&'static str];
75
76 fn create(&self, tag: &str, ctx: &CustomNodeContext) -> Option<Arc<dyn CustomNode>>;
78}
79
80#[derive(Default)]
82pub struct ComponentRegistry {
83 factories: Vec<Box<dyn CustomNodeFactory>>,
84}
85
86impl ComponentRegistry {
87 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 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 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 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 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 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 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 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 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 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 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 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}