← Commit history

Delete rust/crates/kicad-platform/src/linux/atspi.rs

Colby Knox ·8c9312acbc ·8d ago ·parent 51b39e1
1 file changed −493
rust/crates/kicad-platform/src/linux/atspi.rsdeleted−493
@@ -1,493 +0,0 @@-//! KiCad's widgets on Linux through AT-SPI, the desktop accessibility bus: the Linux-//! counterpart of UI Automation on Windows. wxGTK builds KiCad's menus, dialogs, buttons,-//! labels and check boxes from GTK widgets, and GTK publishes every one of them on the-//! accessibility bus with its name, role, state and actions. The bridge reads a dialog's-//! text and presses its buttons, walks and invokes the menu bar, and sets text fields this-//! way, all without moving, raising or focusing a window.-//!-//! The X window (from x11.rs) is matched to its AT-SPI frame by process id and title.-//! Pure Rust D-Bus (zbus, blocking API).--use std::collections::VecDeque;-use std::sync::Mutex;-use std::time::{Duration, Instant};--use zbus::blocking::Connection;-use zbus::zvariant::{OwnedObjectPath, OwnedValue};--use crate::{MenuItem, WindowInfo};--const ACCESSIBLE: &str = "org.a11y.atspi.Accessible";-const ACTION: &str = "org.a11y.atspi.Action";-const TEXT: &str = "org.a11y.atspi.Text";-const EDITABLE: &str = "org.a11y.atspi.EditableText";-const COMPONENT: &str = "org.a11y.atspi.Component";-const ROOT: &str = "/org/a11y/atspi/accessible/root";-/// A walk never visits more than this many nodes (a whole editor frame is ~3000).-const MAX_NODES: usize = 8000;-/// ... nor runs longer than this.-const WALK_DEADLINE: Duration = Duration::from_secs(8);-/// One D-Bus call never waits longer than this: a busy or frozen application must not hold a verb.-const CALL_TIMEOUT: Duration = Duration::from_secs(3);--// AT-SPI state bits (atspi-constants.h, AtspiStateType).-const STATE_CHECKED: u32 = 4;-const STATE_ENABLED: u32 = 8;-const STATE_SHOWING: u32 = 25;--static BUS: Mutex<Option<Connection>> = Mutex::new(None);--/// Processes whose accessibility calls just timed out, and when. A GTK application stops-/// answering the bus while a GTK message box runs its own loop; skip it for a while instead-/// of paying the call timeout on every attempt.-static SILENT: Mutex<Vec<(u32, Instant)>> = Mutex::new(Vec::new());-const SILENT_FOR: Duration = Duration::from_secs(5);--fn is_silent(pid: u32) -> bool {-    let mut g = SILENT.lock().unwrap_or_else(|p| p.into_inner());-    g.retain(|(_, t)| t.elapsed() < SILENT_FOR);-    g.iter().any(|(p, _)| *p == pid)-}--fn mark_silent(pid: u32) {-    let mut g = SILENT.lock().unwrap_or_else(|p| p.into_inner());-    g.retain(|(p, _)| *p != pid);-    g.push((pid, Instant::now()));-}--/// One accessible object: (D-Bus name of its application, object path).-#[derive(Clone, Debug, PartialEq, Eq)]-pub struct Node {-    pub bus: String,-    pub path: OwnedObjectPath,-}--fn a11y_bus() -> Result<Connection, String> {-    let mut g = BUS.lock().unwrap_or_else(|p| p.into_inner());-    if let Some(c) = g.as_ref() {-        // still alive? (the a11y bus restarts with the session)-        if c.call_method(Some("org.freedesktop.DBus"), "/org/freedesktop/DBus", Some("org.freedesktop.DBus"), "GetId", &()).is_ok() {-            return Ok(c.clone());-        }-    }-    let session = zbus::blocking::connection::Builder::session()-        .and_then(|b| b.method_timeout(CALL_TIMEOUT).build())-        .map_err(|e| format!("no D-Bus session bus (DBUS_SESSION_BUS_ADDRESS): {e}"))?;-    let addr: String = session-        .call_method(Some("org.a11y.Bus"), "/org/a11y/bus", Some("org.a11y.Bus"), "GetAddress", &())-        .and_then(|m| m.body().deserialize())-        .map_err(|e| format!("the accessibility bus (org.a11y.Bus) did not answer: {e}. Is at-spi2-core installed and the desktop session running?"))?;-    let c = zbus::blocking::connection::Builder::address(addr.as_str())-        .and_then(|b| b.method_timeout(CALL_TIMEOUT).build())-        .map_err(|e| format!("cannot connect to the accessibility bus at {addr}: {e}"))?;-    *g = Some(c.clone());-    Ok(c)-}--struct Walker {-    c: Connection,-}--impl Walker {-    fn new() -> Result<Self, String> {-        Ok(Walker { c: a11y_bus()? })-    }-    fn call<B: serde::Serialize + zbus::zvariant::DynamicType, R: for<'d> serde::Deserialize<'d> + zbus::zvariant::Type>(&self, n: &Node, iface: &str, method: &str, body: &B) -> Result<R, String> {-        self.c-            .call_method(Some(n.bus.as_str()), n.path.as_str(), Some(iface), method, body)-            .map_err(|e| format!("{iface}.{method}: {e}"))?-            .body()-            .deserialize::<R>()-            .map_err(|e| format!("{iface}.{method} reply: {e}"))-    }-    fn prop(&self, n: &Node, iface: &str, name: &str) -> Result<OwnedValue, String> {-        self.c-            .call_method(Some(n.bus.as_str()), n.path.as_str(), Some("org.freedesktop.DBus.Properties"), "Get", &(iface, name))-            .map_err(|e| format!("{iface}.{name}: {e}"))?-            .body()-            .deserialize::<OwnedValue>()-            .map_err(|e| e.to_string())-    }-    fn name(&self, n: &Node) -> String {-        self.prop(n, ACCESSIBLE, "Name").ok().and_then(|v| String::try_from(v).ok()).unwrap_or_default()-    }-    fn role(&self, n: &Node) -> String {-        self.call::<_, String>(n, ACCESSIBLE, "GetRoleName", &()).unwrap_or_default()-    }-    fn children(&self, n: &Node) -> Vec<Node> {-        self.call::<_, Vec<(String, OwnedObjectPath)>>(n, ACCESSIBLE, "GetChildren", &()).unwrap_or_default().into_iter().map(|(bus, path)| Node { bus, path }).collect()-    }-    fn states(&self, n: &Node) -> u64 {-        let v: Vec<u32> = self.call(n, ACCESSIBLE, "GetState", &()).unwrap_or_default();-        v.first().copied().unwrap_or(0) as u64 | (v.get(1).copied().unwrap_or(0) as u64) << 32-    }-    fn has_state(&self, n: &Node, bit: u32) -> bool {-        self.states(n) & (1u64 << bit) != 0-    }-    /// (name, keybinding) of every action.-    fn actions(&self, n: &Node) -> Vec<(String, String)> {-        self.call::<_, Vec<(String, String, String)>>(n, ACTION, "GetActions", &()).unwrap_or_default().into_iter().map(|(name, _desc, key)| (name, key)).collect()-    }-    fn do_action(&self, n: &Node, preferred: &[&str]) -> Result<String, String> {-        let acts = self.actions(n);-        if acts.is_empty() {-            return Err("not_invokable: the control has no AT-SPI action".into());-        }-        // GTK names its actions "click", ATK's table cells "Activate": match without case-        let i = preferred.iter().find_map(|p| acts.iter().position(|(a, _)| a.eq_ignore_ascii_case(p))).unwrap_or(0);-        let ok: bool = self.call(n, ACTION, "DoAction", &(i as i32))?;-        if ok {-            Ok(acts[i].0.clone())-        } else {-            Err(format!("the control refused its {:?} action", acts[i].0))-        }-    }-    fn text(&self, n: &Node) -> Option<String> {-        let count = self.prop(n, TEXT, "CharacterCount").ok().and_then(|v| i32::try_from(v).ok())?;-        self.call::<_, String>(n, TEXT, "GetText", &(0i32, count)).ok()-    }-    fn extents(&self, n: &Node) -> Option<(i32, i32, i32, i32)> {-        self.call::<_, (i32, i32, i32, i32)>(n, COMPONENT, "GetExtents", &(0u32,)).ok()-    }-    fn pid_of_bus(&self, bus: &str) -> Option<u32> {-        self.c-            .call_method(Some("org.freedesktop.DBus"), "/org/freedesktop/DBus", Some("org.freedesktop.DBus"), "GetConnectionUnixProcessID", &(bus,))-            .ok()?-            .body()-            .deserialize::<u32>()-            .ok()-    }-    /// Breadth-first walk under `top`, calling `visit(node, role, name, depth, path)` with the-    /// names of the ancestors; `visit` returns false to stop.-    fn walk(&self, top: &Node, mut visit: impl FnMut(&Node, &str, &str, &[String]) -> bool) {-        let started = Instant::now();-        let mut q: VecDeque<(Node, Vec<String>)> = VecDeque::from([(top.clone(), Vec::new())]);-        let mut seen = 0usize;-        while let Some((n, trail)) = q.pop_front() {-            seen += 1;-            if seen > MAX_NODES || started.elapsed() > WALK_DEADLINE {-                return;-            }-            let role = self.role(&n);-            let name = self.name(&n);-            if !visit(&n, &role, &name, &trail) {-                return;-            }-            let mut t = trail.clone();-            t.push(name);-            for c in self.children(&n) {-                q.push_back((c, t.clone()));-            }-        }-    }-}--const WINDOW_ROLES: &[&str] = &["frame", "dialog", "alert", "window", "file chooser"];--/// The AT-SPI frame of an X window: same process, same title (the window's on-screen box-/// breaks a tie between equal titles).-fn frame_of(w: &Walker, win: &WindowInfo) -> Result<Node, String> {-    if win.pid == 0 {-        return Err(format!("window {} has no _NET_WM_PID, so its accessible frame cannot be found", win.hwnd));-    }-    if is_silent(win.pid) {-        return Err(format!("not_answering: process {} is not answering the accessibility bus right now (a GTK message box blocks it); read the dialog from its screenshot", win.pid));-    }-    let registry = Node { bus: "org.a11y.atspi.Registry".into(), path: OwnedObjectPath::try_from(ROOT).map_err(|e| e.to_string())? };-    let mut candidates = Vec::new();-    for app in w.children(&registry) {-        if w.pid_of_bus(&app.bus) != Some(win.pid) {-            continue;-        }-        // A window that just opened keeps KiCad busy for a moment: one timeout is "busy", two in a-        // row (with a pause between) is a GTK message box holding the application.-        let timed_out = |e: &str| e.contains("imed out") || e.contains("imeout");-        let mut got = w.call::<_, Vec<(String, OwnedObjectPath)>>(&app, ACCESSIBLE, "GetChildren", &());-        if got.as_ref().err().is_some_and(|e| timed_out(e)) {-            std::thread::sleep(Duration::from_millis(1500));-            got = w.call::<_, Vec<(String, OwnedObjectPath)>>(&app, ACCESSIBLE, "GetChildren", &());-        }-        let tops = match got {-            Ok(v) => v.into_iter().map(|(bus, path)| Node { bus, path }).collect::<Vec<_>>(),-            Err(e) if timed_out(&e) => {-                mark_silent(win.pid);-                return Err(format!("not_answering: process {} did not answer the accessibility bus within {:?} (a GTK message box blocks it); read the dialog from its screenshot", win.pid, CALL_TIMEOUT));-            }-            Err(_) => Vec::new(),-        };-        for top in tops {-            if WINDOW_ROLES.contains(&w.role(&top).as_str()) && w.name(&top) == win.title {-                candidates.push(top);-            }-        }-    }-    match candidates.len() {-        0 => Err(format!(-            "control_not_found: no accessible window titled {:?} in process {} on the accessibility bus (GTK publishes its widgets there when at-spi2 runs in the session)",-            win.title, win.pid-        )),-        1 => Ok(candidates.remove(0)),-        _ => {-            let (x, y, ww, hh) = win.rect;-            let score = |n: &Node| w.extents(n).map(|(a, b, c, d)| (a - x).abs() + (b - y).abs() + (c - ww).abs() + (d - hh).abs()).unwrap_or(i32::MAX);-            candidates.sort_by_key(score);-            Ok(candidates.remove(0))-        }-    }-}--fn frame_for(h: u64) -> Result<(Walker, Node), String> {-    let win = super::x11::window_info(h)?.ok_or_else(|| format!("window {h} is gone"))?;-    let w = Walker::new()?;-    let f = frame_of(&w, &win)?;-    Ok((w, f))-}--const SELECTION: &str = "org.a11y.atspi.Selection";--/// The selected row of the window's first tree (or list), activated the way a double-click does:-/// GTK answers the cell's "activate" action with row-activated.-pub fn tree_activate_selected(h: u64) -> Result<String, String> {-    let (w, f) = frame_for(h)?;-    let mut tree = None;-    w.walk(&f, |n, role, _, _| {-        if matches!(role, "tree table" | "tree" | "table" | "list") {-            tree = Some(n.clone());-            return false;-        }-        true-    });-    let tree = tree.ok_or_else(|| format!("control_not_found: no tree in window {h}"))?;-    let count: i32 = w.prop(&tree, SELECTION, "NSelectedChildren").ok().and_then(|v| i32::try_from(v).ok()).unwrap_or(0);-    if count < 1 {-        return Err("nothing_selected: the tree has no selected row".into());-    }-    let (bus, path): (String, OwnedObjectPath) = w.call(&tree, SELECTION, "GetSelectedChild", &(0i32,))?;-    let cell = Node { bus, path };-    let act = w.do_action(&cell, &["activate", "click"])?;-    Ok(format!("{act}:selected row"))-}--/// GTK writes a mnemonic as "_Save"; wx's "&Save" can survive in a name too.-fn clean(label: &str) -> String {-    label.replace(['_', '&'], "").trim().to_lowercase()-}--const BUTTON_ROLES: &[&str] = &["push button", "toggle button", "button"];--/// The dialog's message text: its labels, in order (the Linux stand-in for Win32 Static children).-pub fn dialog_texts(h: u64) -> Result<Vec<String>, String> {-    let (w, f) = frame_for(h)?;-    let mut out = Vec::new();-    w.walk(&f, |n, role, name, _| {-        // only what is on screen: a wizard keeps its other pages' labels in the tree, hidden-        if (role == "label" || role == "static") && w.has_state(n, STATE_SHOWING) {-            let t = if name.is_empty() { w.text(n).unwrap_or_default() } else { name.to_string() };-            if !t.trim().is_empty() {-                out.push(t);-            }-        }-        out.len() < 64-    });-    Ok(out)-}--/// Press a button whose caption is EXACTLY one of `labels` (mnemonic stripped, any case).-pub fn click_button(h: u64, labels: &[&str]) -> Result<String, String> {-    let (w, f) = frame_for(h)?;-    let wanted: Vec<String> = labels.iter().map(|l| clean(l)).collect();-    let mut hit: Option<(Node, String)> = None;-    let mut seen = Vec::new();-    w.walk(&f, |n, role, name, _| {-        if BUTTON_ROLES.contains(&role) {-            if wanted.contains(&clean(name)) && w.has_state(n, STATE_ENABLED) {-                hit = Some((n.clone(), name.to_string()));-                return false;-            }-            if !name.is_empty() {-                seen.push(name.to_string());-            }-        }-        true-    });-    let Some((n, name)) = hit else {-        return Err(format!("control_not_found: no button {labels:?} on {h} (buttons seen: {seen:?})"));-    };-    w.do_action(&n, &["click", "press", "activate"])?;-    Ok(format!("atspi_click:{name}"))-}--/// The first descendant whose name contains `name` (any case), preferring one with an action.-fn find(w: &Walker, f: &Node, name: &str, want: impl Fn(&Walker, &Node, &str) -> bool) -> Option<(Node, String)> {-    let needle = name.to_lowercase();-    let mut exact: Option<(Node, String)> = None;-    let mut first: Option<(Node, String)> = None;-    w.walk(f, |n, role, nm, _| {-        if nm.is_empty() || !nm.to_lowercase().contains(&needle) {-            return true;-        }-        if want(w, n, role) {-            if clean(nm) == clean(name) {-                exact = Some((n.clone(), nm.to_string()));-                return false;-            }-            first.get_or_insert((n.clone(), nm.to_string()));-        }-        true-    });-    exact.or(first)-}--pub fn invoke(h: u64, name: &str) -> Result<String, String> {-    let (w, f) = frame_for(h)?;-    let Some((n, found)) = find(&w, &f, name, |w, n, _| !w.actions(n).is_empty()) else {-        return Err(format!("control_not_found: no descendant of {h} named like {name:?} with an action"));-    };-    let act = w.do_action(&n, &["click", "press", "activate", "toggle"])?;-    Ok(format!("{act}:{found}"))-}--pub fn find_names(h: u64, name: &str, limit: usize) -> Result<Vec<String>, String> {-    let (w, f) = frame_for(h)?;-    let needle = name.to_lowercase();-    let mut out = Vec::new();-    w.walk(&f, |_, _, nm, _| {-        if !nm.is_empty() && nm.to_lowercase().contains(&needle) {-            out.push(nm.to_string());-        }-        out.len() < limit-    });-    Ok(out)-}--/// Replace a text field's contents: the field named like `name`, or (empty name) the first-/// editable text field in the window.-pub fn set_value(h: u64, name: &str, text: &str) -> Result<(), String> {-    let (w, f) = frame_for(h)?;-    let editable = |w: &Walker, n: &Node, _role: &str| w.call::<_, Vec<String>>(n, ACCESSIBLE, "GetInterfaces", &()).unwrap_or_default().iter().any(|i| i == EDITABLE);-    let target = if name.is_empty() {-        let mut t = None;-        w.walk(&f, |n, role, _, _| {-            if editable(&w, n, role) {-                t = Some((n.clone(), String::new()));-                return false;-            }-            true-        });-        t-    } else {-        find(&w, &f, name, |w, n, role| editable(w, n, role))-    };-    let Some((n, found)) = target else {-        return Err(format!("control_not_found: no editable text field named like {name:?} in {h}"));-    };-    let ok: bool = w.call(&n, EDITABLE, "SetTextContents", &(text,))?;-    if ok {-        Ok(())-    } else {-        Err(format!("not_settable: the field {found:?} refused the text"))-    }-}--/// Set the check box captioned exactly `name` on or off.-pub fn set_checked(h: u64, name: &str, on: bool) -> Result<String, String> {-    let (w, f) = frame_for(h)?;-    let want = clean(name);-    let mut hit = None;-    w.walk(&f, |n, role, nm, _| {-        if (role == "check box" || role == "toggle button" || role == "check menu item") && clean(nm) == want {-            hit = Some((n.clone(), nm.to_string()));-            return false;-        }-        true-    });-    let Some((n, found)) = hit else {-        return Err(format!("control_not_found: no check box {name:?} in {h}"));-    };-    if w.has_state(&n, STATE_CHECKED) == on {-        return Ok(format!("unchanged:{found}"));-    }-    w.do_action(&n, &["click", "toggle", "press"])?;-    Ok(format!("set:{found}={}", if on { "on" } else { "off" }))-}--/// GTK's "<Primary><Shift>e" or "<Control>s" keybinding as KiCad's "Ctrl+Shift+E".-fn accel_text(binding: &str) -> Option<String> {-    // AT-SPI's keybinding is "mnemonic;full-path;accelerator"; the accelerator is the last field.-    let acc = binding.rsplit(';').next().unwrap_or(binding).trim();-    if acc.is_empty() {-        return None;-    }-    let mut mods = Vec::new();-    let mut rest = acc;-    while let Some(stripped) = rest.strip_prefix('<') {-        let end = stripped.find('>')?;-        let m = match &stripped[..end].to_lowercase()[..] {-            "control" | "ctrl" | "primary" => "Ctrl",-            "shift" => "Shift",-            "alt" | "mod1" => "Alt",-            "super" | "meta" => "Meta",-            _ => "",-        };-        if !m.is_empty() {-            mods.push(m);-        }-        rest = &stripped[end + 1..];-    }-    if rest.is_empty() {-        return None;-    }-    let key = if rest.chars().count() == 1 { rest.to_uppercase() } else { rest.to_string() };-    mods.push(&key);-    Some(mods.join("+"))-}--fn menu_nodes(w: &Walker, f: &Node) -> Vec<(Node, MenuItem)> {-    let mut out = Vec::new();-    let mut bar_seen = false;-    w.walk(f, |n, role, name, trail| {-        if role == "menu bar" {-            bar_seen = true;-        }-        if (role == "menu item" || role == "check menu item" || role == "radio menu item") && !name.is_empty() {-            // the path from below the menu bar: drop the frame, filler and bar names (empty)-            let mut path: Vec<String> = trail.iter().filter(|s| !s.is_empty()).skip(1).cloned().collect();-            path.push(name.to_string());-            let accel = w.actions(n).first().and_then(|(_, k)| accel_text(k));-            out.push((n.clone(), MenuItem { path, id: out.len() as u32, accel }));-        }-        true-    });-    let _ = bar_seen;-    out-}--pub fn menu_tree(h: u64) -> Result<Vec<MenuItem>, String> {-    let (w, f) = frame_for(h)?;-    let items: Vec<MenuItem> = menu_nodes(&w, &f).into_iter().map(|(_, m)| m).collect();-    if items.is_empty() {-        return Err(format!("no menu items under window {h} on the accessibility bus"));-    }-    Ok(items)-}--/// Invoke menu item `id` from menu_tree (ids are positions in that walk, stable while the menus are).-pub fn menu_invoke(h: u64, id: u32) -> Result<(), String> {-    let (w, f) = frame_for(h)?;-    let nodes = menu_nodes(&w, &f);-    let (n, _) = nodes.get(id as usize).ok_or_else(|| format!("menu item {id} not found (the menu has {} items)", nodes.len()))?;-    w.do_action(n, &["click", "activate"]).map(|_| ())-}--#[cfg(test)]-mod tests {-    #[test]-    fn gtk_accelerators_read_like_kicads() {-        assert_eq!(super::accel_text("<Control>s").as_deref(), Some("Ctrl+S"));-        assert_eq!(super::accel_text("_File;;<Primary><Shift>e").as_deref(), Some("Ctrl+Shift+E"));-        assert_eq!(super::accel_text("<Alt>3").as_deref(), Some("Alt+3"));-        assert_eq!(super::accel_text("F5").as_deref(), Some("F5"));-        assert_eq!(super::accel_text("").as_deref(), None);-        assert_eq!(super::clean("_Save"), "save");-    }-}