1use std::collections::HashMap;
9
10use iced::Font;
11use serde_json::Value;
12
13use crate::effects;
14use crate::protocol::{EffectResponse, IncomingMessage, OutgoingEvent};
15use crate::theming;
16use crate::tree::Tree;
17use crate::widgets::{self, WidgetCaches};
18
19#[derive(Debug)]
21pub enum CoreEffect {
22 SyncWindows,
24 EmitEvent(OutgoingEvent),
26 EmitEffectResponse(EffectResponse),
28 WidgetOp { op: String, payload: Value },
30 WindowOp {
32 op: String,
33 window_id: String,
34 settings: Value,
35 },
36 ThemeChanged(iced::Theme),
38 ThemeFollowsSystem,
40 ImageOp {
42 op: String,
43 handle: String,
44 data: Option<Vec<u8>>,
45 pixels: Option<Vec<u8>>,
46 width: Option<u32>,
47 height: Option<u32>,
48 },
49 ExtensionConfig(Value),
51 SpawnAsyncEffect {
53 request_id: String,
54 effect_type: String,
55 params: Value,
56 },
57}
58
59pub struct Core {
65 pub tree: Tree,
67 pub caches: WidgetCaches,
69 pub active_subscriptions: HashMap<String, String>,
71 pub default_text_size: Option<f32>,
73 pub default_font: Option<Font>,
75 pub cached_theme: Option<iced::Theme>,
78 cached_theme_json: Option<String>,
80 settings_applied: bool,
83}
84
85impl Default for Core {
86 fn default() -> Self {
87 Self::new()
88 }
89}
90
91impl Core {
92 pub fn new() -> Self {
93 Self {
94 tree: Tree::new(),
95 caches: WidgetCaches::new(),
96 active_subscriptions: HashMap::new(),
97 default_text_size: None,
98 default_font: None,
99 cached_theme: None,
100 cached_theme_json: None,
101 settings_applied: false,
102 }
103 }
104
105 pub fn tree_hash(&self) -> String {
108 use sha2::{Digest, Sha256};
109 match &self.tree.root() {
110 Some(root) => {
111 let json = serde_json::to_string(root).unwrap_or_default();
112 let hash = Sha256::digest(json.as_bytes());
113 format!("{:x}", hash)
114 }
115 None => String::new(),
116 }
117 }
118
119 fn resolve_and_cache_theme(
122 &mut self,
123 theme_val: &serde_json::Value,
124 effects: &mut Vec<CoreEffect>,
125 ) {
126 let json_str = theme_val.to_string();
127 if self.cached_theme_json.as_deref() == Some(&json_str) {
128 return;
130 }
131 self.cached_theme_json = Some(json_str);
132 match theming::resolve_theme_only(theme_val) {
133 Some(theme) => {
134 self.cached_theme = Some(theme.clone());
135 effects.push(CoreEffect::ThemeChanged(theme));
136 }
137 None => {
138 self.cached_theme = None;
139 effects.push(CoreEffect::ThemeFollowsSystem);
140 }
141 }
142 }
143
144 pub fn apply(&mut self, message: IncomingMessage) -> Vec<CoreEffect> {
146 let mut effects = Vec::new();
147
148 match message {
149 IncomingMessage::Snapshot { tree } => {
150 log::debug!("snapshot received (root id={})", tree.id);
151 if let Some(theme_val) = tree.props.get("theme") {
152 self.resolve_and_cache_theme(theme_val, &mut effects);
153 }
154 self.tree.snapshot(tree);
155 self.caches.clear_builtin();
159 if let Some(root) = self.tree.root() {
160 widgets::ensure_caches(root, &mut self.caches);
161 }
162 effects.push(CoreEffect::SyncWindows);
163 }
164 IncomingMessage::Patch { ops } => {
165 log::debug!("patch received ({} ops)", ops.len());
166 self.tree.apply_patch(ops);
167 if let Some(root) = self.tree.root()
169 && let Some(theme_val) = root.props.get("theme")
170 {
171 let theme_val = theme_val.clone();
172 self.resolve_and_cache_theme(&theme_val, &mut effects);
173 }
174 if let Some(root) = self.tree.root() {
175 widgets::ensure_caches(root, &mut self.caches);
176 }
177 effects.push(CoreEffect::SyncWindows);
178 }
179 IncomingMessage::Effect { id, kind, payload } => {
180 log::debug!("effect request: {kind} ({id})");
181 if effects::is_async_effect(&kind) {
182 effects.push(CoreEffect::SpawnAsyncEffect {
183 request_id: id,
184 effect_type: kind,
185 params: payload,
186 });
187 } else {
188 let response = effects::handle_effect(id, &kind, &payload);
189 effects.push(CoreEffect::EmitEffectResponse(response));
190 }
191 }
192 IncomingMessage::WidgetOp { op, payload } => {
193 log::debug!("widget_op: {op}");
194 effects.push(CoreEffect::WidgetOp { op, payload });
195 }
196 IncomingMessage::Subscribe { kind, tag } => {
197 log::debug!("subscription register: {kind} -> {tag}");
198 if let Some(old_tag) = self.active_subscriptions.insert(kind.clone(), tag.clone())
199 && old_tag != tag
200 {
201 log::warn!(
202 "subscription `{kind}` re-registered with tag `{tag}` \
203 (was `{old_tag}`); previous handler replaced"
204 );
205 }
206 }
207 IncomingMessage::Unsubscribe { kind } => {
208 log::debug!("subscription unregister: {kind}");
209 self.active_subscriptions.remove(&kind);
210 }
211 IncomingMessage::WindowOp {
212 op,
213 window_id,
214 settings,
215 } => {
216 log::debug!("window_op: {op} ({window_id})");
217 effects.push(CoreEffect::WindowOp {
218 op,
219 window_id,
220 settings,
221 });
222 }
223 IncomingMessage::Settings { settings } => {
224 log::debug!("settings received");
225
226 if let Some(v) = settings.get("protocol_version").and_then(|v| v.as_u64()) {
228 if v != u64::from(crate::protocol::PROTOCOL_VERSION) {
229 log::error!(
230 "protocol version mismatch: expected {}, got {}",
231 crate::protocol::PROTOCOL_VERSION,
232 v
233 );
234 }
235 } else {
236 log::error!("no protocol_version in Settings, assuming compatible");
237 }
238
239 if self.settings_applied {
243 for field in &["antialiasing", "vsync", "fonts", "scale_factor"] {
244 if settings.get(*field).is_some() {
245 log::warn!(
246 "Settings field `{field}` is startup-only; \
247 ignored after the daemon has started"
248 );
249 }
250 }
251 }
252 self.settings_applied = true;
253
254 self.default_text_size = settings
255 .get("default_text_size")
256 .and_then(|v| v.as_f64())
257 .map(|v| v as f32);
258 self.default_font = settings.get("default_font").map(|v| {
259 let family = v.get("family").and_then(|f| f.as_str());
260 match family {
261 Some("monospace") => Font::MONOSPACE,
262 Some(other) => {
263 log::warn!(
264 "unsupported default_font family `{other}`, \
265 using system default"
266 );
267 Font::DEFAULT
268 }
269 None => Font::DEFAULT,
270 }
271 });
272 if let Some(ext_config) = settings.get("extension_config") {
273 effects.push(CoreEffect::ExtensionConfig(ext_config.clone()));
274 }
275 }
276 IncomingMessage::ImageOp {
277 op,
278 handle,
279 data,
280 pixels,
281 width,
282 height,
283 } => {
284 log::debug!("image_op: {op} ({handle})");
285 effects.push(CoreEffect::ImageOp {
286 op,
287 handle,
288 data,
289 pixels,
290 width,
291 height,
292 });
293 }
294 IncomingMessage::Query { .. } => {
299 log::debug!("Query message ignored by Core (handled by scripting layer)");
300 }
301 IncomingMessage::Interact { .. } => {
302 log::debug!("Interact message ignored by Core (handled by scripting layer)");
303 }
304 IncomingMessage::TreeHash { .. } => {
305 log::debug!("TreeHash message ignored by Core (handled by scripting layer)");
306 }
307 IncomingMessage::Screenshot { .. } => {
308 log::debug!("Screenshot message ignored by Core (handled by scripting layer)");
309 }
310 IncomingMessage::Reset { .. } => {
311 log::debug!("Reset message ignored by Core (handled by scripting layer)");
312 }
313 IncomingMessage::ExtensionCommand { .. } => {
314 log::debug!("ExtensionCommand message ignored by Core (handled by renderer App)");
315 }
316 IncomingMessage::AdvanceFrame { .. } => {
317 log::warn!(
318 "AdvanceFrame is only supported in headless/test mode; ignored in daemon mode"
319 );
320 }
321 IncomingMessage::ExtensionCommands { .. } => {
322 log::debug!("ExtensionCommands message ignored by Core (handled by renderer App)");
323 }
324 }
325
326 effects
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use serde_json::Value;
333
334 use super::*;
335 use crate::protocol::{IncomingMessage, TreeNode};
336
337 fn make_node(id: &str, type_name: &str) -> TreeNode {
338 TreeNode {
339 id: id.to_string(),
340 type_name: type_name.to_string(),
341 props: serde_json::json!({}),
342 children: vec![],
343 }
344 }
345
346 fn make_node_with_props(id: &str, type_name: &str, props: Value) -> TreeNode {
347 TreeNode {
348 id: id.to_string(),
349 type_name: type_name.to_string(),
350 props,
351 children: vec![],
352 }
353 }
354
355 #[test]
358 fn new_returns_empty_tree() {
359 let core = Core::new();
360 assert!(core.tree.root().is_none());
361 }
362
363 #[test]
364 fn new_has_empty_active_subscriptions() {
365 let core = Core::new();
366 assert!(core.active_subscriptions.is_empty());
367 }
368
369 #[test]
370 fn new_has_no_default_text_size() {
371 let core = Core::new();
372 assert!(core.default_text_size.is_none());
373 }
374
375 #[test]
376 fn new_has_no_default_font() {
377 let core = Core::new();
378 assert!(core.default_font.is_none());
379 }
380
381 #[test]
384 fn snapshot_sets_tree_and_returns_sync_windows() {
385 let mut core = Core::new();
386 let msg = IncomingMessage::Snapshot {
387 tree: make_node("root", "column"),
388 };
389 let effects = core.apply(msg);
390 assert!(core.tree.root().is_some());
392 assert_eq!(core.tree.root().unwrap().id, "root");
393 let has_sync = effects.iter().any(|e| matches!(e, CoreEffect::SyncWindows));
395 assert!(has_sync);
396 }
397
398 #[test]
399 fn snapshot_with_theme_prop_returns_theme_changed() {
400 let mut core = Core::new();
401 let msg = IncomingMessage::Snapshot {
402 tree: make_node_with_props("root", "column", serde_json::json!({"theme": "dark"})),
403 };
404 let effects = core.apply(msg);
405 let has_theme = effects
406 .iter()
407 .any(|e| matches!(e, CoreEffect::ThemeChanged(_)));
408 assert!(has_theme);
409 }
410
411 #[test]
412 fn snapshot_without_theme_prop_has_no_theme_changed() {
413 let mut core = Core::new();
414 let msg = IncomingMessage::Snapshot {
415 tree: make_node("root", "column"),
416 };
417 let effects = core.apply(msg);
418 let has_theme = effects
419 .iter()
420 .any(|e| matches!(e, CoreEffect::ThemeChanged(_)));
421 assert!(!has_theme);
422 }
423
424 #[test]
427 fn patch_with_no_ops_returns_sync_windows() {
428 let mut core = Core::new();
429 let snapshot_msg = IncomingMessage::Snapshot {
431 tree: make_node("root", "column"),
432 };
433 core.apply(snapshot_msg);
434
435 let patch_msg = IncomingMessage::Patch { ops: vec![] };
436 let effects = core.apply(patch_msg);
437 let has_sync = effects.iter().any(|e| matches!(e, CoreEffect::SyncWindows));
438 assert!(has_sync);
439 }
440
441 #[test]
444 fn settings_sets_default_text_size() {
445 let mut core = Core::new();
446 let msg = IncomingMessage::Settings {
447 settings: serde_json::json!({"default_text_size": 18.0}),
448 };
449 core.apply(msg);
450 assert_eq!(core.default_text_size, Some(18.0_f32));
451 }
452
453 #[test]
454 fn settings_sets_default_font_monospace() {
455 let mut core = Core::new();
456 let msg = IncomingMessage::Settings {
457 settings: serde_json::json!({"default_font": {"family": "monospace"}}),
458 };
459 core.apply(msg);
460 assert_eq!(core.default_font, Some(iced::Font::MONOSPACE));
461 }
462
463 #[test]
464 fn settings_sets_default_font_default_for_unknown_family() {
465 let mut core = Core::new();
466 let msg = IncomingMessage::Settings {
467 settings: serde_json::json!({"default_font": {"family": "sans-serif"}}),
468 };
469 core.apply(msg);
470 assert_eq!(core.default_font, Some(iced::Font::DEFAULT));
471 }
472
473 #[test]
474 fn settings_without_extension_config_returns_no_effects() {
475 let mut core = Core::new();
476 let msg = IncomingMessage::Settings {
477 settings: serde_json::json!({"default_text_size": 14.0}),
478 };
479 let effects = core.apply(msg);
480 assert!(effects.is_empty());
481 }
482
483 #[test]
484 fn settings_with_extension_config_emits_effect() {
485 let mut core = Core::new();
486 let msg = IncomingMessage::Settings {
487 settings: serde_json::json!({
488 "default_text_size": 14.0,
489 "extension_config": {
490 "terminal": {"shell": "/bin/bash"}
491 }
492 }),
493 };
494 let effects = core.apply(msg);
495 let has_ext_config = effects
496 .iter()
497 .any(|e| matches!(e, CoreEffect::ExtensionConfig(_)));
498 assert!(has_ext_config);
499 }
500
501 #[test]
502 fn settings_with_extension_config_contains_correct_value() {
503 let mut core = Core::new();
504 let config_val = serde_json::json!({"terminal": {"shell": "/bin/zsh"}});
505 let msg = IncomingMessage::Settings {
506 settings: serde_json::json!({
507 "extension_config": config_val,
508 }),
509 };
510 let effects = core.apply(msg);
511 let ext_config = effects.iter().find_map(|e| match e {
512 CoreEffect::ExtensionConfig(v) => Some(v),
513 _ => None,
514 });
515 assert_eq!(
516 ext_config.unwrap(),
517 &serde_json::json!({"terminal": {"shell": "/bin/zsh"}})
518 );
519 }
520
521 #[test]
524 fn subscription_register_adds_to_active_subscriptions() {
525 let mut core = Core::new();
526 let msg = IncomingMessage::Subscribe {
527 kind: "time".to_string(),
528 tag: "tick".to_string(),
529 };
530 core.apply(msg);
531 assert_eq!(
532 core.active_subscriptions.get("time").map(|s| s.as_str()),
533 Some("tick")
534 );
535 }
536
537 #[test]
538 fn subscription_register_returns_no_effects() {
539 let mut core = Core::new();
540 let msg = IncomingMessage::Subscribe {
541 kind: "keyboard".to_string(),
542 tag: "key".to_string(),
543 };
544 let effects = core.apply(msg);
545 assert!(effects.is_empty());
546 }
547
548 #[test]
549 fn subscription_unregister_removes_from_active_subscriptions() {
550 let mut core = Core::new();
551 core.active_subscriptions
552 .insert("time".to_string(), "tick".to_string());
553 let msg = IncomingMessage::Unsubscribe {
554 kind: "time".to_string(),
555 };
556 core.apply(msg);
557 assert!(!core.active_subscriptions.contains_key("time"));
558 }
559
560 #[test]
561 fn subscription_unregister_returns_no_effects() {
562 let mut core = Core::new();
563 let msg = IncomingMessage::Unsubscribe {
564 kind: "time".to_string(),
565 };
566 let effects = core.apply(msg);
567 assert!(effects.is_empty());
568 }
569
570 #[test]
573 fn unhandled_message_returns_empty_effects() {
574 let mut core = Core::new();
575 let msg = IncomingMessage::Query {
577 id: "q1".to_string(),
578 target: "tree".to_string(),
579 selector: Value::Null,
580 };
581 let effects = core.apply(msg);
582 assert!(effects.is_empty());
583 }
584
585 #[test]
588 fn snapshot_preserves_extension_caches() {
589 let mut core = Core::new();
590
591 core.caches.extension.insert("ext", "node-1", 42u32);
593
594 let msg = IncomingMessage::Snapshot {
596 tree: make_node("root", "column"),
597 };
598 core.apply(msg);
599
600 assert_eq!(core.caches.extension.get::<u32>("ext", "node-1"), Some(&42));
604 }
605
606 #[test]
607 fn snapshot_clears_builtin_caches() {
608 let mut core = Core::new();
609
610 let editor_node = make_node_with_props(
612 "ed1",
613 "text_editor",
614 serde_json::json!({"content": "hello"}),
615 );
616 let mut root = make_node("root", "column");
617 root.children.push(editor_node);
618 core.apply(IncomingMessage::Snapshot { tree: root });
619 assert!(core.caches.editor_contents.contains_key("ed1"));
620
621 core.apply(IncomingMessage::Snapshot {
624 tree: make_node("root2", "column"),
625 });
626 assert!(!core.caches.editor_contents.contains_key("ed1"));
627 }
628
629 fn make_window_node(id: &str) -> TreeNode {
632 TreeNode {
633 id: id.to_string(),
634 type_name: "window".to_string(),
635 props: serde_json::json!({}),
636 children: vec![],
637 }
638 }
639
640 #[test]
641 fn multi_window_snapshot_two_windows_produces_sync_windows() {
642 let mut core = Core::new();
643 let mut root = make_node("root", "column");
644 root.children.push(make_window_node("win-a"));
645 root.children.push(make_window_node("win-b"));
646
647 let effects = core.apply(IncomingMessage::Snapshot { tree: root });
648
649 let has_sync = effects.iter().any(|e| matches!(e, CoreEffect::SyncWindows));
650 assert!(has_sync, "Snapshot with windows should produce SyncWindows");
651
652 let ids = core.tree.window_ids();
654 assert_eq!(ids.len(), 2);
655 assert!(ids.contains(&"win-a".to_string()));
656 assert!(ids.contains(&"win-b".to_string()));
657 }
658
659 #[test]
660 fn multi_window_second_snapshot_removes_window() {
661 let mut core = Core::new();
662
663 let mut root1 = make_node("root", "column");
665 root1.children.push(make_window_node("win-a"));
666 root1.children.push(make_window_node("win-b"));
667 core.apply(IncomingMessage::Snapshot { tree: root1 });
668 assert_eq!(core.tree.window_ids().len(), 2);
669
670 let mut root2 = make_node("root", "column");
672 root2.children.push(make_window_node("win-a"));
673 let effects = core.apply(IncomingMessage::Snapshot { tree: root2 });
674
675 let has_sync = effects.iter().any(|e| matches!(e, CoreEffect::SyncWindows));
676 assert!(has_sync, "Second Snapshot should produce SyncWindows");
677
678 let ids = core.tree.window_ids();
679 assert_eq!(ids.len(), 1);
680 assert_eq!(ids[0], "win-a");
681 }
682
683 #[test]
684 fn multi_window_snapshot_then_add_window_via_second_snapshot() {
685 let mut core = Core::new();
686
687 let mut root1 = make_node("root", "column");
689 root1.children.push(make_window_node("win-a"));
690 core.apply(IncomingMessage::Snapshot { tree: root1 });
691 assert_eq!(core.tree.window_ids().len(), 1);
692
693 let mut root2 = make_node("root", "column");
695 root2.children.push(make_window_node("win-a"));
696 root2.children.push(make_window_node("win-b"));
697 root2.children.push(make_window_node("win-c"));
698 let effects = core.apply(IncomingMessage::Snapshot { tree: root2 });
699
700 let has_sync = effects.iter().any(|e| matches!(e, CoreEffect::SyncWindows));
701 assert!(has_sync);
702
703 let ids = core.tree.window_ids();
704 assert_eq!(ids.len(), 3);
705 }
706}
707
708#[cfg(test)]
716mod extension_event_tests {
717 use iced::{Element, Theme};
718 use serde_json::{Value, json};
719
720 use crate::extensions::{
721 EventResult, ExtensionCaches, ExtensionDispatcher, GenerationCounter, WidgetEnv,
722 WidgetExtension,
723 };
724 use crate::message::Message;
725 use crate::protocol::{OutgoingEvent, TreeNode};
726
727 struct CountingExtension;
730
731 impl WidgetExtension for CountingExtension {
732 fn type_names(&self) -> &[&str] {
733 &["counter_widget"]
734 }
735
736 fn config_key(&self) -> &str {
737 "counting"
738 }
739
740 fn prepare(&mut self, node: &TreeNode, caches: &mut ExtensionCaches, _theme: &Theme) {
741 caches.get_or_insert(self.config_key(), &node.id, GenerationCounter::new);
743 }
744
745 fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
746 iced::widget::text("test").into()
747 }
748
749 fn handle_event(
750 &mut self,
751 node_id: &str,
752 _family: &str,
753 _data: &Value,
754 caches: &mut ExtensionCaches,
755 ) -> EventResult {
756 if let Some(counter) = caches.get_mut::<GenerationCounter>(self.config_key(), node_id) {
759 counter.bump();
760 }
761 EventResult::Consumed(vec![])
762 }
763 }
764
765 struct ObservingExtension;
767
768 impl WidgetExtension for ObservingExtension {
769 fn type_names(&self) -> &[&str] {
770 &["observer_widget"]
771 }
772
773 fn config_key(&self) -> &str {
774 "observing"
775 }
776
777 fn prepare(&mut self, node: &TreeNode, caches: &mut ExtensionCaches, _theme: &Theme) {
778 caches.get_or_insert(self.config_key(), &node.id, GenerationCounter::new);
779 }
780
781 fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
782 iced::widget::text("test").into()
783 }
784
785 fn handle_event(
786 &mut self,
787 node_id: &str,
788 _family: &str,
789 _data: &Value,
790 caches: &mut ExtensionCaches,
791 ) -> EventResult {
792 if let Some(counter) = caches.get_mut::<GenerationCounter>(self.config_key(), node_id) {
793 counter.bump();
794 }
795 EventResult::Observed(vec![OutgoingEvent::generic(
796 "viewport".to_string(),
797 node_id.to_string(),
798 Some(json!({"zoom": 1.5})),
799 )])
800 }
801 }
802
803 fn make_tree(id: &str, type_name: &str) -> TreeNode {
804 TreeNode {
805 id: id.to_string(),
806 type_name: type_name.to_string(),
807 props: json!({}),
808 children: vec![],
809 }
810 }
811
812 #[test]
815 fn consumed_empty_events_still_mutates_caches() {
816 let ext: Box<dyn WidgetExtension> = Box::new(CountingExtension);
817 let mut dispatcher = ExtensionDispatcher::new(vec![ext]);
818 let mut caches = ExtensionCaches::new();
819 let root = make_tree("cw-1", "counter_widget");
820
821 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
823 assert_eq!(
824 caches
825 .get::<GenerationCounter>("counting", "cw-1")
826 .unwrap()
827 .get(),
828 0
829 );
830
831 let result = dispatcher.handle_event("cw-1", "click", &Value::Null, &mut caches);
833 assert!(matches!(result, EventResult::Consumed(ref v) if v.is_empty()));
834
835 assert_eq!(
837 caches
838 .get::<GenerationCounter>("counting", "cw-1")
839 .unwrap()
840 .get(),
841 1
842 );
843 }
844
845 #[test]
846 fn consumed_caches_accumulate_across_multiple_events() {
847 let ext: Box<dyn WidgetExtension> = Box::new(CountingExtension);
848 let mut dispatcher = ExtensionDispatcher::new(vec![ext]);
849 let mut caches = ExtensionCaches::new();
850 let root = make_tree("cw-1", "counter_widget");
851
852 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
853
854 for _ in 0..5 {
855 let _ = dispatcher.handle_event("cw-1", "click", &Value::Null, &mut caches);
856 }
857
858 assert_eq!(
859 caches
860 .get::<GenerationCounter>("counting", "cw-1")
861 .unwrap()
862 .get(),
863 5
864 );
865 }
866
867 #[test]
870 fn observed_mutates_caches_and_returns_events() {
871 let ext: Box<dyn WidgetExtension> = Box::new(ObservingExtension);
872 let mut dispatcher = ExtensionDispatcher::new(vec![ext]);
873 let mut caches = ExtensionCaches::new();
874 let root = make_tree("ow-1", "observer_widget");
875
876 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
877
878 let result = dispatcher.handle_event("ow-1", "pan", &Value::Null, &mut caches);
879 match result {
880 EventResult::Observed(events) => {
881 assert_eq!(events.len(), 1);
882 }
883 other => panic!("expected Observed, got {:?}", variant_name(&other)),
884 }
885
886 assert_eq!(
887 caches
888 .get::<GenerationCounter>("observing", "ow-1")
889 .unwrap()
890 .get(),
891 1
892 );
893 }
894
895 #[test]
898 fn unknown_node_returns_passthrough() {
899 let ext: Box<dyn WidgetExtension> = Box::new(CountingExtension);
900 let mut dispatcher = ExtensionDispatcher::new(vec![ext]);
901 let mut caches = ExtensionCaches::new();
902
903 let result = dispatcher.handle_event("nonexistent", "click", &Value::Null, &mut caches);
905 assert!(matches!(result, EventResult::PassThrough));
906 }
907
908 #[test]
911 fn generation_counter_detects_stale_state() {
912 let mut counter = GenerationCounter::new();
913 let saved = counter.get();
914 assert_eq!(saved, 0);
915
916 counter.bump();
917 assert_ne!(counter.get(), saved, "generation should differ after bump");
918
919 let needs_redraw = counter.get() != saved;
922 assert!(needs_redraw);
923 }
924
925 fn variant_name(result: &EventResult) -> &'static str {
926 match result {
927 EventResult::PassThrough => "PassThrough",
928 EventResult::Consumed(_) => "Consumed",
929 EventResult::Observed(_) => "Observed",
930 }
931 }
932}