use std::io;
use iced::keyboard::{self, Key, Modifiers};
use iced::mouse;
use iced::{Event, Point};
use smol_str::SmolStr;
use serde_json::Value;
use plushie_renderer_engine::Codec;
use plushie_renderer_engine::Core;
use plushie_widget_sdk::protocol::{
InteractResponse, OutgoingEvent, QueryResponse, ResetResponse, TreeHashResponse, TreeNode,
};
pub use plushie_core::{MAX_SELECTOR_SEARCH_DEPTH, Selector};
pub fn parse_selector(selector: &Value) -> Option<Selector> {
Selector::from_wire(selector)
}
pub fn parse_key_and_modifiers(
payload: Option<&serde_json::Map<String, Value>>,
) -> (String, Value) {
let payload_value = payload
.map(|m| Value::Object(m.clone()))
.unwrap_or(Value::Null);
if let Some(kp) = plushie_core::KeyPress::from_wire(&payload_value) {
return (kp.key.wire_name(), resolve_modifiers(&kp.modifiers));
}
let raw_key = payload_value
.get("combo")
.and_then(|v| v.as_str())
.or_else(|| payload_value.get("key").and_then(|v| v.as_str()))
.unwrap_or("");
match raw_key.parse::<plushie_core::KeyPress>() {
Ok(kp) => (kp.key.wire_name(), resolve_modifiers(&kp.modifiers)),
Err(err) => {
log::warn!("invalid scripted key combo {raw_key:?}: {err}");
let key = plushie_core::Key::from(raw_key);
(key.wire_name(), resolve_modifiers(&Default::default()))
}
}
}
pub fn parse_iced_key(name: &str) -> Key {
let core_key = plushie_core::Key::from(name);
core_key_to_iced(&core_key)
}
fn resolve_modifiers(mods: &plushie_core::protocol::KeyModifiers) -> Value {
let is_macos = cfg!(target_os = "macos");
serde_json::json!({
"shift": mods.shift,
"ctrl": mods.ctrl || (!is_macos && mods.command),
"alt": mods.alt,
"logo": mods.logo || (is_macos && mods.command),
})
}
fn core_key_to_iced(key: &plushie_core::Key) -> Key {
use plushie_core::Key as CK;
match key {
CK::ArrowUp => Key::Named(keyboard::key::Named::ArrowUp),
CK::ArrowDown => Key::Named(keyboard::key::Named::ArrowDown),
CK::ArrowLeft => Key::Named(keyboard::key::Named::ArrowLeft),
CK::ArrowRight => Key::Named(keyboard::key::Named::ArrowRight),
CK::Home => Key::Named(keyboard::key::Named::Home),
CK::End => Key::Named(keyboard::key::Named::End),
CK::PageUp => Key::Named(keyboard::key::Named::PageUp),
CK::PageDown => Key::Named(keyboard::key::Named::PageDown),
CK::Enter => Key::Named(keyboard::key::Named::Enter),
CK::Tab => Key::Named(keyboard::key::Named::Tab),
CK::Space => Key::Named(keyboard::key::Named::Space),
CK::Backspace => Key::Named(keyboard::key::Named::Backspace),
CK::Delete => Key::Named(keyboard::key::Named::Delete),
CK::Insert => Key::Named(keyboard::key::Named::Insert),
CK::Escape => Key::Named(keyboard::key::Named::Escape),
CK::Shift => Key::Named(keyboard::key::Named::Shift),
CK::Control => Key::Named(keyboard::key::Named::Control),
CK::Alt => Key::Named(keyboard::key::Named::Alt),
CK::Super => Key::Named(keyboard::key::Named::Super),
CK::F1 => Key::Named(keyboard::key::Named::F1),
CK::F2 => Key::Named(keyboard::key::Named::F2),
CK::F3 => Key::Named(keyboard::key::Named::F3),
CK::F4 => Key::Named(keyboard::key::Named::F4),
CK::F5 => Key::Named(keyboard::key::Named::F5),
CK::F6 => Key::Named(keyboard::key::Named::F6),
CK::F7 => Key::Named(keyboard::key::Named::F7),
CK::F8 => Key::Named(keyboard::key::Named::F8),
CK::F9 => Key::Named(keyboard::key::Named::F9),
CK::F10 => Key::Named(keyboard::key::Named::F10),
CK::F11 => Key::Named(keyboard::key::Named::F11),
CK::F12 => Key::Named(keyboard::key::Named::F12),
CK::CapsLock => Key::Named(keyboard::key::Named::CapsLock),
CK::NumLock => Key::Named(keyboard::key::Named::NumLock),
CK::ScrollLock => Key::Named(keyboard::key::Named::ScrollLock),
CK::PrintScreen => Key::Named(keyboard::key::Named::PrintScreen),
CK::Pause => Key::Named(keyboard::key::Named::Pause),
CK::ContextMenu => Key::Named(keyboard::key::Named::ContextMenu),
CK::Copy => Key::Named(keyboard::key::Named::Copy),
CK::Cut => Key::Named(keyboard::key::Named::Cut),
CK::Paste => Key::Named(keyboard::key::Named::Paste),
CK::Undo => Key::Named(keyboard::key::Named::Undo),
CK::Redo => Key::Named(keyboard::key::Named::Redo),
CK::Char(c) => Key::Character(SmolStr::new(c.to_string())),
CK::Named(name) => {
Key::Character(SmolStr::new(name))
}
}
}
pub fn parse_iced_modifiers(mods: &Value) -> Modifiers {
let mut m = Modifiers::empty();
if mods.get("shift").and_then(|v| v.as_bool()).unwrap_or(false) {
m |= Modifiers::SHIFT;
}
if mods.get("ctrl").and_then(|v| v.as_bool()).unwrap_or(false) {
m |= Modifiers::CTRL;
}
if mods.get("alt").and_then(|v| v.as_bool()).unwrap_or(false) {
m |= Modifiers::ALT;
}
if mods.get("logo").and_then(|v| v.as_bool()).unwrap_or(false) {
m |= Modifiers::LOGO;
}
m
}
pub fn make_key_pressed(key: Key, modifiers: Modifiers, text: Option<SmolStr>) -> Event {
Event::Keyboard(keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers,
text,
repeat: false,
})
}
pub fn make_key_released(key: Key, modifiers: Modifiers) -> Event {
Event::Keyboard(keyboard::Event::KeyReleased {
key: key.clone(),
modified_key: key,
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers,
})
}
pub fn interaction_to_iced_events(
action: &str,
_widget_id: Option<&str>,
payload: &Value,
cursor: mouse::Cursor,
) -> Vec<Event> {
match action {
"click" | "toggle" | "select" => {
let pos = match cursor {
mouse::Cursor::Available(p) | mouse::Cursor::Levitating(p) => p,
mouse::Cursor::Unavailable => Point::new(0.0, 0.0),
};
vec![
Event::Mouse(mouse::Event::CursorMoved { position: pos }),
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
]
}
"type_text" => {
let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
text.chars()
.flat_map(|c| {
let s = SmolStr::new(c.to_string());
let key = Key::Character(s.clone());
[
make_key_pressed(key.clone(), Modifiers::empty(), Some(s)),
make_key_released(key, Modifiers::empty()),
]
})
.collect()
}
"type_key" => {
let payload_map = payload.as_object();
let (key_str, mods_json) = parse_key_and_modifiers(payload_map);
let key = parse_iced_key(&key_str);
let modifiers = parse_iced_modifiers(&mods_json);
let text = match &key {
Key::Character(c) if modifiers.is_empty() => Some(c.clone()),
_ => None,
};
vec![
make_key_pressed(key.clone(), modifiers, text),
make_key_released(key, modifiers),
]
}
"press" => {
let payload_map = payload.as_object();
let (key_str, mods_json) = parse_key_and_modifiers(payload_map);
let key = parse_iced_key(&key_str);
let modifiers = parse_iced_modifiers(&mods_json);
let text = match &key {
Key::Character(c) if modifiers.is_empty() => Some(c.clone()),
_ => None,
};
vec![make_key_pressed(key, modifiers, text)]
}
"release" => {
let payload_map = payload.as_object();
let (key_str, mods_json) = parse_key_and_modifiers(payload_map);
let key = parse_iced_key(&key_str);
let modifiers = parse_iced_modifiers(&mods_json);
vec![make_key_released(key, modifiers)]
}
"submit" => {
let key = Key::Named(keyboard::key::Named::Enter);
vec![
make_key_pressed(key.clone(), Modifiers::empty(), None),
make_key_released(key, Modifiers::empty()),
]
}
"scroll" => {
let delta_x = payload
.get("delta_x")
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32;
let delta_y = payload
.get("delta_y")
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32;
vec![Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines {
x: delta_x,
y: delta_y,
},
})]
}
"move_to" => {
let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
vec![Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(x, y),
})]
}
"canvas_press" => {
let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let button = match payload.get("button").and_then(|v| v.as_str()) {
Some("right") => mouse::Button::Right,
Some("middle") => mouse::Button::Middle,
_ => mouse::Button::Left,
};
vec![
Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(x, y),
}),
Event::Mouse(mouse::Event::ButtonPressed(button)),
]
}
"canvas_release" => {
let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let button = match payload.get("button").and_then(|v| v.as_str()) {
Some("right") => mouse::Button::Right,
Some("middle") => mouse::Button::Middle,
_ => mouse::Button::Left,
};
vec![
Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(x, y),
}),
Event::Mouse(mouse::Event::ButtonReleased(button)),
]
}
"canvas_move" => {
let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
vec![Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(x, y),
})]
}
"click_element" => {
let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
vec![
Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(x, y),
}),
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
]
}
"focus_element" => {
vec![Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab),
modified_key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab),
physical_key: iced::keyboard::key::Physical::Code(iced::keyboard::key::Code::Tab),
location: iced::keyboard::Location::Standard,
modifiers: iced::keyboard::Modifiers::default(),
text: None,
repeat: false,
})]
}
"paste" | "sort" | "pane_focus_cycle" | "slide" => vec![],
_ => vec![],
}
}
pub fn emit_wire<T: serde::Serialize>(
emitter: &crate::emitter::EventEmitter,
codec: &Codec,
value: &T,
) -> io::Result<()> {
let bytes = codec.encode(value).map_err(io::Error::other)?;
emitter.write_raw(&bytes)
}
#[cfg(test)]
fn search_tree<R>(
node: &TreeNode,
depth: usize,
predicate: &dyn Fn(&TreeNode) -> bool,
extract: &dyn Fn(&TreeNode) -> R,
) -> Option<R> {
if depth > MAX_SELECTOR_SEARCH_DEPTH {
return None;
}
if predicate(node) {
return Some(extract(node));
}
for child in &node.children {
if let Some(found) = search_tree(child, depth + 1, predicate, extract) {
return Some(found);
}
}
None
}
fn node_to_value(node: &TreeNode) -> Value {
serde_json::to_value(node).unwrap_or(Value::Null)
}
#[cfg(test)]
fn matches_role(node: &TreeNode, role: &str) -> bool {
if let Some(a11y) = node.props.get("a11y") {
a11y.as_object()
.and_then(|m| m.get("role"))
.and_then(|v| v.as_str())
== Some(role)
} else {
node.type_name == role
}
}
#[cfg(test)]
fn matches_label(node: &TreeNode, label: &str) -> bool {
if let Some(a11y) = node.props.get("a11y")
&& let Some(node_label) = a11y
.as_object()
.and_then(|m| m.get("label"))
.and_then(|v| v.as_str())
&& node_label == label
{
return true;
}
for key in &["label", "content"] {
if let Some(val) = node.props.get(key)
&& val.as_str() == Some(label)
{
return true;
}
}
false
}
#[cfg(test)]
fn matches_text(node: &TreeNode, text: &str) -> bool {
for key in &["content", "label", "value", "placeholder"] {
if let Some(val) = node.props.get(key)
&& val.as_str() == Some(text)
{
return true;
}
}
false
}
#[cfg(test)]
fn is_focused(node: &TreeNode) -> bool {
if node.props.get("focused").and_then(|v| v.as_bool()) == Some(true) {
return true;
}
if let Some(a11y) = node.props.get("a11y")
&& a11y
.as_object()
.and_then(|m| m.get("focused"))
.and_then(|v| v.as_bool())
== Some(true)
{
return true;
}
false
}
pub fn find_node_by_id(core: &Core, widget_id: &str, window_id: Option<&str>) -> Value {
let selector = match window_id {
Some(win) => Selector::id_in_window(widget_id, win),
None => Selector::id(widget_id),
};
find_by_selector(core, &selector)
}
pub fn find_node_by_text(core: &Core, text: &str) -> Value {
find_by_selector(core, &Selector::text(text))
}
pub fn find_node_by_role(core: &Core, role: &str) -> Value {
find_by_selector(core, &Selector::role(role))
}
pub fn find_node_by_label(core: &Core, label: &str) -> Value {
find_by_selector(core, &Selector::label(label))
}
pub fn find_focused_node(core: &Core) -> Value {
find_by_selector(core, &Selector::focused())
}
pub fn find_by_selector(core: &Core, selector: &Selector) -> Value {
core.tree
.root()
.and_then(|root| selector.find(root))
.map(node_to_value)
.unwrap_or(Value::Null)
}
pub fn find_id_by_text(node: &TreeNode, text: &str, _depth: usize) -> Option<String> {
Selector::text(text).find(node).map(|n| n.id.clone())
}
pub fn find_id_by_role(node: &TreeNode, role: &str, _depth: usize) -> Option<String> {
Selector::role(role).find(node).map(|n| n.id.clone())
}
pub fn find_id_by_label(node: &TreeNode, label: &str, _depth: usize) -> Option<String> {
Selector::label(label).find(node).map(|n| n.id.clone())
}
pub fn find_id_focused(node: &TreeNode, _depth: usize) -> Option<String> {
Selector::focused().find(node).map(|n| n.id.clone())
}
pub fn build_query_response(
core: &Core,
id: String,
target: String,
selector: Value,
) -> QueryResponse {
let data = match target.as_str() {
"tree" => match core.tree.root() {
Some(root) => serde_json::to_value(root).unwrap_or(Value::Null),
None => Value::Null,
},
"find" => match parse_selector(&selector) {
Some(Selector::Id {
widget_id,
window_id,
}) => find_node_by_id(core, &widget_id, window_id.as_deref()),
Some(Selector::Text(text)) => find_node_by_text(core, &text),
Some(Selector::Role(role)) => find_node_by_role(core, &role),
Some(Selector::Label(label)) => find_node_by_label(core, &label),
Some(Selector::Focused) => find_focused_node(core),
None => Value::Null,
},
_ => {
log::warn!("unknown query target: {target}");
Value::Null
}
};
QueryResponse::new(id, target, data)
}
pub fn handle_query(
emitter: &crate::emitter::EventEmitter,
codec: &Codec,
core: &Core,
id: String,
target: String,
selector: Value,
) -> io::Result<()> {
emit_wire(
emitter,
codec,
&build_query_response(core, id, target, selector),
)
}
pub fn resolve_widget_id(core: &Core, selector: &Value) -> Option<String> {
let sel = Selector::from_wire(selector)?;
let root = core.tree.root()?;
sel.find(root).map(|node| node.id.clone())
}
pub fn build_interact_response(
core: &Core,
id: String,
action: String,
selector: Value,
payload: Value,
) -> InteractResponse {
let widget_target = resolve_widget_target(core, &selector);
let events: Vec<OutgoingEvent> = match (action.as_str(), widget_target) {
("click", Some((_window_id, wid))) => {
vec![OutgoingEvent::click(wid)]
}
("type_text", Some((_window_id, wid))) => {
let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
vec![OutgoingEvent::input(wid, text.to_string())]
}
("submit", Some((_window_id, wid))) => {
let value = payload.get("value").and_then(|v| v.as_str()).unwrap_or("");
vec![OutgoingEvent::submit(wid, value.to_string())]
}
("toggle", Some((_window_id, wid))) => {
let value = payload
.get("value")
.and_then(|v| v.as_bool())
.unwrap_or(false);
vec![OutgoingEvent::toggle(wid, value)]
}
("select", Some((_window_id, wid))) => {
let value = payload.get("value").and_then(|v| v.as_str()).unwrap_or("");
vec![OutgoingEvent::select(wid, value.to_string())]
}
("slide", Some((_window_id, wid))) => {
let value = payload.get("value").and_then(|v| v.as_f64()).unwrap_or(0.0);
vec![OutgoingEvent::slide(wid, value)]
}
("press", _) => {
let payload_map = payload.as_object();
let (key, modifiers) = parse_key_and_modifiers(payload_map);
vec![OutgoingEvent::scripting_key_press(key, modifiers)]
}
("release", _) => {
let payload_map = payload.as_object();
let (key, modifiers) = parse_key_and_modifiers(payload_map);
vec![OutgoingEvent::scripting_key_release(key, modifiers)]
}
("move_to", _) => {
let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
vec![OutgoingEvent::scripting_cursor_moved(x, y)]
}
("type_key", _) => {
let payload_map = payload.as_object();
let (key, modifiers) = parse_key_and_modifiers(payload_map);
vec![
OutgoingEvent::scripting_key_press(key.clone(), modifiers.clone()),
OutgoingEvent::scripting_key_release(key, modifiers),
]
}
("paste", Some((_window_id, wid))) => {
let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
vec![OutgoingEvent::paste(wid, text.to_string())]
}
("scroll", Some((_window_id, wid))) => {
let delta_x = payload
.get("delta_x")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let delta_y = payload
.get("delta_y")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
vec![OutgoingEvent::generic(
"scroll",
wid,
Some(serde_json::json!({"delta_x": delta_x, "delta_y": delta_y})),
)]
}
("scroll", None) => {
let delta_x = payload
.get("delta_x")
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32;
let delta_y = payload
.get("delta_y")
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32;
vec![OutgoingEvent::scripting_scroll(delta_x, delta_y)]
}
("sort", Some((_window_id, wid))) => {
let column = payload.get("column").and_then(|v| v.as_str()).unwrap_or("");
vec![OutgoingEvent::generic(
"sort",
wid,
Some(serde_json::json!({"column": column})),
)]
}
("pane_focus_cycle", Some((_window_id, wid))) => {
vec![OutgoingEvent::generic("pane_focus_cycle", wid, None)]
}
("canvas_press", Some((window_id, wid))) => {
let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let button = payload
.get("button")
.and_then(|v| v.as_str())
.unwrap_or("left")
.to_string();
let mods = plushie_widget_sdk::protocol::KeyModifiers::default();
if let Some(node) = core.tree.root().and_then(|root| {
find_tree_node_by_id_with_window(root, &wid, Some(&window_id), None, 0)
}) {
if let Some(element_id) =
plushie_widget_sdk::runtime::canvas::canvas_hit_test(node, x, y)
{
vec![OutgoingEvent::generic(
"click",
element_id,
Some(serde_json::json!({
"x": x, "y": y, "button": button,
})),
)]
} else if plushie_widget_sdk::runtime::canvas::canvas_has_on_press(node) {
vec![OutgoingEvent::pointer_press(
wid, x, y, &button, "mouse", None, mods,
)]
} else {
vec![]
}
} else {
vec![]
}
}
("canvas_release", Some((window_id, wid))) => {
let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let button = payload
.get("button")
.and_then(|v| v.as_str())
.unwrap_or("left")
.to_string();
let mods = plushie_widget_sdk::protocol::KeyModifiers::default();
if let Some(node) = core.tree.root().and_then(|root| {
find_tree_node_by_id_with_window(root, &wid, Some(&window_id), None, 0)
}) {
if plushie_widget_sdk::runtime::canvas::canvas_has_on_press(node) {
vec![OutgoingEvent::pointer_release(
wid, x, y, &button, "mouse", None, mods,
)]
} else {
vec![]
}
} else {
vec![]
}
}
("canvas_move", Some((window_id, wid))) => {
let x = payload.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = payload.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let mods = plushie_widget_sdk::protocol::KeyModifiers::default();
if let Some(node) = core.tree.root().and_then(|root| {
find_tree_node_by_id_with_window(root, &wid, Some(&window_id), None, 0)
}) {
let mut events = Vec::new();
if let Some(element_id) =
plushie_widget_sdk::runtime::canvas::canvas_hit_test(node, x, y)
{
events.push(OutgoingEvent::generic(
"enter",
element_id,
Some(serde_json::json!({"x": x, "y": y})),
));
}
events.push(OutgoingEvent::pointer_move(wid, x, y, "mouse", None, mods));
events
} else {
vec![]
}
}
("click", None) => {
let raw_id = parse_selector(&selector).and_then(|sel| match sel {
Selector::Id { widget_id, .. } => Some(widget_id),
_ => None,
});
if let Some(scoped_id) = raw_id.filter(|id| id.contains('/')) {
let mut found_canvas = None;
let mut remaining = scoped_id.as_str();
while let Some(slash) = remaining.rfind('/') {
let prefix = &scoped_id[..slash];
let element_local = &scoped_id[slash + 1..];
if let Some(window_id) = core
.tree
.root()
.and_then(|root| find_window_id_for_node(root, prefix, None))
&& let Some(node) = core.tree.root().and_then(|root| {
find_tree_node_by_id_with_window(
root,
prefix,
Some(&window_id),
None,
0,
)
})
{
found_canvas = Some((node, window_id, prefix, element_local));
break;
}
remaining = &scoped_id[..slash];
}
if let Some((node, _window_id, canvas_id, element_local)) = found_canvas {
if plushie_widget_sdk::runtime::canvas::canvas_find_element_by_id(
node,
element_local,
) {
vec![OutgoingEvent::generic(
"click",
scoped_id,
Some(serde_json::json!({
"x": 0.0, "y": 0.0, "button": "left",
})),
)]
} else {
log::warn!(
"canvas element '{element_local}' not found in canvas '{canvas_id}'"
);
vec![]
}
} else {
log::warn!("click action: no canvas node found for '{scoped_id}'");
vec![]
}
} else {
log::warn!("click action: widget not found");
vec![]
}
}
(action, _) if plushie_core::InteractAction::from_wire(action).is_none() => {
log::warn!("interact: unknown action '{action}'");
vec![OutgoingEvent::diagnostic(
String::new(),
None,
"warning",
"unknown_action",
&format!("unknown interact action: {action}"),
)]
}
(action, None) => {
log::warn!("interact: widget not found for action '{action}'");
vec![OutgoingEvent::diagnostic(
String::new(),
None,
"warning",
"selector_not_found",
&format!("interact {action}: no widget matched the selector"),
)]
}
(action, _) => {
log::warn!("interact: unhandled action '{action}'");
vec![]
}
};
InteractResponse::new(id, events)
}
fn resolve_widget_target(core: &Core, selector: &Value) -> Option<(String, String)> {
let parsed = parse_selector(selector)?;
let widget_id = resolve_widget_id(core, selector)?;
let requested_window = match parsed {
Selector::Id { window_id, .. } => window_id,
_ => None,
};
let window_id = find_window_id_for_node(core.tree.root()?, &widget_id, None)?;
if let Some(expected) = requested_window
&& expected != window_id
{
return None;
}
Some((window_id, widget_id))
}
fn find_tree_node_by_id_with_window<'a>(
node: &'a plushie_widget_sdk::protocol::TreeNode,
target_id: &str,
target_window_id: Option<&str>,
current_window_id: Option<&'a str>,
depth: usize,
) -> Option<&'a plushie_widget_sdk::protocol::TreeNode> {
if depth > MAX_SELECTOR_SEARCH_DEPTH {
return None;
}
let current_window_id = if node.type_name == "window" {
Some(node.id.as_str())
} else {
current_window_id
};
if node.id == target_id
&& target_window_id.is_none_or(|window_id| current_window_id == Some(window_id))
{
return Some(node);
}
node.children.iter().find_map(|child| {
find_tree_node_by_id_with_window(
child,
target_id,
target_window_id,
current_window_id,
depth + 1,
)
})
}
fn find_window_id_for_node(
node: &plushie_widget_sdk::protocol::TreeNode,
target_id: &str,
current_window_id: Option<&str>,
) -> Option<String> {
let current_window_id = if node.type_name == "window" {
Some(node.id.as_str())
} else {
current_window_id
};
if node.id == target_id {
return current_window_id.map(str::to_string);
}
node.children
.iter()
.find_map(|child| find_window_id_for_node(child, target_id, current_window_id))
}
pub fn handle_interact(
emitter: &crate::emitter::EventEmitter,
codec: &Codec,
core: &Core,
id: String,
action: String,
selector: Value,
payload: Value,
) -> io::Result<()> {
emit_wire(
emitter,
codec,
&build_interact_response(core, id, action, selector, payload),
)
}
pub fn build_reset_response(core: &mut Core, id: String) -> ResetResponse {
*core = Core::new();
ResetResponse::ok(id)
}
pub fn handle_reset(
emitter: &crate::emitter::EventEmitter,
codec: &Codec,
core: &mut Core,
id: String,
) -> io::Result<()> {
emit_wire(emitter, codec, &build_reset_response(core, id))
}
pub fn build_tree_hash_response(core: &Core, id: String, name: String) -> TreeHashResponse {
let hash = match plushie_widget_sdk::protocol::canonical_tree_hash(core.tree.root()) {
Ok(hash) => hash,
Err(e) => {
log::error!("tree_hash: serialization failed: {e}");
"SERIALIZATION_ERROR".to_string()
}
};
TreeHashResponse::new(id, name, hash)
}
pub fn handle_tree_hash(
emitter: &crate::emitter::EventEmitter,
codec: &Codec,
core: &Core,
id: String,
name: String,
) -> io::Result<()> {
emit_wire(emitter, codec, &build_tree_hash_response(core, id, name))
}
#[cfg(test)]
#[allow(unused_must_use)]
mod tests {
use super::*;
use plushie_widget_sdk::testing::{node as make_node, node_with_props};
use serde_json::json;
fn test_emitter() -> crate::emitter::EventEmitter {
use std::sync::Arc;
use parking_lot::Mutex;
struct NullSink;
impl crate::emitters::EventSink for NullSink {
fn emit_event(
&mut self,
_: plushie_widget_sdk::protocol::OutgoingEvent,
) -> std::io::Result<()> {
Ok(())
}
fn emit_effect_response(
&mut self,
_: plushie_widget_sdk::protocol::EffectResponse,
) -> std::io::Result<()> {
Ok(())
}
fn emit_query_response(
&mut self,
_: &str,
_: &str,
_: &serde_json::Value,
) -> std::io::Result<()> {
Ok(())
}
fn emit_screenshot_response(
&mut self,
_: &str,
_: &str,
_: &str,
_: u32,
_: u32,
_: &[u8],
) -> std::io::Result<()> {
Ok(())
}
fn emit_hello(
&mut self,
_: &str,
_: &str,
_: &[&str],
_: &[&str],
_: &str,
) -> std::io::Result<()> {
Ok(())
}
fn emit_diagnostic(
&mut self,
_: plushie_widget_sdk::protocol::DiagnosticMessage,
) -> std::io::Result<()> {
Ok(())
}
fn write_raw(&mut self, _: &[u8]) -> std::io::Result<()> {
Ok(())
}
}
let sink: Arc<Mutex<Box<dyn crate::emitters::EventSink>>> =
Arc::new(Mutex::new(Box::new(NullSink)));
crate::emitter::EventEmitter::new(sink)
}
fn make_text_node(id: &str, content: &str) -> plushie_widget_sdk::protocol::TreeNode {
node_with_props(id, "text", json!({"content": content}))
}
fn text_node_at_depth(depth: usize, content: &str) -> plushie_widget_sdk::protocol::TreeNode {
let mut target = make_text_node("target", content);
for level in (0..depth).rev() {
target = plushie_widget_sdk::testing::node_with_children(
&format!("level-{level}"),
"column",
vec![target],
);
}
target
}
#[test]
fn parse_selector_by_id() {
let sel = json!({"by": "id", "value": "btn-1"});
match parse_selector(&sel) {
Some(Selector::Id {
widget_id,
window_id,
}) => {
assert_eq!(widget_id, "btn-1");
assert_eq!(window_id, None);
}
other => panic!("expected Id, got {other:?}"),
}
}
#[test]
fn parse_selector_by_id_with_window() {
let sel = json!({"by": "id", "value": "form/save", "window_id": "prefs"});
match parse_selector(&sel) {
Some(Selector::Id {
widget_id,
window_id,
}) => {
assert_eq!(widget_id, "form/save");
assert_eq!(window_id.as_deref(), Some("prefs"));
}
other => panic!("expected Id, got {other:?}"),
}
}
#[test]
fn parse_selector_by_text() {
let sel = json!({"by": "text", "value": "Click me"});
match parse_selector(&sel) {
Some(Selector::Text(t)) => assert_eq!(t, "Click me"),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn parse_selector_unknown_by() {
let sel = json!({"by": "css", "value": ".foo"});
assert!(parse_selector(&sel).is_none());
}
#[test]
fn parse_selector_missing_fields() {
assert!(parse_selector(&json!({})).is_none());
assert!(parse_selector(&json!({"by": "id"})).is_none());
assert!(parse_selector(&Value::Null).is_none());
}
#[test]
fn search_tree_uses_core_selector_depth_limit() {
let at_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH, "needle");
assert!(
search_tree(&at_limit, 0, &|n| matches_text(n, "needle"), &node_to_value).is_some()
);
assert!(
find_tree_node_by_id_with_window(&at_limit, "target", None, None, 0).is_some(),
"production ID lookup should include nodes at the shared depth limit"
);
let past_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH + 1, "needle");
assert!(
search_tree(
&past_limit,
0,
&|n| matches_text(n, "needle"),
&node_to_value
)
.is_none()
);
assert!(
find_tree_node_by_id_with_window(&past_limit, "target", None, None, 0).is_none(),
"production ID lookup should stop after the shared depth limit"
);
assert!(Selector::text("needle").find(&past_limit).is_none());
}
#[test]
fn parse_key_plain() {
let (key, mods) = parse_key_and_modifiers(None);
assert_eq!(key, "");
assert_eq!(mods["ctrl"], false);
}
#[test]
fn parse_key_combined_string() {
let map: serde_json::Map<String, Value> =
serde_json::from_value(json!({"key": "ctrl+shift+s"})).unwrap();
let (key, mods) = parse_key_and_modifiers(Some(&map));
assert_eq!(key, "s");
assert_eq!(mods["ctrl"], true);
assert_eq!(mods["shift"], true);
assert_eq!(mods["alt"], false);
}
#[test]
fn parse_key_unknown_modifier_falls_back_to_literal_key() {
let map: serde_json::Map<String, Value> =
serde_json::from_value(json!({"key": "crtl+s"})).unwrap();
let (key, mods) = parse_key_and_modifiers(Some(&map));
assert_eq!(key, "crtl+s");
assert_eq!(mods["ctrl"], false);
assert_eq!(mods["shift"], false);
}
#[test]
fn parse_key_unknown_combo_modifier_falls_back_to_literal_key() {
let map: serde_json::Map<String, Value> =
serde_json::from_value(json!({"combo": "crtl+s"})).unwrap();
let (key, mods) = parse_key_and_modifiers(Some(&map));
assert_eq!(key, "crtl+s");
assert_eq!(mods["ctrl"], false);
assert_eq!(mods["shift"], false);
}
#[test]
fn parse_key_explicit_modifiers() {
let map: serde_json::Map<String, Value> =
serde_json::from_value(json!({"key": "a", "modifiers": {"alt": true}})).unwrap();
let (key, mods) = parse_key_and_modifiers(Some(&map));
assert_eq!(key, "a");
assert_eq!(mods["alt"], true);
assert_eq!(mods["ctrl"], false);
}
#[test]
fn parse_key_logo_aliases() {
for alias in &["logo", "super", "meta"] {
let combo = format!("{alias}+x");
let map: serde_json::Map<String, Value> =
serde_json::from_value(json!({"key": combo})).unwrap();
let (key, mods) = parse_key_and_modifiers(Some(&map));
assert_eq!(key, "x");
assert_eq!(mods["logo"], true, "alias '{alias}' should set logo=true");
}
}
#[test]
fn search_by_id_finds_root() {
let root = make_node("root", "column");
let result = search_tree(&root, 0, &|n| n.id == "root", &node_to_value);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "root");
}
#[test]
fn search_by_id_finds_child() {
let mut root = make_node("root", "column");
root.children.push(make_node("child", "button"));
let result = search_tree(&root, 0, &|n| n.id == "child", &node_to_value);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "child");
}
#[test]
fn search_by_id_not_found() {
let root = make_node("root", "column");
assert!(search_tree(&root, 0, &|n| n.id == "missing", &node_to_value).is_none());
}
#[test]
fn search_by_text_finds_node() {
let mut root = make_node("root", "column");
root.children.push(make_text_node("lbl", "Hello World"));
let result = search_tree(
&root,
0,
&|n| matches_text(n, "Hello World"),
&node_to_value,
);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "lbl");
}
#[test]
fn search_by_text_not_found() {
let root = make_text_node("lbl", "Hello");
assert!(search_tree(&root, 0, &|n| matches_text(n, "Goodbye"), &node_to_value).is_none());
}
#[test]
fn find_id_by_text_returns_id() {
let mut root = make_node("root", "column");
root.children.push(make_text_node("btn", "Submit"));
assert_eq!(find_id_by_text(&root, "Submit", 0), Some("btn".to_string()));
}
#[test]
fn find_id_by_text_not_found() {
let root = make_node("root", "column");
assert_eq!(find_id_by_text(&root, "nope", 0), None);
}
#[test]
fn find_node_by_id_empty_tree() {
let core: Core = Core::new();
assert_eq!(find_node_by_id(&core, "anything", None), Value::Null);
}
#[test]
fn find_node_by_text_empty_tree() {
let core: Core = Core::new();
assert_eq!(find_node_by_text(&core, "anything"), Value::Null);
}
fn core_with_tree() -> Core {
let mut core: Core = Core::new();
let mut root = make_node("root", "column");
root.children.push(make_text_node("btn1", "Click me"));
root.children.push({
let mut n = make_node("input1", "text_input");
n.props = plushie_widget_sdk::protocol::Props::from_json(
json!({"placeholder": "Type here", "value": ""}),
);
n
});
root.children.push({
let mut n = make_node("toggle1", "toggler");
n.props = plushie_widget_sdk::protocol::Props::from_json(json!({"is_toggled": false}));
n
});
root.children.push({
let mut n = make_node("slider1", "slider");
n.props = plushie_widget_sdk::protocol::Props::from_json(
json!({"min": 0.0, "max": 100.0, "value": 50.0}),
);
n
});
core.apply(plushie_widget_sdk::protocol::IncomingMessage::Snapshot { tree: root });
core
}
#[test]
fn build_tree_hash_response_matches_core_tree_hash() {
let core = core_with_tree();
let response = build_tree_hash_response(&core, "q1".to_string(), "main".to_string());
assert_eq!(response.hash, core.tree_hash());
}
#[test]
fn build_tree_hash_response_empty_tree_uses_empty_string() {
let core = Core::new();
let response = build_tree_hash_response(&core, "q1".to_string(), "main".to_string());
assert_eq!(response.hash, "");
}
#[test]
fn handle_interact_click_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i1".to_string(),
"click".to_string(),
json!({"by": "id", "value": "btn1"}),
json!({}),
);
}
#[test]
fn handle_interact_type_text_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i2".to_string(),
"type_text".to_string(),
json!({"by": "id", "value": "input1"}),
json!({"text": "hello"}),
);
}
#[test]
fn handle_interact_submit_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i3".to_string(),
"submit".to_string(),
json!({"by": "id", "value": "input1"}),
json!({"value": "submitted"}),
);
}
#[test]
fn handle_interact_toggle_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i4".to_string(),
"toggle".to_string(),
json!({"by": "id", "value": "toggle1"}),
json!({"value": true}),
);
}
#[test]
fn handle_interact_select_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i5".to_string(),
"select".to_string(),
json!({"by": "id", "value": "btn1"}),
json!({"value": "option_a"}),
);
}
#[test]
fn handle_interact_slide_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i6".to_string(),
"slide".to_string(),
json!({"by": "id", "value": "slider1"}),
json!({"value": 75.0}),
);
}
#[test]
fn handle_interact_press_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i7".to_string(),
"press".to_string(),
json!({}),
json!({"key": "ctrl+s"}),
);
}
#[test]
fn handle_interact_release_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i8".to_string(),
"release".to_string(),
json!({}),
json!({"key": "a"}),
);
}
#[test]
fn handle_interact_move_to_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i9".to_string(),
"move_to".to_string(),
json!({}),
json!({"x": 100.0, "y": 200.0}),
);
}
#[test]
fn handle_interact_type_key_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i10".to_string(),
"type_key".to_string(),
json!({}),
json!({"key": "enter"}),
);
}
#[test]
fn handle_interact_unknown_action_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i11".to_string(),
"nonexistent_action".to_string(),
json!({"by": "id", "value": "btn1"}),
json!({}),
);
}
#[test]
fn handle_interact_selector_not_found_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i12".to_string(),
"click".to_string(),
json!({"by": "id", "value": "no_such_widget"}),
json!({}),
);
}
#[test]
fn handle_interact_by_text_selector() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i13".to_string(),
"click".to_string(),
json!({"by": "text", "value": "Click me"}),
json!({}),
);
}
#[test]
fn parse_selector_by_role() {
let sel = json!({"by": "role", "value": "button"});
match parse_selector(&sel) {
Some(Selector::Role(r)) => assert_eq!(r, "button"),
other => panic!("expected Role, got {other:?}"),
}
}
#[test]
fn parse_selector_by_label() {
let sel = json!({"by": "label", "value": "Submit"});
match parse_selector(&sel) {
Some(Selector::Label(l)) => assert_eq!(l, "Submit"),
other => panic!("expected Label, got {other:?}"),
}
}
#[test]
fn parse_selector_focused() {
let sel = json!({"by": "focused"});
match parse_selector(&sel) {
Some(Selector::Focused) => {}
other => panic!("expected Focused, got {other:?}"),
}
}
#[test]
fn parse_selector_focused_ignores_value() {
let sel = json!({"by": "focused", "value": "ignored"});
match parse_selector(&sel) {
Some(Selector::Focused) => {}
other => panic!("expected Focused, got {other:?}"),
}
}
fn make_a11y_node(id: &str, type_name: &str, a11y: Value) -> TreeNode {
TreeNode {
id: id.to_string(),
type_name: type_name.to_string(),
props: plushie_widget_sdk::protocol::Props::from_json(json!({"a11y": a11y})),
children: vec![],
}
}
#[test]
fn search_by_role_matches_a11y_prop() {
let node = make_a11y_node("btn", "container", json!({"role": "button"}));
let result = search_tree(&node, 0, &|n| matches_role(n, "button"), &node_to_value);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "btn");
}
#[test]
fn search_by_role_matches_type_name() {
let node = make_node("btn", "button");
let result = search_tree(&node, 0, &|n| matches_role(n, "button"), &node_to_value);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "btn");
}
#[test]
fn search_by_role_prefers_a11y_over_type() {
let node = make_a11y_node("h1", "container", json!({"role": "heading"}));
assert!(search_tree(&node, 0, &|n| matches_role(n, "heading"), &node_to_value).is_some());
assert!(search_tree(&node, 0, &|n| matches_role(n, "container"), &node_to_value).is_none());
}
#[test]
fn search_by_role_finds_in_children() {
let mut root = make_node("root", "column");
root.children.push(make_a11y_node(
"slider",
"slider",
json!({"role": "slider"}),
));
let result = search_tree(&root, 0, &|n| matches_role(n, "slider"), &node_to_value);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "slider");
}
#[test]
fn search_by_role_not_found() {
let node = make_node("root", "column");
assert!(search_tree(&node, 0, &|n| matches_role(n, "button"), &node_to_value).is_none());
}
#[test]
fn search_by_label_matches_a11y_label() {
let node = make_a11y_node("btn", "button", json!({"label": "Submit"}));
let result = search_tree(&node, 0, &|n| matches_label(n, "Submit"), &node_to_value);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "btn");
}
#[test]
fn search_by_label_matches_label_prop() {
let mut node = make_node("chk", "checkbox");
node.props =
plushie_widget_sdk::protocol::Props::from_json(json!({"label": "Accept terms"}));
let result = search_tree(
&node,
0,
&|n| matches_label(n, "Accept terms"),
&node_to_value,
);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "chk");
}
#[test]
fn search_by_label_matches_content_prop() {
let node = make_text_node("txt", "Hello World");
let result = search_tree(
&node,
0,
&|n| matches_label(n, "Hello World"),
&node_to_value,
);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "txt");
}
#[test]
fn search_by_label_not_found() {
let node = make_node("root", "column");
assert!(search_tree(&node, 0, &|n| matches_label(n, "Missing"), &node_to_value).is_none());
}
#[test]
fn search_focused_matches_focused_prop() {
let mut node = make_node("inp", "text_input");
node.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": true}));
let result = search_tree(&node, 0, &is_focused, &node_to_value);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "inp");
}
#[test]
fn search_focused_matches_a11y_focused() {
let node = make_a11y_node("inp", "text_input", json!({"focused": true}));
let result = search_tree(&node, 0, &is_focused, &node_to_value);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "inp");
}
#[test]
fn search_focused_skips_unfocused() {
let mut node = make_node("inp", "text_input");
node.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": false}));
assert!(search_tree(&node, 0, &is_focused, &node_to_value).is_none());
}
#[test]
fn search_focused_finds_in_children() {
let mut root = make_node("root", "column");
let mut child = make_node("inp", "text_input");
child.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": true}));
root.children.push(child);
let result = search_tree(&root, 0, &is_focused, &node_to_value);
assert!(result.is_some());
assert_eq!(result.unwrap()["id"], "inp");
}
#[test]
fn search_focused_not_found() {
let root = make_node("root", "column");
assert!(search_tree(&root, 0, &is_focused, &node_to_value).is_none());
}
#[test]
fn find_id_by_role_returns_id() {
let mut root = make_node("root", "column");
root.children.push(make_node("btn", "button"));
assert_eq!(find_id_by_role(&root, "button", 0), Some("btn".to_string()));
}
#[test]
fn find_id_by_label_returns_id() {
let mut root = make_node("root", "column");
root.children
.push(make_a11y_node("btn", "button", json!({"label": "Submit"})));
assert_eq!(
find_id_by_label(&root, "Submit", 0),
Some("btn".to_string())
);
}
#[test]
fn find_id_focused_returns_id() {
let mut root = make_node("root", "column");
let mut child = make_node("inp", "text_input");
child.props = plushie_widget_sdk::protocol::Props::from_json(json!({"focused": true}));
root.children.push(child);
assert_eq!(find_id_focused(&root, 0), Some("inp".to_string()));
}
#[test]
fn handle_interact_by_role_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i14".to_string(),
"click".to_string(),
json!({"by": "role", "value": "text_input"}),
json!({}),
);
}
#[test]
fn handle_interact_by_label_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i15".to_string(),
"click".to_string(),
json!({"by": "label", "value": "Click me"}),
json!({}),
);
}
#[test]
fn handle_interact_paste_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i17".to_string(),
"paste".to_string(),
json!({"by": "id", "value": "input1"}),
json!({"text": "pasted text"}),
);
}
#[test]
fn handle_interact_scroll_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i18".to_string(),
"scroll".to_string(),
json!({}),
json!({"delta_x": 0.0, "delta_y": -10.0}),
);
}
#[test]
fn handle_interact_sort_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i19".to_string(),
"sort".to_string(),
json!({"by": "id", "value": "btn1"}),
json!({"column": "name"}),
);
}
#[test]
fn handle_interact_pane_focus_cycle_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i20".to_string(),
"pane_focus_cycle".to_string(),
json!({"by": "id", "value": "btn1"}),
json!({}),
);
}
#[test]
fn handle_interact_canvas_press_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i21".to_string(),
"canvas_press".to_string(),
json!({"by": "id", "value": "btn1"}),
json!({"x": 50.0, "y": 75.0}),
);
}
#[test]
fn handle_interact_canvas_release_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i22".to_string(),
"canvas_release".to_string(),
json!({"by": "id", "value": "btn1"}),
json!({"x": 50.0, "y": 75.0}),
);
}
#[test]
fn handle_interact_canvas_move_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i23".to_string(),
"canvas_move".to_string(),
json!({"by": "id", "value": "btn1"}),
json!({"x": 60.0, "y": 80.0}),
);
}
#[test]
fn handle_interact_focused_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"i16".to_string(),
"click".to_string(),
json!({"by": "focused"}),
json!({}),
);
}
#[test]
fn parse_iced_key_named_enter() {
assert_eq!(
parse_iced_key("Enter"),
Key::Named(keyboard::key::Named::Enter)
);
assert_eq!(
parse_iced_key("enter"),
Key::Named(keyboard::key::Named::Enter)
);
}
#[test]
fn parse_iced_key_named_tab() {
assert_eq!(parse_iced_key("Tab"), Key::Named(keyboard::key::Named::Tab));
}
#[test]
fn parse_iced_key_named_arrows() {
assert_eq!(
parse_iced_key("ArrowUp"),
Key::Named(keyboard::key::Named::ArrowUp)
);
assert_eq!(
parse_iced_key("Up"),
Key::Named(keyboard::key::Named::ArrowUp)
);
assert_eq!(
parse_iced_key("ArrowDown"),
Key::Named(keyboard::key::Named::ArrowDown)
);
}
#[test]
fn parse_iced_key_single_char() {
assert_eq!(parse_iced_key("a"), Key::Character(SmolStr::new("a")));
assert_eq!(parse_iced_key("Z"), Key::Character(SmolStr::new("Z")));
}
#[test]
fn parse_iced_key_function_keys() {
assert_eq!(parse_iced_key("F1"), Key::Named(keyboard::key::Named::F1));
assert_eq!(parse_iced_key("F12"), Key::Named(keyboard::key::Named::F12));
}
#[test]
fn parse_iced_modifiers_from_json() {
let mods = json!({"shift": true, "ctrl": true, "alt": false, "logo": false});
let result = parse_iced_modifiers(&mods);
assert!(result.shift());
assert!(result.control());
assert!(!result.alt());
assert!(!result.logo());
}
#[test]
fn parse_iced_modifiers_empty() {
let mods = json!({});
let result = parse_iced_modifiers(&mods);
assert!(result.is_empty());
}
#[test]
fn interaction_to_iced_events_click() {
let events = interaction_to_iced_events(
"click",
Some("btn1"),
&json!({}),
mouse::Cursor::Available(Point::new(100.0, 50.0)),
);
assert_eq!(events.len(), 3); }
#[test]
fn interaction_to_iced_events_type_text() {
let events = interaction_to_iced_events(
"type_text",
Some("inp1"),
&json!({"text": "hi"}),
mouse::Cursor::Unavailable,
);
assert_eq!(events.len(), 4);
}
#[test]
fn interaction_to_iced_events_scroll() {
let events = interaction_to_iced_events(
"scroll",
None,
&json!({"delta_x": 0.0, "delta_y": -10.0}),
mouse::Cursor::Unavailable,
);
assert_eq!(events.len(), 1);
match &events[0] {
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
assert_eq!(*delta, mouse::ScrollDelta::Lines { x: 0.0, y: -10.0 });
}
_ => panic!("expected WheelScrolled"),
}
}
#[test]
fn interaction_to_iced_events_move_to() {
let events = interaction_to_iced_events(
"move_to",
None,
&json!({"x": 42.0, "y": 84.0}),
mouse::Cursor::Unavailable,
);
assert_eq!(events.len(), 1);
match &events[0] {
Event::Mouse(mouse::Event::CursorMoved { position }) => {
assert_eq!(*position, Point::new(42.0, 84.0));
}
_ => panic!("expected CursorMoved"),
}
}
#[test]
fn interaction_to_iced_events_synthetic_only() {
for action in &["paste", "sort", "pane_focus_cycle", "slide"] {
let events = interaction_to_iced_events(
action,
Some("w1"),
&json!({}),
mouse::Cursor::Unavailable,
);
assert!(
events.is_empty(),
"action '{action}' should produce no iced events"
);
}
}
#[test]
fn interaction_to_iced_events_submit() {
let events = interaction_to_iced_events(
"submit",
Some("inp1"),
&json!({}),
mouse::Cursor::Unavailable,
);
assert_eq!(events.len(), 2); }
#[test]
fn interaction_to_iced_events_type_key() {
let events = interaction_to_iced_events(
"type_key",
None,
&json!({"key": "ctrl+s"}),
mouse::Cursor::Unavailable,
);
assert_eq!(events.len(), 2); }
#[test]
fn interaction_to_iced_events_press_release() {
let press = interaction_to_iced_events(
"press",
None,
&json!({"key": "a"}),
mouse::Cursor::Unavailable,
);
assert_eq!(press.len(), 1);
let release = interaction_to_iced_events(
"release",
None,
&json!({"key": "a"}),
mouse::Cursor::Unavailable,
);
assert_eq!(release.len(), 1);
}
#[test]
fn handle_interact_slide_string_value_does_not_panic() {
let core = core_with_tree();
handle_interact(
&test_emitter(),
&Codec::MsgPack,
&core,
"bad1".to_string(),
"slide".to_string(),
json!({"by": "id", "value": "slider1"}),
json!({"value": "not_a_number"}),
);
}
#[test]
fn parse_key_and_modifiers_null_key() {
let map: serde_json::Map<String, Value> =
serde_json::from_value(json!({"key": null})).unwrap();
let (key, mods) = parse_key_and_modifiers(Some(&map));
assert_eq!(key, "");
assert_eq!(mods["ctrl"], false);
}
#[test]
fn parse_selector_null_value() {
let sel = json!({"by": "id", "value": null});
assert!(parse_selector(&sel).is_none());
}
}