Skip to main content

ashlar_debug/
target.rs

1//! Element targets as written on the command line, and as the control protocol takes them.
2//!
3//! | Written | Sent |
4//! |---|---|
5//! | `@e3`, `e3` | `{"ref": "e3"}` (from the latest snapshot) |
6//! | `id=file.save`, `#file.save` | `{"id": "file.save"}` (automation id) |
7//! | `text=Save`, `button=Save`, `role:button=Save` | `{"text": "Save"}`, `{"text": "Save", "role": "button"}` |
8//! | `120,40` | `{"x": 120, "y": 40}` (logical points) |
9
10use serde_json::{Value, json};
11
12/// Whether `s` is a ref: `e` followed by digits.
13fn is_ref(s: &str) -> bool {
14    s.strip_prefix('e').is_some_and(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit()))
15}
16
17/// Roles that can prefix a text target (`button=Save`).
18const ROLES: &[&str] =
19    &["button", "link", "textbox", "checkbox", "radio", "label", "menuitem", "tab", "slider", "combobox", "listitem", "heading", "image", "window"];
20
21/// Parse one target. The error says which forms exist.
22pub fn parse(s: &str) -> Result<Value, String> {
23    let s = s.trim();
24    if let Some(r) = s.strip_prefix('@') {
25        return if r.is_empty() { Err("`@` needs a ref, like @e3".into()) } else { Ok(json!({"ref": r})) };
26    }
27    if is_ref(s) {
28        return Ok(json!({"ref": s}));
29    }
30    if let Some(id) = s.strip_prefix("id=").or_else(|| s.strip_prefix('#')).filter(|i| !i.is_empty()) {
31        return Ok(json!({"id": id}));
32    }
33    if let Some(t) = s.strip_prefix("text=") {
34        return Ok(json!({"text": t}));
35    }
36    if let Some((role, text)) = s.split_once('=') {
37        let role = role.strip_prefix("role:").unwrap_or(role);
38        if ROLES.contains(&role) || s.starts_with("role:") {
39            return Ok(json!({"text": text, "role": role}));
40        }
41    }
42    if let Some((x, y)) = s.split_once(',')
43        && let (Ok(x), Ok(y)) = (x.trim().parse::<f64>(), y.trim().parse::<f64>())
44        && x.is_finite()
45        && y.is_finite()
46    {
47        return Ok(json!({"x": x, "y": y}));
48    }
49    Err(format!("`{s}` is not a target; write a ref (@e3), id=<automation id>, text=<label>, <role>=<label> or x,y"))
50}
51
52/// A target for display (`@e3`, `id=file.save`, …).
53pub fn show(t: &Value) -> String {
54    let s = |k: &str| t.get(k).and_then(Value::as_str);
55    if let Some(r) = s("ref") {
56        return format!("@{r}");
57    }
58    if let Some(i) = s("id") {
59        return format!("id={i}");
60    }
61    if let Some(x) = s("text") {
62        return match s("role") {
63            Some(r) => format!("{r}={x}"),
64            None => format!("text={x}"),
65        };
66    }
67    match (t.get("x").and_then(Value::as_f64), t.get("y").and_then(Value::as_f64)) {
68        (Some(x), Some(y)) => format!("{x},{y}"),
69        _ => t.to_string(),
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn every_form() {
79        assert_eq!(parse("@e3").unwrap(), json!({"ref": "e3"}));
80        assert_eq!(parse("e12").unwrap(), json!({"ref": "e12"}));
81        assert_eq!(parse("@custom").unwrap(), json!({"ref": "custom"}));
82        assert_eq!(parse("id=file.save").unwrap(), json!({"id": "file.save"}));
83        assert_eq!(parse("#file.save").unwrap(), json!({"id": "file.save"}));
84        assert_eq!(parse("text=Save as…").unwrap(), json!({"text": "Save as…"}));
85        assert_eq!(parse("button=Save").unwrap(), json!({"text": "Save", "role": "button"}));
86        assert_eq!(parse("role:toolbar=Main").unwrap(), json!({"text": "Main", "role": "toolbar"}));
87        assert_eq!(parse("120,40.5").unwrap(), json!({"x": 120.0, "y": 40.5}));
88        for bad in ["", "@", "e", "Save", "nan,1", "inf,2", "a=b", "#"] {
89            assert!(parse(bad).is_err(), "{bad}");
90        }
91    }
92
93    #[test]
94    fn show_inverts_parse() {
95        for s in ["@e3", "id=file.save", "text=Save", "button=Save", "1,2"] {
96            assert_eq!(show(&parse(s).unwrap()), s);
97        }
98    }
99}