1use 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
21pub type DomWriteBack = mpsc::Sender<(u32, String)>;
28
29pub struct CustomNodeContext<'a> {
31 pub tag: &'a str,
33 pub media_source: Option<&'a str>,
35 pub container_style: &'a ContainerStyle,
37 pub text_style: &'a TextStyle,
39 pub measurer: Arc<dyn text::TextMeasurer>,
41 pub images: &'a HashMap<String, Image>,
43 pub audio: &'a HashMap<String, Arc<[u8]>>,
45 pub get_attr: &'a dyn Fn(&str) -> Option<String>,
47 pub write_back: Option<(DomWriteBack, u32)>,
49 pub dom_snapshot: &'a DomSnapshot,
51 pub dom_id: NodeId,
53}
54
55pub trait CustomNodeFactory {
57 fn tags(&self) -> &'static [&'static str];
59
60 fn create(&self, tag: &str, ctx: &CustomNodeContext) -> Option<Arc<dyn CustomNode>>;
62}
63
64#[derive(Default)]
66pub struct ComponentRegistry {
67 factories: Vec<Box<dyn CustomNodeFactory>>,
68}
69
70impl ComponentRegistry {
71 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 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 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 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 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 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 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 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 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}