app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Delete rust/crates/kicad-platform/src/linux/atspi.rs
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(®istry) {- 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");- }-}