1use std::io;
14
15use iced::keyboard::{self, Key, Modifiers};
16use iced::mouse;
17use iced::{Event, Point};
18
19use smol_str::SmolStr;
20
21use serde_json::Value;
22
23use plushie_renderer_engine::Codec;
24use plushie_renderer_engine::Core;
25use plushie_widget_sdk::protocol::{
26 InteractResponse, OutgoingEvent, QueryResponse, ResetResponse, TreeHashResponse, TreeNode,
27};
28
29pub use plushie_core::{MAX_SELECTOR_SEARCH_DEPTH, Selector};
34
35pub fn parse_selector(selector: &Value) -> Option<Selector> {
39 Selector::from_wire(selector)
40}
41
42pub fn parse_key_and_modifiers(
56 payload: Option<&serde_json::Map<String, Value>>,
57) -> (String, Value) {
58 let payload_value = payload
59 .map(|m| Value::Object(m.clone()))
60 .unwrap_or(Value::Null);
61
62 if let Some(kp) = plushie_core::KeyPress::from_wire(&payload_value) {
64 return (kp.key.wire_name(), resolve_modifiers(&kp.modifiers));
65 }
66
67 let raw_key = payload_value
70 .get("combo")
71 .and_then(|v| v.as_str())
72 .or_else(|| payload_value.get("key").and_then(|v| v.as_str()))
73 .unwrap_or("");
74 match raw_key.parse::<plushie_core::KeyPress>() {
75 Ok(kp) => (kp.key.wire_name(), resolve_modifiers(&kp.modifiers)),
76 Err(err) => {
77 log::warn!("invalid scripted key combo {raw_key:?}: {err}");
78 let key = plushie_core::Key::from(raw_key);
79 (key.wire_name(), resolve_modifiers(&Default::default()))
80 }
81 }
82}
83
84pub fn parse_iced_key(name: &str) -> Key {
93 let core_key = plushie_core::Key::from(name);
94 core_key_to_iced(&core_key)
95}
96
97fn resolve_modifiers(mods: &plushie_core::protocol::KeyModifiers) -> Value {
103 let is_macos = cfg!(target_os = "macos");
104 serde_json::json!({
105 "shift": mods.shift,
106 "ctrl": mods.ctrl || (!is_macos && mods.command),
107 "alt": mods.alt,
108 "logo": mods.logo || (is_macos && mods.command),
109 })
110}
111
112fn core_key_to_iced(key: &plushie_core::Key) -> Key {
114 use plushie_core::Key as CK;
115 match key {
116 CK::ArrowUp => Key::Named(keyboard::key::Named::ArrowUp),
117 CK::ArrowDown => Key::Named(keyboard::key::Named::ArrowDown),
118 CK::ArrowLeft => Key::Named(keyboard::key::Named::ArrowLeft),
119 CK::ArrowRight => Key::Named(keyboard::key::Named::ArrowRight),
120 CK::Home => Key::Named(keyboard::key::Named::Home),
121 CK::End => Key::Named(keyboard::key::Named::End),
122 CK::PageUp => Key::Named(keyboard::key::Named::PageUp),
123 CK::PageDown => Key::Named(keyboard::key::Named::PageDown),
124 CK::Enter => Key::Named(keyboard::key::Named::Enter),
125 CK::Tab => Key::Named(keyboard::key::Named::Tab),
126 CK::Space => Key::Named(keyboard::key::Named::Space),
127 CK::Backspace => Key::Named(keyboard::key::Named::Backspace),
128 CK::Delete => Key::Named(keyboard::key::Named::Delete),
129 CK::Insert => Key::Named(keyboard::key::Named::Insert),
130 CK::Escape => Key::Named(keyboard::key::Named::Escape),
131 CK::Shift => Key::Named(keyboard::key::Named::Shift),
132 CK::Control => Key::Named(keyboard::key::Named::Control),
133 CK::Alt => Key::Named(keyboard::key::Named::Alt),
134 CK::Super => Key::Named(keyboard::key::Named::Super),
135 CK::F1 => Key::Named(keyboard::key::Named::F1),
136 CK::F2 => Key::Named(keyboard::key::Named::F2),
137 CK::F3 => Key::Named(keyboard::key::Named::F3),
138 CK::F4 => Key::Named(keyboard::key::Named::F4),
139 CK::F5 => Key::Named(keyboard::key::Named::F5),
140 CK::F6 => Key::Named(keyboard::key::Named::F6),
141 CK::F7 => Key::Named(keyboard::key::Named::F7),
142 CK::F8 => Key::Named(keyboard::key::Named::F8),
143 CK::F9 => Key::Named(keyboard::key::Named::F9),
144 CK::F10 => Key::Named(keyboard::key::Named::F10),
145 CK::F11 => Key::Named(keyboard::key::Named::F11),
146 CK::F12 => Key::Named(keyboard::key::Named::F12),
147 CK::CapsLock => Key::Named(keyboard::key::Named::CapsLock),
148 CK::NumLock => Key::Named(keyboard::key::Named::NumLock),
149 CK::ScrollLock => Key::Named(keyboard::key::Named::ScrollLock),
150 CK::PrintScreen => Key::Named(keyboard::key::Named::PrintScreen),
151 CK::Pause => Key::Named(keyboard::key::Named::Pause),
152 CK::ContextMenu => Key::Named(keyboard::key::Named::ContextMenu),
153 CK::Copy => Key::Named(keyboard::key::Named::Copy),
154 CK::Cut => Key::Named(keyboard::key::Named::Cut),
155 CK::Paste => Key::Named(keyboard::key::Named::Paste),
156 CK::Undo => Key::Named(keyboard::key::Named::Undo),
157 CK::Redo => Key::Named(keyboard::key::Named::Redo),
158 CK::Char(c) => Key::Character(SmolStr::new(c.to_string())),
159 CK::Named(name) => {
160 Key::Character(SmolStr::new(name))
163 }
164 }
165}
166
167pub fn parse_iced_modifiers(mods: &Value) -> Modifiers {
169 let mut m = Modifiers::empty();
170 if mods.get("shift").and_then(|v| v.as_bool()).unwrap_or(false) {
171 m |= Modifiers::SHIFT;
172 }
173 if mods.get("ctrl").and_then(|v| v.as_bool()).unwrap_or(false) {
174 m |= Modifiers::CTRL;
175 }
176 if mods.get("alt").and_then(|v| v.as_bool()).unwrap_or(false) {
177 m |= Modifiers::ALT;
178 }
179 if mods.get("logo").and_then(|v| v.as_bool()).unwrap_or(false) {
180 m |= Modifiers::LOGO;
181 }
182 m
183}
184
185pub fn make_key_pressed(key: Key, modifiers: Modifiers, text: Option<SmolStr>) -> Event {
187 Event::Keyboard(keyboard::Event::KeyPressed {
188 key: key.clone(),
189 modified_key: key,
190 physical_key: keyboard::key::Physical::Unidentified(
191 keyboard::key::NativeCode::Unidentified,
192 ),
193 location: keyboard::Location::Standard,
194 modifiers,
195 text,
196 repeat: false,
197 })
198}
199
200pub fn make_key_released(key: Key, modifiers: Modifiers) -> Event {
202 Event::Keyboard(keyboard::Event::KeyReleased {
203 key: key.clone(),
204 modified_key: key,
205 physical_key: keyboard::key::Physical::Unidentified(
206 keyboard::key::NativeCode::Unidentified,
207 ),
208 location: keyboard::Location::Standard,
209 modifiers,
210 })
211}
212
213pub fn interaction_to_iced_events(
222 action: &str,
223 _widget_id: Option<&str>,
224 payload: &Value,
225 cursor: mouse::Cursor,
226) -> Vec<Event> {
227 match action {
228 "click" | "toggle" | "select" => {
229 let pos = match cursor {
231 mouse::Cursor::Available(p) | mouse::Cursor::Levitating(p) => p,
232 mouse::Cursor::Unavailable => Point::new(0.0, 0.0),
233 };
234 vec![
235 Event::Mouse(mouse::Event::CursorMoved { position: pos }),
236 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
237 Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
238 ]
239 }
240 "type_text" => {
241 let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
242 text.chars()
243 .flat_map(|c| {
244 let s = SmolStr::new(c.to_string());
245 let key = Key::Character(s.clone());
246 [
247 make_key_pressed(key.clone(), Modifiers::empty(), Some(s)),
248 make_key_released(key, Modifiers::empty()),
249 ]
250 })
251 .collect()
252 }
253 "type_key" => {
254 let payload_map = payload.as_object();
255 let (key_str, mods_json) = parse_key_and_modifiers(payload_map);
256 let key = parse_iced_key(&key_str);
257 let modifiers = parse_iced_modifiers(&mods_json);
258 let text = match &key {
259 Key::Character(c) if modifiers.is_empty() => Some(c.clone()),
260 _ => None,
261 };
262 vec![
263 make_key_pressed(key.clone(), modifiers, text),
264 make_key_released(key, modifiers),
265 ]
266 }
267 "press" => {
268 let payload_map = payload.as_object();
269 let (key_str, mods_json) = parse_key_and_modifiers(payload_map);
270 let key = parse_iced_key(&key_str);
271 let modifiers = parse_iced_modifiers(&mods_json);
272 let text = match &key {
273 Key::Character(c) if modifiers.is_empty() => Some(c.clone()),
274 _ => None,
275 };
276 vec![make_key_pressed(key, modifiers, text)]
277 }
278 "release" => {
279 let payload_map = payload.as_object();
280 let (key_str, mods_json) = parse_key_and_modifiers(payload_map);
281 let key = parse_iced_key(&key_str);
282 let modifiers = parse_iced_modifiers(&mods_json);
283 vec![make_key_released(key, modifiers)]
284 }
285 "submit" => {
286 let key = Key::Named(keyboard::key::Named::Enter);
287 vec![
288 make_key_pressed(key.clone(), Modifiers::empty(), None),
289 make_key_released(key, Modifiers::empty()),
290 ]
291 }
292 "scroll" => {
293 let delta_x = payload
294 .get("delta_x")
295 .and_then(|v| v.as_f64())
296 .unwrap_or(0.0) as f32;
297 let delta_y = payload
298 .get("delta_y")
299 .and_then(|v| v.as_f64())
300 .unwrap_or(0.0) as f32;
301 vec![Event::Mouse(mouse::Event::WheelScrolled {
302 delta: mouse::ScrollDelta::Lines {
303 x: delta_x,
304 y: delta_y,
305 },
306 })]
307 }
308 "move_to" => {
309 let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
310 let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
311 vec![Event::Mouse(mouse::Event::CursorMoved {
312 position: Point::new(x, y),
313 })]
314 }
315 "canvas_press" => {
319 let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
320 let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
321 let button = match payload.get("button").and_then(|v| v.as_str()) {
322 Some("right") => mouse::Button::Right,
323 Some("middle") => mouse::Button::Middle,
324 _ => mouse::Button::Left,
325 };
326 vec![
327 Event::Mouse(mouse::Event::CursorMoved {
328 position: Point::new(x, y),
329 }),
330 Event::Mouse(mouse::Event::ButtonPressed(button)),
331 ]
332 }
333 "canvas_release" => {
334 let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
335 let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
336 let button = match payload.get("button").and_then(|v| v.as_str()) {
337 Some("right") => mouse::Button::Right,
338 Some("middle") => mouse::Button::Middle,
339 _ => mouse::Button::Left,
340 };
341 vec![
342 Event::Mouse(mouse::Event::CursorMoved {
343 position: Point::new(x, y),
344 }),
345 Event::Mouse(mouse::Event::ButtonReleased(button)),
346 ]
347 }
348 "canvas_move" => {
349 let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
350 let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
351 vec![Event::Mouse(mouse::Event::CursorMoved {
352 position: Point::new(x, y),
353 })]
354 }
355 "click_element" => {
359 let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
363 let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
364 vec![
365 Event::Mouse(mouse::Event::CursorMoved {
366 position: Point::new(x, y),
367 }),
368 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
369 Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
370 ]
371 }
372 "focus_element" => {
373 vec![Event::Keyboard(iced::keyboard::Event::KeyPressed {
376 key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab),
377 modified_key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab),
378 physical_key: iced::keyboard::key::Physical::Code(iced::keyboard::key::Code::Tab),
379 location: iced::keyboard::Location::Standard,
380 modifiers: iced::keyboard::Modifiers::default(),
381 text: None,
382 repeat: false,
383 })]
384 }
385 "paste" | "sort" | "pane_focus_cycle" | "slide" => vec![],
387 _ => vec![],
388 }
389}
390
391pub fn emit_wire<T: serde::Serialize>(
397 emitter: &crate::emitter::EventEmitter,
398 codec: &Codec,
399 value: &T,
400) -> io::Result<()> {
401 let bytes = codec.encode(value).map_err(io::Error::other)?;
402 emitter.write_raw(&bytes)
403}
404
405#[cfg(test)]
412fn search_tree<R>(
413 node: &TreeNode,
414 depth: usize,
415 predicate: &dyn Fn(&TreeNode) -> bool,
416 extract: &dyn Fn(&TreeNode) -> R,
417) -> Option<R> {
418 if depth > MAX_SELECTOR_SEARCH_DEPTH {
419 return None;
420 }
421 if predicate(node) {
422 return Some(extract(node));
423 }
424 for child in &node.children {
425 if let Some(found) = search_tree(child, depth + 1, predicate, extract) {
426 return Some(found);
427 }
428 }
429 None
430}
431
432fn node_to_value(node: &TreeNode) -> Value {
435 serde_json::to_value(node).unwrap_or(Value::Null)
436}
437
438#[cfg(test)]
443fn matches_role(node: &TreeNode, role: &str) -> bool {
444 if let Some(a11y) = node.props.get("a11y") {
445 a11y.as_object()
446 .and_then(|m| m.get("role"))
447 .and_then(|v| v.as_str())
448 == Some(role)
449 } else {
450 node.type_name == role
451 }
452}
453
454#[cfg(test)]
456fn matches_label(node: &TreeNode, label: &str) -> bool {
457 if let Some(a11y) = node.props.get("a11y")
458 && let Some(node_label) = a11y
459 .as_object()
460 .and_then(|m| m.get("label"))
461 .and_then(|v| v.as_str())
462 && node_label == label
463 {
464 return true;
465 }
466 for key in &["label", "content"] {
467 if let Some(val) = node.props.get(key)
468 && val.as_str() == Some(label)
469 {
470 return true;
471 }
472 }
473 false
474}
475
476#[cfg(test)]
478fn matches_text(node: &TreeNode, text: &str) -> bool {
479 for key in &["content", "label", "value", "placeholder"] {
480 if let Some(val) = node.props.get(key)
481 && val.as_str() == Some(text)
482 {
483 return true;
484 }
485 }
486 false
487}
488
489#[cfg(test)]
491fn is_focused(node: &TreeNode) -> bool {
492 if node.props.get("focused").and_then(|v| v.as_bool()) == Some(true) {
493 return true;
494 }
495 if let Some(a11y) = node.props.get("a11y")
496 && a11y
497 .as_object()
498 .and_then(|m| m.get("focused"))
499 .and_then(|v| v.as_bool())
500 == Some(true)
501 {
502 return true;
503 }
504 false
505}
506
507pub fn find_node_by_id(core: &Core, widget_id: &str, window_id: Option<&str>) -> Value {
510 let selector = match window_id {
511 Some(win) => Selector::id_in_window(widget_id, win),
512 None => Selector::id(widget_id),
513 };
514 find_by_selector(core, &selector)
515}
516
517pub fn find_node_by_text(core: &Core, text: &str) -> Value {
518 find_by_selector(core, &Selector::text(text))
519}
520
521pub fn find_node_by_role(core: &Core, role: &str) -> Value {
522 find_by_selector(core, &Selector::role(role))
523}
524
525pub fn find_node_by_label(core: &Core, label: &str) -> Value {
526 find_by_selector(core, &Selector::label(label))
527}
528
529pub fn find_focused_node(core: &Core) -> Value {
530 find_by_selector(core, &Selector::focused())
531}
532
533pub fn find_by_selector(core: &Core, selector: &Selector) -> Value {
535 core.tree
536 .root()
537 .and_then(|root| selector.find(root))
538 .map(node_to_value)
539 .unwrap_or(Value::Null)
540}
541
542pub fn find_id_by_text(node: &TreeNode, text: &str, _depth: usize) -> Option<String> {
545 Selector::text(text).find(node).map(|n| n.id.clone())
546}
547
548pub fn find_id_by_role(node: &TreeNode, role: &str, _depth: usize) -> Option<String> {
549 Selector::role(role).find(node).map(|n| n.id.clone())
550}
551
552pub fn find_id_by_label(node: &TreeNode, label: &str, _depth: usize) -> Option<String> {
553 Selector::label(label).find(node).map(|n| n.id.clone())
554}
555
556pub fn find_id_focused(node: &TreeNode, _depth: usize) -> Option<String> {
557 Selector::focused().find(node).map(|n| n.id.clone())
558}
559
560pub fn build_query_response(
566 core: &Core,
567 id: String,
568 target: String,
569 selector: Value,
570) -> QueryResponse {
571 let data = match target.as_str() {
572 "tree" => match core.tree.root() {
573 Some(root) => serde_json::to_value(root).unwrap_or(Value::Null),
574 None => Value::Null,
575 },
576 "find" => match parse_selector(&selector) {
577 Some(Selector::Id {
578 widget_id,
579 window_id,
580 }) => find_node_by_id(core, &widget_id, window_id.as_deref()),
581 Some(Selector::Text(text)) => find_node_by_text(core, &text),
582 Some(Selector::Role(role)) => find_node_by_role(core, &role),
583 Some(Selector::Label(label)) => find_node_by_label(core, &label),
584 Some(Selector::Focused) => find_focused_node(core),
585 None => Value::Null,
586 },
587 _ => {
588 log::warn!("unknown query target: {target}");
589 Value::Null
590 }
591 };
592
593 QueryResponse::new(id, target, data)
594}
595
596pub fn handle_query(
598 emitter: &crate::emitter::EventEmitter,
599 codec: &Codec,
600 core: &Core,
601 id: String,
602 target: String,
603 selector: Value,
604) -> io::Result<()> {
605 emit_wire(
606 emitter,
607 codec,
608 &build_query_response(core, id, target, selector),
609 )
610}
611
612pub fn resolve_widget_id(core: &Core, selector: &Value) -> Option<String> {
614 let sel = Selector::from_wire(selector)?;
615 let root = core.tree.root()?;
616 sel.find(root).map(|node| node.id.clone())
617}
618
619pub fn build_interact_response(
621 core: &Core,
622 id: String,
623 action: String,
624 selector: Value,
625 payload: Value,
626) -> InteractResponse {
627 let widget_target = resolve_widget_target(core, &selector);
628
629 let events: Vec<OutgoingEvent> = match (action.as_str(), widget_target) {
630 ("click", Some((_window_id, wid))) => {
631 vec![OutgoingEvent::click(wid)]
632 }
633 ("type_text", Some((_window_id, wid))) => {
634 let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
635 vec![OutgoingEvent::input(wid, text.to_string())]
636 }
637 ("submit", Some((_window_id, wid))) => {
638 let value = payload.get("value").and_then(|v| v.as_str()).unwrap_or("");
639 vec![OutgoingEvent::submit(wid, value.to_string())]
640 }
641 ("toggle", Some((_window_id, wid))) => {
642 let value = payload
643 .get("value")
644 .and_then(|v| v.as_bool())
645 .unwrap_or(false);
646 vec![OutgoingEvent::toggle(wid, value)]
647 }
648 ("select", Some((_window_id, wid))) => {
649 let value = payload.get("value").and_then(|v| v.as_str()).unwrap_or("");
650 vec![OutgoingEvent::select(wid, value.to_string())]
651 }
652 ("slide", Some((_window_id, wid))) => {
653 let value = payload.get("value").and_then(|v| v.as_f64()).unwrap_or(0.0);
654 vec![OutgoingEvent::slide(wid, value)]
655 }
656 ("press", _) => {
657 let payload_map = payload.as_object();
658 let (key, modifiers) = parse_key_and_modifiers(payload_map);
659 vec![OutgoingEvent::scripting_key_press(key, modifiers)]
660 }
661 ("release", _) => {
662 let payload_map = payload.as_object();
663 let (key, modifiers) = parse_key_and_modifiers(payload_map);
664 vec![OutgoingEvent::scripting_key_release(key, modifiers)]
665 }
666 ("move_to", _) => {
667 let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
668 let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
669 vec![OutgoingEvent::scripting_cursor_moved(x, y)]
670 }
671 ("type_key", _) => {
672 let payload_map = payload.as_object();
673 let (key, modifiers) = parse_key_and_modifiers(payload_map);
674 vec![
675 OutgoingEvent::scripting_key_press(key.clone(), modifiers.clone()),
676 OutgoingEvent::scripting_key_release(key, modifiers),
677 ]
678 }
679 ("paste", Some((_window_id, wid))) => {
680 let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
681 vec![OutgoingEvent::paste(wid, text.to_string())]
682 }
683 ("scroll", Some((_window_id, wid))) => {
685 let delta_x = payload
686 .get("delta_x")
687 .and_then(|v| v.as_f64())
688 .unwrap_or(0.0);
689 let delta_y = payload
690 .get("delta_y")
691 .and_then(|v| v.as_f64())
692 .unwrap_or(0.0);
693 vec![OutgoingEvent::generic(
694 "scroll",
695 wid,
696 Some(serde_json::json!({"delta_x": delta_x, "delta_y": delta_y})),
697 )]
698 }
699 ("scroll", None) => {
701 let delta_x = payload
702 .get("delta_x")
703 .and_then(|v| v.as_f64())
704 .unwrap_or(0.0) as f32;
705 let delta_y = payload
706 .get("delta_y")
707 .and_then(|v| v.as_f64())
708 .unwrap_or(0.0) as f32;
709 vec![OutgoingEvent::scripting_scroll(delta_x, delta_y)]
710 }
711 ("sort", Some((_window_id, wid))) => {
712 let column = payload.get("column").and_then(|v| v.as_str()).unwrap_or("");
713 vec![OutgoingEvent::generic(
714 "sort",
715 wid,
716 Some(serde_json::json!({"column": column})),
717 )]
718 }
719 ("pane_focus_cycle", Some((_window_id, wid))) => {
720 vec![OutgoingEvent::generic("pane_focus_cycle", wid, None)]
721 }
722 ("canvas_press", Some((window_id, wid))) => {
723 let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
724 let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
725 let button = payload
726 .get("button")
727 .and_then(|v| v.as_str())
728 .unwrap_or("left")
729 .to_string();
730 let mods = plushie_widget_sdk::protocol::KeyModifiers::default();
731
732 if let Some(node) = core.tree.root().and_then(|root| {
736 find_tree_node_by_id_with_window(root, &wid, Some(&window_id), None, 0)
737 }) {
738 if let Some(element_id) =
739 plushie_widget_sdk::runtime::canvas::canvas_hit_test(node, x, y)
740 {
741 vec![OutgoingEvent::generic(
742 "click",
743 element_id,
744 Some(serde_json::json!({
745 "x": x, "y": y, "button": button,
746 })),
747 )]
748 } else if plushie_widget_sdk::runtime::canvas::canvas_has_on_press(node) {
749 vec![OutgoingEvent::pointer_press(
750 wid, x, y, &button, "mouse", None, mods,
751 )]
752 } else {
753 vec![]
754 }
755 } else {
756 vec![]
757 }
758 }
759 ("canvas_release", Some((window_id, wid))) => {
760 let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
761 let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
762 let button = payload
763 .get("button")
764 .and_then(|v| v.as_str())
765 .unwrap_or("left")
766 .to_string();
767 let mods = plushie_widget_sdk::protocol::KeyModifiers::default();
768
769 if let Some(node) = core.tree.root().and_then(|root| {
770 find_tree_node_by_id_with_window(root, &wid, Some(&window_id), None, 0)
771 }) {
772 if plushie_widget_sdk::runtime::canvas::canvas_has_on_press(node) {
773 vec![OutgoingEvent::pointer_release(
774 wid, x, y, &button, "mouse", None, mods,
775 )]
776 } else {
777 vec![]
778 }
779 } else {
780 vec![]
781 }
782 }
783 ("canvas_move", Some((window_id, wid))) => {
784 let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
785 let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
786 let mods = plushie_widget_sdk::protocol::KeyModifiers::default();
787
788 if let Some(node) = core.tree.root().and_then(|root| {
789 find_tree_node_by_id_with_window(root, &wid, Some(&window_id), None, 0)
790 }) {
791 let mut events = Vec::new();
793 if let Some(element_id) =
794 plushie_widget_sdk::runtime::canvas::canvas_hit_test(node, x, y)
795 {
796 events.push(OutgoingEvent::generic(
797 "enter",
798 element_id,
799 Some(serde_json::json!({"x": x, "y": y})),
800 ));
801 }
802 events.push(OutgoingEvent::pointer_move(wid, x, y, "mouse", None, mods));
803 events
804 } else {
805 vec![]
806 }
807 }
808 ("click", None) => {
813 let raw_id = parse_selector(&selector).and_then(|sel| match sel {
814 Selector::Id { widget_id, .. } => Some(widget_id),
815 _ => None,
816 });
817
818 if let Some(scoped_id) = raw_id.filter(|id| id.contains('/')) {
819 let mut found_canvas = None;
821 let mut remaining = scoped_id.as_str();
822 while let Some(slash) = remaining.rfind('/') {
823 let prefix = &scoped_id[..slash];
824 let element_local = &scoped_id[slash + 1..];
825 if let Some(window_id) = core
826 .tree
827 .root()
828 .and_then(|root| find_window_id_for_node(root, prefix, None))
829 && let Some(node) = core.tree.root().and_then(|root| {
830 find_tree_node_by_id_with_window(
831 root,
832 prefix,
833 Some(&window_id),
834 None,
835 0,
836 )
837 })
838 {
839 found_canvas = Some((node, window_id, prefix, element_local));
840 break;
841 }
842 remaining = &scoped_id[..slash];
843 }
844
845 if let Some((node, _window_id, canvas_id, element_local)) = found_canvas {
846 if plushie_widget_sdk::runtime::canvas::canvas_find_element_by_id(
847 node,
848 element_local,
849 ) {
850 vec![OutgoingEvent::generic(
851 "click",
852 scoped_id,
853 Some(serde_json::json!({
854 "x": 0.0, "y": 0.0, "button": "left",
855 })),
856 )]
857 } else {
858 log::warn!(
859 "canvas element '{element_local}' not found in canvas '{canvas_id}'"
860 );
861 vec![]
862 }
863 } else {
864 log::warn!("click action: no canvas node found for '{scoped_id}'");
865 vec![]
866 }
867 } else {
868 log::warn!("click action: widget not found");
869 vec![]
870 }
871 }
872 (action, _) if plushie_core::InteractAction::from_wire(action).is_none() => {
875 log::warn!("interact: unknown action '{action}'");
876 vec![OutgoingEvent::diagnostic(
877 String::new(),
878 None,
879 "warning",
880 "unknown_action",
881 &format!("unknown interact action: {action}"),
882 )]
883 }
884 (action, None) => {
886 log::warn!("interact: widget not found for action '{action}'");
887 vec![OutgoingEvent::diagnostic(
888 String::new(),
889 None,
890 "warning",
891 "selector_not_found",
892 &format!("interact {action}: no widget matched the selector"),
893 )]
894 }
895 (action, _) => {
898 log::warn!("interact: unhandled action '{action}'");
899 vec![]
900 }
901 };
902
903 InteractResponse::new(id, events)
904}
905
906fn resolve_widget_target(core: &Core, selector: &Value) -> Option<(String, String)> {
907 let parsed = parse_selector(selector)?;
908 let widget_id = resolve_widget_id(core, selector)?;
909 let requested_window = match parsed {
910 Selector::Id { window_id, .. } => window_id,
911 _ => None,
912 };
913 let window_id = find_window_id_for_node(core.tree.root()?, &widget_id, None)?;
914 if let Some(expected) = requested_window
915 && expected != window_id
916 {
917 return None;
918 }
919 Some((window_id, widget_id))
920}
921
922fn find_tree_node_by_id_with_window<'a>(
923 node: &'a plushie_widget_sdk::protocol::TreeNode,
924 target_id: &str,
925 target_window_id: Option<&str>,
926 current_window_id: Option<&'a str>,
927 depth: usize,
928) -> Option<&'a plushie_widget_sdk::protocol::TreeNode> {
929 if depth > MAX_SELECTOR_SEARCH_DEPTH {
930 return None;
931 }
932
933 let current_window_id = if node.type_name == "window" {
934 Some(node.id.as_str())
935 } else {
936 current_window_id
937 };
938
939 if node.id == target_id
940 && target_window_id.is_none_or(|window_id| current_window_id == Some(window_id))
941 {
942 return Some(node);
943 }
944
945 node.children.iter().find_map(|child| {
946 find_tree_node_by_id_with_window(
947 child,
948 target_id,
949 target_window_id,
950 current_window_id,
951 depth + 1,
952 )
953 })
954}
955
956fn find_window_id_for_node(
957 node: &plushie_widget_sdk::protocol::TreeNode,
958 target_id: &str,
959 current_window_id: Option<&str>,
960) -> Option<String> {
961 let current_window_id = if node.type_name == "window" {
962 Some(node.id.as_str())
963 } else {
964 current_window_id
965 };
966
967 if node.id == target_id {
968 return current_window_id.map(str::to_string);
969 }
970
971 node.children
972 .iter()
973 .find_map(|child| find_window_id_for_node(child, target_id, current_window_id))
974}
975
976pub fn handle_interact(
978 emitter: &crate::emitter::EventEmitter,
979 codec: &Codec,
980 core: &Core,
981 id: String,
982 action: String,
983 selector: Value,
984 payload: Value,
985) -> io::Result<()> {
986 emit_wire(
987 emitter,
988 codec,
989 &build_interact_response(core, id, action, selector, payload),
990 )
991}
992
993pub fn build_reset_response(core: &mut Core, id: String) -> ResetResponse {
995 *core = Core::new();
996 ResetResponse::ok(id)
997}
998
999pub fn handle_reset(
1001 emitter: &crate::emitter::EventEmitter,
1002 codec: &Codec,
1003 core: &mut Core,
1004 id: String,
1005) -> io::Result<()> {
1006 emit_wire(emitter, codec, &build_reset_response(core, id))
1007}
1008
1009pub fn build_tree_hash_response(core: &Core, id: String, name: String) -> TreeHashResponse {
1011 let hash = match plushie_widget_sdk::protocol::canonical_tree_hash(core.tree.root()) {
1012 Ok(hash) => hash,
1013 Err(e) => {
1014 log::error!("tree_hash: serialization failed: {e}");
1015 "SERIALIZATION_ERROR".to_string()
1016 }
1017 };
1018
1019 TreeHashResponse::new(id, name, hash)
1020}
1021
1022pub fn handle_tree_hash(
1024 emitter: &crate::emitter::EventEmitter,
1025 codec: &Codec,
1026 core: &Core,
1027 id: String,
1028 name: String,
1029) -> io::Result<()> {
1030 emit_wire(emitter, codec, &build_tree_hash_response(core, id, name))
1031}
1032
1033#[cfg(test)]
1038#[allow(unused_must_use)]
1039mod tests {
1040 use super::*;
1041 use plushie_widget_sdk::testing::{node as make_node, node_with_props};
1042 use serde_json::json;
1043
1044 fn test_emitter() -> crate::emitter::EventEmitter {
1046 use std::sync::Arc;
1047
1048 use parking_lot::Mutex;
1049 struct NullSink;
1050 impl crate::emitters::EventSink for NullSink {
1051 fn emit_event(
1052 &mut self,
1053 _: plushie_widget_sdk::protocol::OutgoingEvent,
1054 ) -> std::io::Result<()> {
1055 Ok(())
1056 }
1057 fn emit_effect_response(
1058 &mut self,
1059 _: plushie_widget_sdk::protocol::EffectResponse,
1060 ) -> std::io::Result<()> {
1061 Ok(())
1062 }
1063 fn emit_query_response(
1064 &mut self,
1065 _: &str,
1066 _: &str,
1067 _: &serde_json::Value,
1068 ) -> std::io::Result<()> {
1069 Ok(())
1070 }
1071 fn emit_screenshot_response(
1072 &mut self,
1073 _: &str,
1074 _: &str,
1075 _: &str,
1076 _: u32,
1077 _: u32,
1078 _: &[u8],
1079 ) -> std::io::Result<()> {
1080 Ok(())
1081 }
1082 fn emit_hello(
1083 &mut self,
1084 _: &str,
1085 _: &str,
1086 _: &[&str],
1087 _: &[&str],
1088 _: &str,
1089 ) -> std::io::Result<()> {
1090 Ok(())
1091 }
1092 fn emit_diagnostic(
1093 &mut self,
1094 _: plushie_widget_sdk::protocol::DiagnosticMessage,
1095 ) -> std::io::Result<()> {
1096 Ok(())
1097 }
1098 fn write_raw(&mut self, _: &[u8]) -> std::io::Result<()> {
1099 Ok(())
1100 }
1101 }
1102 let sink: Arc<Mutex<Box<dyn crate::emitters::EventSink>>> =
1103 Arc::new(Mutex::new(Box::new(NullSink)));
1104 crate::emitter::EventEmitter::new(sink)
1105 }
1106
1107 fn make_text_node(id: &str, content: &str) -> plushie_widget_sdk::protocol::TreeNode {
1108 node_with_props(id, "text", json!({"content": content}))
1109 }
1110
1111 fn text_node_at_depth(depth: usize, content: &str) -> plushie_widget_sdk::protocol::TreeNode {
1112 let mut target = make_text_node("target", content);
1113
1114 for level in (0..depth).rev() {
1115 target = plushie_widget_sdk::testing::node_with_children(
1116 &format!("level-{level}"),
1117 "column",
1118 vec![target],
1119 );
1120 }
1121
1122 target
1123 }
1124
1125 #[test]
1128 fn parse_selector_by_id() {
1129 let sel = json!({"by": "id", "value": "btn-1"});
1130 match parse_selector(&sel) {
1131 Some(Selector::Id {
1132 widget_id,
1133 window_id,
1134 }) => {
1135 assert_eq!(widget_id, "btn-1");
1136 assert_eq!(window_id, None);
1137 }
1138 other => panic!("expected Id, got {other:?}"),
1139 }
1140 }
1141
1142 #[test]
1143 fn parse_selector_by_id_with_window() {
1144 let sel = json!({"by": "id", "value": "form/save", "window_id": "prefs"});
1145 match parse_selector(&sel) {
1146 Some(Selector::Id {
1147 widget_id,
1148 window_id,
1149 }) => {
1150 assert_eq!(widget_id, "form/save");
1151 assert_eq!(window_id.as_deref(), Some("prefs"));
1152 }
1153 other => panic!("expected Id, got {other:?}"),
1154 }
1155 }
1156
1157 #[test]
1158 fn parse_selector_by_text() {
1159 let sel = json!({"by": "text", "value": "Click me"});
1160 match parse_selector(&sel) {
1161 Some(Selector::Text(t)) => assert_eq!(t, "Click me"),
1162 other => panic!("expected Text, got {other:?}"),
1163 }
1164 }
1165
1166 #[test]
1167 fn parse_selector_unknown_by() {
1168 let sel = json!({"by": "css", "value": ".foo"});
1169 assert!(parse_selector(&sel).is_none());
1170 }
1171
1172 #[test]
1173 fn parse_selector_missing_fields() {
1174 assert!(parse_selector(&json!({})).is_none());
1175 assert!(parse_selector(&json!({"by": "id"})).is_none());
1176 assert!(parse_selector(&Value::Null).is_none());
1177 }
1178
1179 #[test]
1180 fn search_tree_uses_core_selector_depth_limit() {
1181 let at_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH, "needle");
1182 assert!(
1183 search_tree(&at_limit, 0, &|n| matches_text(n, "needle"), &node_to_value).is_some()
1184 );
1185 assert!(
1186 find_tree_node_by_id_with_window(&at_limit, "target", None, None, 0).is_some(),
1187 "production ID lookup should include nodes at the shared depth limit"
1188 );
1189
1190 let past_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH + 1, "needle");
1191 assert!(
1192 search_tree(
1193 &past_limit,
1194 0,
1195 &|n| matches_text(n, "needle"),
1196 &node_to_value
1197 )
1198 .is_none()
1199 );
1200 assert!(
1201 find_tree_node_by_id_with_window(&past_limit, "target", None, None, 0).is_none(),
1202 "production ID lookup should stop after the shared depth limit"
1203 );
1204 assert!(Selector::text("needle").find(&past_limit).is_none());
1205 }
1206
1207 #[test]
1210 fn parse_key_plain() {
1211 let (key, mods) = parse_key_and_modifiers(None);
1212 assert_eq!(key, "");
1213 assert_eq!(mods["ctrl"], false);
1214 }
1215
1216 #[test]
1217 fn parse_key_combined_string() {
1218 let map: serde_json::Map<String, Value> =
1219 serde_json::from_value(json!({"key": "ctrl+shift+s"})).unwrap();
1220 let (key, mods) = parse_key_and_modifiers(Some(&map));
1221 assert_eq!(key, "s");
1222 assert_eq!(mods["ctrl"], true);
1223 assert_eq!(mods["shift"], true);
1224 assert_eq!(mods["alt"], false);
1225 }
1226
1227 #[test]
1228 fn parse_key_unknown_modifier_falls_back_to_literal_key() {
1229 let map: serde_json::Map<String, Value> =
1230 serde_json::from_value(json!({"key": "crtl+s"})).unwrap();
1231 let (key, mods) = parse_key_and_modifiers(Some(&map));
1232 assert_eq!(key, "crtl+s");
1233 assert_eq!(mods["ctrl"], false);
1234 assert_eq!(mods["shift"], false);
1235 }
1236
1237 #[test]
1238 fn parse_key_unknown_combo_modifier_falls_back_to_literal_key() {
1239 let map: serde_json::Map<String, Value> =
1240 serde_json::from_value(json!({"combo": "crtl+s"})).unwrap();
1241 let (key, mods) = parse_key_and_modifiers(Some(&map));
1242 assert_eq!(key, "crtl+s");
1243 assert_eq!(mods["ctrl"], false);
1244 assert_eq!(mods["shift"], false);
1245 }
1246
1247 #[test]
1248 fn parse_key_explicit_modifiers() {
1249 let map: serde_json::Map<String, Value> =
1250 serde_json::from_value(json!({"key": "a", "modifiers": {"alt": true}})).unwrap();
1251 let (key, mods) = parse_key_and_modifiers(Some(&map));
1252 assert_eq!(key, "a");
1253 assert_eq!(mods["alt"], true);
1254 assert_eq!(mods["ctrl"], false);
1255 }
1256
1257 #[test]
1258 fn parse_key_logo_aliases() {
1259 for alias in &["logo", "super", "meta"] {
1260 let combo = format!("{alias}+x");
1261 let map: serde_json::Map<String, Value> =
1262 serde_json::from_value(json!({"key": combo})).unwrap();
1263 let (key, mods) = parse_key_and_modifiers(Some(&map));
1264 assert_eq!(key, "x");
1265 assert_eq!(mods["logo"], true, "alias '{alias}' should set logo=true");
1266 }
1267 }
1268
1269 #[test]
1272 fn search_by_id_finds_root() {
1273 let root = make_node("root", "column");
1274 let result = search_tree(&root, 0, &|n| n.id == "root", &node_to_value);
1275 assert!(result.is_some());
1276 assert_eq!(result.unwrap()["id"], "root");
1277 }
1278
1279 #[test]
1280 fn search_by_id_finds_child() {
1281 let mut root = make_node("root", "column");
1282 root.children.push(make_node("child", "button"));
1283 let result = search_tree(&root, 0, &|n| n.id == "child", &node_to_value);
1284 assert!(result.is_some());
1285 assert_eq!(result.unwrap()["id"], "child");
1286 }
1287
1288 #[test]
1289 fn search_by_id_not_found() {
1290 let root = make_node("root", "column");
1291 assert!(search_tree(&root, 0, &|n| n.id == "missing", &node_to_value).is_none());
1292 }
1293
1294 #[test]
1295 fn search_by_text_finds_node() {
1296 let mut root = make_node("root", "column");
1297 root.children.push(make_text_node("lbl", "Hello World"));
1298 let result = search_tree(
1299 &root,
1300 0,
1301 &|n| matches_text(n, "Hello World"),
1302 &node_to_value,
1303 );
1304 assert!(result.is_some());
1305 assert_eq!(result.unwrap()["id"], "lbl");
1306 }
1307
1308 #[test]
1309 fn search_by_text_not_found() {
1310 let root = make_text_node("lbl", "Hello");
1311 assert!(search_tree(&root, 0, &|n| matches_text(n, "Goodbye"), &node_to_value).is_none());
1312 }
1313
1314 #[test]
1315 fn find_id_by_text_returns_id() {
1316 let mut root = make_node("root", "column");
1317 root.children.push(make_text_node("btn", "Submit"));
1318 assert_eq!(find_id_by_text(&root, "Submit", 0), Some("btn".to_string()));
1319 }
1320
1321 #[test]
1322 fn find_id_by_text_not_found() {
1323 let root = make_node("root", "column");
1324 assert_eq!(find_id_by_text(&root, "nope", 0), None);
1325 }
1326
1327 #[test]
1330 fn find_node_by_id_empty_tree() {
1331 let core: Core = Core::new();
1332 assert_eq!(find_node_by_id(&core, "anything", None), Value::Null);
1333 }
1334
1335 #[test]
1336 fn find_node_by_text_empty_tree() {
1337 let core: Core = Core::new();
1338 assert_eq!(find_node_by_text(&core, "anything"), Value::Null);
1339 }
1340
1341 fn core_with_tree() -> Core {
1350 let mut core: Core = Core::new();
1351 let mut root = make_node("root", "column");
1352 root.children.push(make_text_node("btn1", "Click me"));
1353 root.children.push({
1354 let mut n = make_node("input1", "text_input");
1355 n.props = plushie_widget_sdk::protocol::Props::from_json(
1356 json!({"placeholder": "Type here", "value": ""}),
1357 );
1358 n
1359 });
1360 root.children.push({
1361 let mut n = make_node("toggle1", "toggler");
1362 n.props = plushie_widget_sdk::protocol::Props::from_json(json!({"is_toggled": false}));
1363 n
1364 });
1365 root.children.push({
1366 let mut n = make_node("slider1", "slider");
1367 n.props = plushie_widget_sdk::protocol::Props::from_json(
1368 json!({"min": 0.0, "max": 100.0, "value": 50.0}),
1369 );
1370 n
1371 });
1372 core.apply(plushie_widget_sdk::protocol::IncomingMessage::Snapshot { tree: root });
1373 core
1374 }
1375
1376 #[test]
1377 fn build_tree_hash_response_matches_core_tree_hash() {
1378 let core = core_with_tree();
1379 let response = build_tree_hash_response(&core, "q1".to_string(), "main".to_string());
1380 assert_eq!(response.hash, core.tree_hash());
1381 }
1382
1383 #[test]
1384 fn build_tree_hash_response_empty_tree_uses_empty_string() {
1385 let core = Core::new();
1386 let response = build_tree_hash_response(&core, "q1".to_string(), "main".to_string());
1387 assert_eq!(response.hash, "");
1388 }
1389
1390 #[test]
1391 fn handle_interact_click_does_not_panic() {
1392 let core = core_with_tree();
1393 handle_interact(
1394 &test_emitter(),
1395 &Codec::MsgPack,
1396 &core,
1397 "i1".to_string(),
1398 "click".to_string(),
1399 json!({"by": "id", "value": "btn1"}),
1400 json!({}),
1401 );
1402 }
1403
1404 #[test]
1405 fn handle_interact_type_text_does_not_panic() {
1406 let core = core_with_tree();
1407 handle_interact(
1408 &test_emitter(),
1409 &Codec::MsgPack,
1410 &core,
1411 "i2".to_string(),
1412 "type_text".to_string(),
1413 json!({"by": "id", "value": "input1"}),
1414 json!({"text": "hello"}),
1415 );
1416 }
1417
1418 #[test]
1419 fn handle_interact_submit_does_not_panic() {
1420 let core = core_with_tree();
1421 handle_interact(
1422 &test_emitter(),
1423 &Codec::MsgPack,
1424 &core,
1425 "i3".to_string(),
1426 "submit".to_string(),
1427 json!({"by": "id", "value": "input1"}),
1428 json!({"value": "submitted"}),
1429 );
1430 }
1431
1432 #[test]
1433 fn handle_interact_toggle_does_not_panic() {
1434 let core = core_with_tree();
1435 handle_interact(
1436 &test_emitter(),
1437 &Codec::MsgPack,
1438 &core,
1439 "i4".to_string(),
1440 "toggle".to_string(),
1441 json!({"by": "id", "value": "toggle1"}),
1442 json!({"value": true}),
1443 );
1444 }
1445
1446 #[test]
1447 fn handle_interact_select_does_not_panic() {
1448 let core = core_with_tree();
1449 handle_interact(
1450 &test_emitter(),
1451 &Codec::MsgPack,
1452 &core,
1453 "i5".to_string(),
1454 "select".to_string(),
1455 json!({"by": "id", "value": "btn1"}),
1456 json!({"value": "option_a"}),
1457 );
1458 }
1459
1460 #[test]
1461 fn handle_interact_slide_does_not_panic() {
1462 let core = core_with_tree();
1463 handle_interact(
1464 &test_emitter(),
1465 &Codec::MsgPack,
1466 &core,
1467 "i6".to_string(),
1468 "slide".to_string(),
1469 json!({"by": "id", "value": "slider1"}),
1470 json!({"value": 75.0}),
1471 );
1472 }
1473
1474 #[test]
1475 fn handle_interact_press_does_not_panic() {
1476 let core = core_with_tree();
1477 handle_interact(
1478 &test_emitter(),
1479 &Codec::MsgPack,
1480 &core,
1481 "i7".to_string(),
1482 "press".to_string(),
1483 json!({}),
1484 json!({"key": "ctrl+s"}),
1485 );
1486 }
1487
1488 #[test]
1489 fn handle_interact_release_does_not_panic() {
1490 let core = core_with_tree();
1491 handle_interact(
1492 &test_emitter(),
1493 &Codec::MsgPack,
1494 &core,
1495 "i8".to_string(),
1496 "release".to_string(),
1497 json!({}),
1498 json!({"key": "a"}),
1499 );
1500 }
1501
1502 #[test]
1503 fn handle_interact_move_to_does_not_panic() {
1504 let core = core_with_tree();
1505 handle_interact(
1506 &test_emitter(),
1507 &Codec::MsgPack,
1508 &core,
1509 "i9".to_string(),
1510 "move_to".to_string(),
1511 json!({}),
1512 json!({"x": 100.0, "y": 200.0}),
1513 );
1514 }
1515
1516 #[test]
1517 fn handle_interact_type_key_does_not_panic() {
1518 let core = core_with_tree();
1519 handle_interact(
1520 &test_emitter(),
1521 &Codec::MsgPack,
1522 &core,
1523 "i10".to_string(),
1524 "type_key".to_string(),
1525 json!({}),
1526 json!({"key": "enter"}),
1527 );
1528 }
1529
1530 #[test]
1531 fn handle_interact_unknown_action_does_not_panic() {
1532 let core = core_with_tree();
1533 handle_interact(
1534 &test_emitter(),
1535 &Codec::MsgPack,
1536 &core,
1537 "i11".to_string(),
1538 "nonexistent_action".to_string(),
1539 json!({"by": "id", "value": "btn1"}),
1540 json!({}),
1541 );
1542 }
1543
1544 #[test]
1545 fn handle_interact_selector_not_found_does_not_panic() {
1546 let core = core_with_tree();
1547 handle_interact(
1548 &test_emitter(),
1549 &Codec::MsgPack,
1550 &core,
1551 "i12".to_string(),
1552 "click".to_string(),
1553 json!({"by": "id", "value": "no_such_widget"}),
1554 json!({}),
1555 );
1556 }
1557
1558 #[test]
1559 fn handle_interact_by_text_selector() {
1560 let core = core_with_tree();
1561 handle_interact(
1562 &test_emitter(),
1563 &Codec::MsgPack,
1564 &core,
1565 "i13".to_string(),
1566 "click".to_string(),
1567 json!({"by": "text", "value": "Click me"}),
1568 json!({}),
1569 );
1570 }
1571
1572 #[test]
1575 fn parse_selector_by_role() {
1576 let sel = json!({"by": "role", "value": "button"});
1577 match parse_selector(&sel) {
1578 Some(Selector::Role(r)) => assert_eq!(r, "button"),
1579 other => panic!("expected Role, got {other:?}"),
1580 }
1581 }
1582
1583 #[test]
1584 fn parse_selector_by_label() {
1585 let sel = json!({"by": "label", "value": "Submit"});
1586 match parse_selector(&sel) {
1587 Some(Selector::Label(l)) => assert_eq!(l, "Submit"),
1588 other => panic!("expected Label, got {other:?}"),
1589 }
1590 }
1591
1592 #[test]
1593 fn parse_selector_focused() {
1594 let sel = json!({"by": "focused"});
1595 match parse_selector(&sel) {
1596 Some(Selector::Focused) => {}
1597 other => panic!("expected Focused, got {other:?}"),
1598 }
1599 }
1600
1601 #[test]
1602 fn parse_selector_focused_ignores_value() {
1603 let sel = json!({"by": "focused", "value": "ignored"});
1605 match parse_selector(&sel) {
1606 Some(Selector::Focused) => {}
1607 other => panic!("expected Focused, got {other:?}"),
1608 }
1609 }
1610
1611 fn make_a11y_node(id: &str, type_name: &str, a11y: Value) -> TreeNode {
1614 TreeNode {
1615 id: id.to_string(),
1616 type_name: type_name.to_string(),
1617 props: plushie_widget_sdk::protocol::Props::from_json(json!({"a11y": a11y})),
1618 children: vec![],
1619 }
1620 }
1621
1622 #[test]
1623 fn search_by_role_matches_a11y_prop() {
1624 let node = make_a11y_node("btn", "container", json!({"role": "button"}));
1625 let result = search_tree(&node, 0, &|n| matches_role(n, "button"), &node_to_value);
1626 assert!(result.is_some());
1627 assert_eq!(result.unwrap()["id"], "btn");
1628 }
1629
1630 #[test]
1631 fn search_by_role_matches_type_name() {
1632 let node = make_node("btn", "button");
1633 let result = search_tree(&node, 0, &|n| matches_role(n, "button"), &node_to_value);
1634 assert!(result.is_some());
1635 assert_eq!(result.unwrap()["id"], "btn");
1636 }
1637
1638 #[test]
1639 fn search_by_role_prefers_a11y_over_type() {
1640 let node = make_a11y_node("h1", "container", json!({"role": "heading"}));
1642 assert!(search_tree(&node, 0, &|n| matches_role(n, "heading"), &node_to_value).is_some());
1643 assert!(search_tree(&node, 0, &|n| matches_role(n, "container"), &node_to_value).is_none());
1644 }
1645
1646 #[test]
1647 fn search_by_role_finds_in_children() {
1648 let mut root = make_node("root", "column");
1649 root.children.push(make_a11y_node(
1650 "slider",
1651 "slider",
1652 json!({"role": "slider"}),
1653 ));
1654 let result = search_tree(&root, 0, &|n| matches_role(n, "slider"), &node_to_value);
1655 assert!(result.is_some());
1656 assert_eq!(result.unwrap()["id"], "slider");
1657 }
1658
1659 #[test]
1660 fn search_by_role_not_found() {
1661 let node = make_node("root", "column");
1662 assert!(search_tree(&node, 0, &|n| matches_role(n, "button"), &node_to_value).is_none());
1663 }
1664
1665 #[test]
1668 fn search_by_label_matches_a11y_label() {
1669 let node = make_a11y_node("btn", "button", json!({"label": "Submit"}));
1670 let result = search_tree(&node, 0, &|n| matches_label(n, "Submit"), &node_to_value);
1671 assert!(result.is_some());
1672 assert_eq!(result.unwrap()["id"], "btn");
1673 }
1674
1675 #[test]
1676 fn search_by_label_matches_label_prop() {
1677 let mut node = make_node("chk", "checkbox");
1678 node.props =
1679 plushie_widget_sdk::protocol::Props::from_json(json!({"label": "Accept terms"}));
1680 let result = search_tree(
1681 &node,
1682 0,
1683 &|n| matches_label(n, "Accept terms"),
1684 &node_to_value,
1685 );
1686 assert!(result.is_some());
1687 assert_eq!(result.unwrap()["id"], "chk");
1688 }
1689
1690 #[test]
1691 fn search_by_label_matches_content_prop() {
1692 let node = make_text_node("txt", "Hello World");
1693 let result = search_tree(
1694 &node,
1695 0,
1696 &|n| matches_label(n, "Hello World"),
1697 &node_to_value,
1698 );
1699 assert!(result.is_some());
1700 assert_eq!(result.unwrap()["id"], "txt");
1701 }
1702
1703 #[test]
1704 fn search_by_label_not_found() {
1705 let node = make_node("root", "column");
1706 assert!(search_tree(&node, 0, &|n| matches_label(n, "Missing"), &node_to_value).is_none());
1707 }
1708
1709 #[test]
1712 fn search_focused_matches_focused_prop() {
1713 let mut node = make_node("inp", "text_input");
1714 node.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": true}));
1715 let result = search_tree(&node, 0, &is_focused, &node_to_value);
1716 assert!(result.is_some());
1717 assert_eq!(result.unwrap()["id"], "inp");
1718 }
1719
1720 #[test]
1721 fn search_focused_matches_a11y_focused() {
1722 let node = make_a11y_node("inp", "text_input", json!({"focused": true}));
1723 let result = search_tree(&node, 0, &is_focused, &node_to_value);
1724 assert!(result.is_some());
1725 assert_eq!(result.unwrap()["id"], "inp");
1726 }
1727
1728 #[test]
1729 fn search_focused_skips_unfocused() {
1730 let mut node = make_node("inp", "text_input");
1731 node.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": false}));
1732 assert!(search_tree(&node, 0, &is_focused, &node_to_value).is_none());
1733 }
1734
1735 #[test]
1736 fn search_focused_finds_in_children() {
1737 let mut root = make_node("root", "column");
1738 let mut child = make_node("inp", "text_input");
1739 child.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": true}));
1740 root.children.push(child);
1741 let result = search_tree(&root, 0, &is_focused, &node_to_value);
1742 assert!(result.is_some());
1743 assert_eq!(result.unwrap()["id"], "inp");
1744 }
1745
1746 #[test]
1747 fn search_focused_not_found() {
1748 let root = make_node("root", "column");
1749 assert!(search_tree(&root, 0, &is_focused, &node_to_value).is_none());
1750 }
1751
1752 #[test]
1755 fn find_id_by_role_returns_id() {
1756 let mut root = make_node("root", "column");
1757 root.children.push(make_node("btn", "button"));
1758 assert_eq!(find_id_by_role(&root, "button", 0), Some("btn".to_string()));
1759 }
1760
1761 #[test]
1762 fn find_id_by_label_returns_id() {
1763 let mut root = make_node("root", "column");
1764 root.children
1765 .push(make_a11y_node("btn", "button", json!({"label": "Submit"})));
1766 assert_eq!(
1767 find_id_by_label(&root, "Submit", 0),
1768 Some("btn".to_string())
1769 );
1770 }
1771
1772 #[test]
1773 fn find_id_focused_returns_id() {
1774 let mut root = make_node("root", "column");
1775 let mut child = make_node("inp", "text_input");
1776 child.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": true}));
1777 root.children.push(child);
1778 assert_eq!(find_id_focused(&root, 0), Some("inp".to_string()));
1779 }
1780
1781 #[test]
1784 fn handle_interact_by_role_does_not_panic() {
1785 let core = core_with_tree();
1786 handle_interact(
1787 &test_emitter(),
1788 &Codec::MsgPack,
1789 &core,
1790 "i14".to_string(),
1791 "click".to_string(),
1792 json!({"by": "role", "value": "text_input"}),
1793 json!({}),
1794 );
1795 }
1796
1797 #[test]
1798 fn handle_interact_by_label_does_not_panic() {
1799 let core = core_with_tree();
1800 handle_interact(
1801 &test_emitter(),
1802 &Codec::MsgPack,
1803 &core,
1804 "i15".to_string(),
1805 "click".to_string(),
1806 json!({"by": "label", "value": "Click me"}),
1807 json!({}),
1808 );
1809 }
1810
1811 #[test]
1812 fn handle_interact_paste_does_not_panic() {
1813 let core = core_with_tree();
1814 handle_interact(
1815 &test_emitter(),
1816 &Codec::MsgPack,
1817 &core,
1818 "i17".to_string(),
1819 "paste".to_string(),
1820 json!({"by": "id", "value": "input1"}),
1821 json!({"text": "pasted text"}),
1822 );
1823 }
1824
1825 #[test]
1826 fn handle_interact_scroll_does_not_panic() {
1827 let core = core_with_tree();
1828 handle_interact(
1829 &test_emitter(),
1830 &Codec::MsgPack,
1831 &core,
1832 "i18".to_string(),
1833 "scroll".to_string(),
1834 json!({}),
1835 json!({"delta_x": 0.0, "delta_y": -10.0}),
1836 );
1837 }
1838
1839 #[test]
1840 fn handle_interact_sort_does_not_panic() {
1841 let core = core_with_tree();
1842 handle_interact(
1843 &test_emitter(),
1844 &Codec::MsgPack,
1845 &core,
1846 "i19".to_string(),
1847 "sort".to_string(),
1848 json!({"by": "id", "value": "btn1"}),
1849 json!({"column": "name"}),
1850 );
1851 }
1852
1853 #[test]
1854 fn handle_interact_pane_focus_cycle_does_not_panic() {
1855 let core = core_with_tree();
1856 handle_interact(
1857 &test_emitter(),
1858 &Codec::MsgPack,
1859 &core,
1860 "i20".to_string(),
1861 "pane_focus_cycle".to_string(),
1862 json!({"by": "id", "value": "btn1"}),
1863 json!({}),
1864 );
1865 }
1866
1867 #[test]
1868 fn handle_interact_canvas_press_does_not_panic() {
1869 let core = core_with_tree();
1870 handle_interact(
1871 &test_emitter(),
1872 &Codec::MsgPack,
1873 &core,
1874 "i21".to_string(),
1875 "canvas_press".to_string(),
1876 json!({"by": "id", "value": "btn1"}),
1877 json!({"x": 50.0, "y": 75.0}),
1878 );
1879 }
1880
1881 #[test]
1882 fn handle_interact_canvas_release_does_not_panic() {
1883 let core = core_with_tree();
1884 handle_interact(
1885 &test_emitter(),
1886 &Codec::MsgPack,
1887 &core,
1888 "i22".to_string(),
1889 "canvas_release".to_string(),
1890 json!({"by": "id", "value": "btn1"}),
1891 json!({"x": 50.0, "y": 75.0}),
1892 );
1893 }
1894
1895 #[test]
1896 fn handle_interact_canvas_move_does_not_panic() {
1897 let core = core_with_tree();
1898 handle_interact(
1899 &test_emitter(),
1900 &Codec::MsgPack,
1901 &core,
1902 "i23".to_string(),
1903 "canvas_move".to_string(),
1904 json!({"by": "id", "value": "btn1"}),
1905 json!({"x": 60.0, "y": 80.0}),
1906 );
1907 }
1908
1909 #[test]
1910 fn handle_interact_focused_does_not_panic() {
1911 let core = core_with_tree();
1912 handle_interact(
1913 &test_emitter(),
1914 &Codec::MsgPack,
1915 &core,
1916 "i16".to_string(),
1917 "click".to_string(),
1918 json!({"by": "focused"}),
1919 json!({}),
1920 );
1921 }
1922
1923 #[test]
1926 fn parse_iced_key_named_enter() {
1927 assert_eq!(
1928 parse_iced_key("Enter"),
1929 Key::Named(keyboard::key::Named::Enter)
1930 );
1931 assert_eq!(
1932 parse_iced_key("enter"),
1933 Key::Named(keyboard::key::Named::Enter)
1934 );
1935 }
1936
1937 #[test]
1938 fn parse_iced_key_named_tab() {
1939 assert_eq!(parse_iced_key("Tab"), Key::Named(keyboard::key::Named::Tab));
1940 }
1941
1942 #[test]
1943 fn parse_iced_key_named_arrows() {
1944 assert_eq!(
1945 parse_iced_key("ArrowUp"),
1946 Key::Named(keyboard::key::Named::ArrowUp)
1947 );
1948 assert_eq!(
1949 parse_iced_key("Up"),
1950 Key::Named(keyboard::key::Named::ArrowUp)
1951 );
1952 assert_eq!(
1953 parse_iced_key("ArrowDown"),
1954 Key::Named(keyboard::key::Named::ArrowDown)
1955 );
1956 }
1957
1958 #[test]
1959 fn parse_iced_key_single_char() {
1960 assert_eq!(parse_iced_key("a"), Key::Character(SmolStr::new("a")));
1961 assert_eq!(parse_iced_key("Z"), Key::Character(SmolStr::new("Z")));
1962 }
1963
1964 #[test]
1965 fn parse_iced_key_function_keys() {
1966 assert_eq!(parse_iced_key("F1"), Key::Named(keyboard::key::Named::F1));
1967 assert_eq!(parse_iced_key("F12"), Key::Named(keyboard::key::Named::F12));
1968 }
1969
1970 #[test]
1971 fn parse_iced_modifiers_from_json() {
1972 let mods = json!({"shift": true, "ctrl": true, "alt": false, "logo": false});
1973 let result = parse_iced_modifiers(&mods);
1974 assert!(result.shift());
1975 assert!(result.control());
1976 assert!(!result.alt());
1977 assert!(!result.logo());
1978 }
1979
1980 #[test]
1981 fn parse_iced_modifiers_empty() {
1982 let mods = json!({});
1983 let result = parse_iced_modifiers(&mods);
1984 assert!(result.is_empty());
1985 }
1986
1987 #[test]
1988 fn interaction_to_iced_events_click() {
1989 let events = interaction_to_iced_events(
1990 "click",
1991 Some("btn1"),
1992 &json!({}),
1993 mouse::Cursor::Available(Point::new(100.0, 50.0)),
1994 );
1995 assert_eq!(events.len(), 3); }
1997
1998 #[test]
1999 fn interaction_to_iced_events_type_text() {
2000 let events = interaction_to_iced_events(
2001 "type_text",
2002 Some("inp1"),
2003 &json!({"text": "hi"}),
2004 mouse::Cursor::Unavailable,
2005 );
2006 assert_eq!(events.len(), 4);
2008 }
2009
2010 #[test]
2011 fn interaction_to_iced_events_scroll() {
2012 let events = interaction_to_iced_events(
2013 "scroll",
2014 None,
2015 &json!({"delta_x": 0.0, "delta_y": -10.0}),
2016 mouse::Cursor::Unavailable,
2017 );
2018 assert_eq!(events.len(), 1);
2019 match &events[0] {
2020 Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
2021 assert_eq!(*delta, mouse::ScrollDelta::Lines { x: 0.0, y: -10.0 });
2022 }
2023 _ => panic!("expected WheelScrolled"),
2024 }
2025 }
2026
2027 #[test]
2028 fn interaction_to_iced_events_move_to() {
2029 let events = interaction_to_iced_events(
2030 "move_to",
2031 None,
2032 &json!({"x": 42.0, "y": 84.0}),
2033 mouse::Cursor::Unavailable,
2034 );
2035 assert_eq!(events.len(), 1);
2036 match &events[0] {
2037 Event::Mouse(mouse::Event::CursorMoved { position }) => {
2038 assert_eq!(*position, Point::new(42.0, 84.0));
2039 }
2040 _ => panic!("expected CursorMoved"),
2041 }
2042 }
2043
2044 #[test]
2045 fn interaction_to_iced_events_synthetic_only() {
2046 for action in &["paste", "sort", "pane_focus_cycle", "slide"] {
2048 let events = interaction_to_iced_events(
2049 action,
2050 Some("w1"),
2051 &json!({}),
2052 mouse::Cursor::Unavailable,
2053 );
2054 assert!(
2055 events.is_empty(),
2056 "action '{action}' should produce no iced events"
2057 );
2058 }
2059 }
2060
2061 #[test]
2062 fn interaction_to_iced_events_submit() {
2063 let events = interaction_to_iced_events(
2064 "submit",
2065 Some("inp1"),
2066 &json!({}),
2067 mouse::Cursor::Unavailable,
2068 );
2069 assert_eq!(events.len(), 2); }
2071
2072 #[test]
2073 fn interaction_to_iced_events_type_key() {
2074 let events = interaction_to_iced_events(
2075 "type_key",
2076 None,
2077 &json!({"key": "ctrl+s"}),
2078 mouse::Cursor::Unavailable,
2079 );
2080 assert_eq!(events.len(), 2); }
2082
2083 #[test]
2084 fn interaction_to_iced_events_press_release() {
2085 let press = interaction_to_iced_events(
2086 "press",
2087 None,
2088 &json!({"key": "a"}),
2089 mouse::Cursor::Unavailable,
2090 );
2091 assert_eq!(press.len(), 1);
2092
2093 let release = interaction_to_iced_events(
2094 "release",
2095 None,
2096 &json!({"key": "a"}),
2097 mouse::Cursor::Unavailable,
2098 );
2099 assert_eq!(release.len(), 1);
2100 }
2101
2102 #[test]
2105 fn handle_interact_slide_string_value_does_not_panic() {
2106 let core = core_with_tree();
2107 handle_interact(
2108 &test_emitter(),
2109 &Codec::MsgPack,
2110 &core,
2111 "bad1".to_string(),
2112 "slide".to_string(),
2113 json!({"by": "id", "value": "slider1"}),
2114 json!({"value": "not_a_number"}),
2115 );
2116 }
2117
2118 #[test]
2119 fn parse_key_and_modifiers_null_key() {
2120 let map: serde_json::Map<String, Value> =
2121 serde_json::from_value(json!({"key": null})).unwrap();
2122 let (key, mods) = parse_key_and_modifiers(Some(&map));
2123 assert_eq!(key, "");
2124 assert_eq!(mods["ctrl"], false);
2125 }
2126
2127 #[test]
2128 fn parse_selector_null_value() {
2129 let sel = json!({"by": "id", "value": null});
2130 assert!(parse_selector(&sel).is_none());
2131 }
2132}