lazyBoy/crates/control/src/cua/translate.rs

298 lines
9.6 KiB
Rust
Raw Normal View History

2026-09-07 14:23:38 +00:00
use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection};
use serde_json::{Value, json};
use crate::controller::ControlError;
use crate::{launch_argv_on, open_argv_on};
#[derive(Debug, Clone, PartialEq)]
pub enum TranslatedAction {
Cua { tool: &'static str, payload: Value },
Sleep { ms: u64 },
2026-09-08 13:27:38 +00:00
Launch { argv: Vec<String> },
2026-09-07 14:23:38 +00:00
FocusTitle { title: String },
}
pub fn translate_action(
action: &ComputerAction,
display: &str,
profile: Option<&str>,
) -> Result<TranslatedAction, ControlError> {
match action {
ComputerAction::Wait { ms } => Ok(TranslatedAction::Sleep { ms: u64::from(*ms) }),
2026-09-08 13:27:38 +00:00
ComputerAction::Open { path } => Ok(TranslatedAction::Launch {
2026-09-07 14:23:38 +00:00
argv: open_argv_on(display, profile, path),
}),
ComputerAction::Launch { application, uri } => {
let argv = launch_argv_on(display, profile, application, uri.as_deref())
.ok_or(ControlError::Unsupported)?;
2026-09-08 13:27:38 +00:00
Ok(TranslatedAction::Launch { argv })
}
ComputerAction::CopySelection | ComputerAction::Ref { .. } => {
Err(ControlError::Unsupported)
2026-09-07 14:23:38 +00:00
}
ComputerAction::Focus { title } => Ok(TranslatedAction::FocusTitle {
title: title.clone(),
}),
ComputerAction::Pointer {
x,
y,
pointer_type,
button,
2026-09-08 01:14:53 +00:00
} => translate_pointer(*x, *y, pointer_type, *button),
2026-09-07 14:23:38 +00:00
ComputerAction::Clipboard { text } => {
tracing::info!(backend = "cua", tool = "type_text", length = text.len());
Ok(TranslatedAction::Cua {
tool: "type_text",
payload: json!({
"text": text,
"scope": "desktop",
}),
})
}
ComputerAction::Key { key, modifiers } => Ok(translate_key(key, modifiers.as_deref())),
ComputerAction::Scroll { direction, amount } => Ok(TranslatedAction::Cua {
tool: "scroll",
payload: json!({
"direction": match direction {
ScrollDirection::Up => "up",
ScrollDirection::Down => "down",
},
2026-09-08 01:14:53 +00:00
"amount": amount.unwrap_or(12).clamp(1, 50),
2026-09-07 14:23:38 +00:00
"by": "line",
"scope": "desktop",
}),
}),
}
}
fn translate_pointer(
x: u32,
y: u32,
pointer_type: &PointerType,
button: Option<PointerButton>,
2026-09-08 01:14:53 +00:00
) -> Result<TranslatedAction, ControlError> {
2026-09-07 14:23:38 +00:00
let button = match button.unwrap_or(PointerButton::Left) {
PointerButton::Left => "left",
PointerButton::Middle => "middle",
PointerButton::Right => "right",
};
match pointer_type {
2026-09-08 01:14:53 +00:00
PointerType::Move => Ok(TranslatedAction::Cua {
2026-09-07 14:23:38 +00:00
tool: "move_cursor",
payload: json!({
"x": x,
"y": y,
"scope": "desktop",
}),
2026-09-08 01:14:53 +00:00
}),
PointerType::Click => Ok(TranslatedAction::Cua {
2026-09-07 14:23:38 +00:00
tool: "click",
payload: json!({
"x": x,
"y": y,
"button": button,
"scope": "desktop",
}),
2026-09-08 01:14:53 +00:00
}),
// A held-button gesture collapses into one `drag` call before it gets
// here; the CLI has no lease that keeps a button down between calls.
PointerType::Down | PointerType::Up => Err(ControlError::Unsupported),
2026-09-07 14:23:38 +00:00
}
}
fn translate_key(key: &str, modifiers: Option<&[String]>) -> TranslatedAction {
2026-09-08 09:14:15 +00:00
// The public action DSL and mobile keyboard send chords as "ctrl+a".
// Cua requires separate keys passed to hotkey, not a literal press_key.
let mut keys: Vec<String> = modifiers
.unwrap_or_default()
.iter()
.map(|item| map_key(item))
.collect();
if key.contains('+') && key.split('+').all(|part| !part.is_empty()) {
keys.extend(key.split('+').map(map_key));
} else {
keys.push(map_key(key));
}
if keys.len() > 1 {
TranslatedAction::Cua {
tool: "hotkey",
payload: json!({ "keys": keys, "scope": "desktop" }),
2026-09-07 14:23:38 +00:00
}
2026-09-08 09:14:15 +00:00
} else {
TranslatedAction::Cua {
2026-09-07 14:23:38 +00:00
tool: "press_key",
2026-09-08 09:14:15 +00:00
payload: json!({ "key": keys[0], "scope": "desktop" }),
}
2026-09-07 14:23:38 +00:00
}
}
fn map_key(key: &str) -> String {
match key.to_ascii_lowercase().as_str() {
"enter" | "return" => "return".into(),
"esc" | "escape" => "escape".into(),
"cmd" | "command" | "super" | "meta" | "win" => "ctrl".into(),
"control" | "ctl" => "ctrl".into(),
"option" => "alt".into(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use lazyboy_contracts::RefVerb;
2026-09-08 09:14:15 +00:00
#[test]
fn inline_shortcuts_use_hotkey_and_literal_plus_stays_a_key() {
for (key, expected) in [
("ctrl+a", json!(["ctrl", "a"])),
("alt+Left", json!(["alt", "left"])),
("Control+Shift+Tab", json!(["ctrl", "shift", "tab"])),
] {
let TranslatedAction::Cua { tool, payload } = translate_key(key, None) else {
panic!("expected Cua")
};
assert_eq!(tool, "hotkey");
assert_eq!(payload["keys"], expected);
}
let TranslatedAction::Cua { tool, payload } = translate_key("+", None) else {
panic!("expected Cua")
};
assert_eq!(tool, "press_key");
assert_eq!(payload["key"], "+");
}
2026-09-07 14:23:38 +00:00
#[test]
fn pixel_click_is_desktop_cua_click() {
let action = ComputerAction::Pointer {
x: 40,
y: 80,
pointer_type: PointerType::Click,
button: Some(PointerButton::Left),
};
let TranslatedAction::Cua { tool, payload } =
translate_action(&action, ":1", None).unwrap()
else {
panic!("expected cua click");
};
assert_eq!(tool, "click");
assert_eq!(payload["x"], 40);
assert_eq!(payload["y"], 80);
assert_eq!(payload["scope"], "desktop");
2026-09-08 01:14:53 +00:00
// 0.23.2 rejects an explicit desktop target with invalid_action_target.
assert!(payload.get("target").is_none());
}
#[test]
fn desktop_actions_omit_the_rejected_target() {
for action in [
ComputerAction::Clipboard { text: "hi".into() },
ComputerAction::Key {
key: "return".into(),
modifiers: None,
},
ComputerAction::Key {
key: "c".into(),
modifiers: Some(vec!["ctrl".into()]),
},
ComputerAction::Pointer {
x: 1,
y: 2,
pointer_type: PointerType::Move,
button: None,
},
ComputerAction::Scroll {
direction: ScrollDirection::Down,
amount: None,
},
] {
let TranslatedAction::Cua { payload, .. } =
translate_action(&action, ":1", None).unwrap()
else {
panic!("every desktop action translates to a Cua tool");
};
assert!(payload.get("target").is_none(), "{action:?}");
}
}
#[test]
fn scroll_amount_stays_inside_the_driver_window() {
let scroll = |amount| match translate_action(
&ComputerAction::Scroll {
direction: ScrollDirection::Down,
amount: Some(amount),
},
":1",
None,
)
.unwrap()
{
TranslatedAction::Cua { payload, .. } => payload["amount"].as_i64().unwrap(),
other => panic!("expected scroll, got {other:?}"),
};
assert_eq!(scroll(0), 1);
assert_eq!(scroll(12), 12);
assert_eq!(scroll(200), 50);
}
#[test]
fn held_buttons_are_not_translated_one_by_one() {
for pointer_type in [PointerType::Down, PointerType::Up] {
let action = ComputerAction::Pointer {
x: 1,
y: 2,
pointer_type,
button: Some(PointerButton::Left),
};
assert!(matches!(
translate_action(&action, ":1", None),
Err(ControlError::Unsupported)
));
}
2026-09-07 14:23:38 +00:00
}
#[test]
fn typed_text_is_redacted_from_tool_name_only() {
let action = ComputerAction::Clipboard {
text: "secret-password".into(),
};
let TranslatedAction::Cua { tool, payload } =
translate_action(&action, ":1", None).unwrap()
else {
panic!("expected type_text");
};
assert_eq!(tool, "type_text");
assert_eq!(payload["text"], "secret-password");
assert_eq!(payload["scope"], "desktop");
}
#[test]
fn chord_uses_hotkey_and_maps_cmd_to_ctrl() {
let action = ComputerAction::Key {
key: "c".into(),
modifiers: Some(vec!["cmd".into()]),
};
let TranslatedAction::Cua { tool, payload } =
translate_action(&action, ":1", None).unwrap()
else {
panic!("expected hotkey");
};
assert_eq!(tool, "hotkey");
assert_eq!(payload["keys"], json!(["ctrl", "c"]));
}
#[test]
fn semantic_ref_is_not_translated_here() {
let action = ComputerAction::Ref {
verb: RefVerb::Click,
target: "#go".into(),
ref_kind: "dom".into(),
text: None,
};
assert!(matches!(
translate_action(&action, ":1", None),
Err(ControlError::Unsupported)
));
}
}