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
1.0.43: 1.0.43: the taskbar says Adom is driving KiCad again (port of the Python build's win_taskbar, dropped by the Rust port: the overlayBadges and activityIndicator settings did nothing from 1.0.0; wiki #113). Overlay badge (overlayBadges, default on): the Adom mark sits on every KiCad window's taskbar button while a verb acts on KiCad, whoever launched it, and comes off five minutes after the last verb, so a badge always means Adom is driving this right now. Progress bar (activityIndicator progress or both): the bridge's measured live progress on every KiCad button while a verb is in flight (the slowest verb's percent, a marquee when there is no estimate), cleared the moment the work stops. Read-only polls (kicad_state, kicad_window_info and the other reads the dashboard repeats) do not count as driving, or the badge would never clear. Both switches apply at once from the dashboard. Done through Adom Bridge's desktop_taskbar, like the Python: no COM here, no AUMID touched.
6 files changed
+265−4
rust/Cargo.lock+3−3@@ -244,7 +244,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.42"+version = "1.0.43" dependencies = [ "kicad-core", "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.42"+version = "1.0.43" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -281,7 +281,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.42"+version = "1.0.43" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.42"+version = "1.0.43" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/main.rs+1@@ -13,6 +13,7 @@ mod util; mod verbs; mod verbs_demo; mod verbs_install;+mod taskbar; mod verbs_libraries; mod verbs_lint; mod verbs_maint;
rust/crates/kicad-bridge/src/taskbar.rsadded+257@@ -0,0 +1,257 @@+//! The taskbar says "Adom is driving KiCad" (port of handlers/win_taskbar.py, which the Rust+//! port dropped: the `overlayBadges` and `activityIndicator` settings did nothing from 1.0.0+//! until 1.0.43; wiki #113, John 2026-09-21: "get that stuff ported").+//!+//! - **Overlay badge** (`overlayBadges`, default on): the Adom mark on a KiCad window's taskbar+//! button while verbs run against KiCad, whoever launched it, and OFF again five minutes after+//! the last verb, so a badge always means "Adom is driving this right now" and never decays+//! into wallpaper.+//! - **Progress bar** (`activityIndicator` = progress | both): the bridge's MEASURED live+//! progress mirrored onto every KiCad button while a verb is in flight, cleared the moment+//! the work stops. A bar informs and removes itself; Windows' orange flash demands a click.+//!+//! Like the Python, none of this touches COM or an AUMID: Adom Bridge owns the taskbar, and+//! its `desktop_taskbar {hwnd, overlay?, progress?}` verb does the work. Everything here is+//! best-effort on a background thread: it never blocks a verb and never fails one.++use std::collections::HashSet;+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};+use std::sync::Mutex;+use std::time::{Duration, Instant};++use serde_json::{json, Value};++use kicad_core::{ab, progress, settings};++/// "Adom is no longer driving KiCad, it's yours."+const IDLE_CLEAR: Duration = Duration::from_secs(300);+/// A verb that ended this recently still counts as driving (back-to-back verbs keep one bar).+const DRIVE_TAIL: Duration = Duration::from_millis(3500);+const TICK: Duration = Duration::from_secs(2);++static INFLIGHT: AtomicUsize = AtomicUsize::new(0);+static TICKER_ON: AtomicBool = AtomicBool::new(false);+static LAST_DRIVE: Mutex<Option<Instant>> = Mutex::new(None);+static BADGED: Mutex<Option<HashSet<u64>>> = Mutex::new(None);+static BADGE_B64: Mutex<Option<String>> = Mutex::new(None);++/// Verbs that only LOOK at KiCad. The dashboard polls some of these every minute; counting them+/// as driving would keep the badge on for ever and defeat the five-minute clear.+const READ_ONLY: &[&str] = &[+ "kicad_state", "kicad_window_info", "kicad_open_editors", "kicad_menu_items", "kicad_screenshot_all",+ "kicad_routing_state", "kicad_zone_state", "kicad_board_pads", "kicad_placement_state", "kicad_text_bounds",+];++pub fn counts_as_driving(command: &str, mechanism: &str) -> bool {+ matches!(mechanism, "window" | "ipc") && !READ_ONLY.contains(&command)+}++/// RAII pair of the Python `note_driving` / `note_done`: held for the length of a verb.+pub struct Driving(bool);++pub fn note_driving(command: &str, mechanism: &str) -> Driving {+ if !counts_as_driving(command, mechanism) {+ return Driving(false);+ }+ INFLIGHT.fetch_add(1, Ordering::SeqCst);+ touch();+ if !TICKER_ON.swap(true, Ordering::SeqCst) {+ let _ = std::thread::Builder::new().name("kicad-taskbar".into()).spawn(ticker);+ }+ Driving(true)+}++impl Drop for Driving {+ fn drop(&mut self) {+ if self.0 {+ let _ = INFLIGHT.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| Some(n.saturating_sub(1)));+ touch(); // the idle clock starts when the LAST verb ends+ }+ }+}++fn touch() {+ if let Ok(mut g) = LAST_DRIVE.lock() {+ *g = Some(Instant::now());+ }+}++fn since_last_drive() -> Option<Duration> {+ LAST_DRIVE.lock().ok().and_then(|g| g.map(|t| t.elapsed()))+}++/// The badge PNG ships in the zip as resources/adom-overlay-32.png, next to the exe.+fn badge_b64() -> Option<String> {+ let mut g = BADGE_B64.lock().ok()?;+ if g.is_none() {+ let exe = std::env::current_exe().ok()?;+ let bytes = exe.ancestors().skip(1).take(3).find_map(|d| std::fs::read(d.join("resources").join("adom-overlay-32.png")).ok())?;+ *g = Some(base64(&bytes));+ }+ g.clone()+}++fn base64(bytes: &[u8]) -> String {+ const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+ let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);+ for c in bytes.chunks(3) {+ let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;+ out.push(T[(n >> 18) as usize & 63] as char);+ out.push(T[(n >> 12) as usize & 63] as char);+ out.push(if c.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });+ out.push(if c.len() > 2 { T[n as usize & 63] as char } else { '=' });+ }+ out+}++fn setting_bool(key: &str, default: bool) -> bool {+ settings::get_settings().get(key).and_then(Value::as_bool).unwrap_or(default)+}++fn activity_mode() -> String {+ settings::get_settings().get("activityIndicator").and_then(Value::as_str).unwrap_or("progress").to_string()+}++fn kicad_hwnds() -> Vec<u64> {+ crate::verbs_windows::kicad_windows().into_iter().map(|w| w.hwnd).collect()+}++fn with_badged<R>(f: impl FnOnce(&mut HashSet<u64>) -> R) -> Option<R> {+ let mut g = BADGED.lock().ok()?;+ Some(f(g.get_or_insert_with(HashSet::new)))+}++fn apply_badge(h: u64) {+ if with_badged(|b| b.contains(&h)).unwrap_or(true) {+ return;+ }+ let Some(icon) = badge_b64() else { return };+ if ab::desktop_taskbar(h, json!({"overlay": {"icon": icon, "tooltip": "Adom is driving this KiCad window"}})).is_ok() {+ with_badged(|b| b.insert(h));+ }+}++fn clear_badge(h: u64) {+ let _ = ab::desktop_taskbar(h, json!({"overlay": {"badge": "none"}}));+ with_badged(|b| b.remove(&h));+}++/// Called after a settings write: the switch takes effect at once in both directions.+pub fn settings_changed() {+ if !setting_bool("overlayBadges", true) {+ for h in with_badged(|b| b.iter().copied().collect::<Vec<_>>()).unwrap_or_default() {+ clear_badge(h);+ }+ }+ if !matches!(activity_mode().as_str(), "progress" | "both") {+ for h in kicad_hwnds() {+ let _ = ab::desktop_taskbar(h, json!({"progress": {"state": "none"}}));+ }+ }+}++/// The smallest measured percent among the verbs in flight, or None for "working, no estimate".+pub fn live_percent(frames: &[Value]) -> Option<i64> {+ frames.iter().filter_map(|f| f.get("percent").and_then(Value::as_i64)).min()+}++fn ticker() {+ let mut painted: HashSet<u64> = HashSet::new();+ let mut idle_ticks = 0u32;+ loop {+ std::thread::sleep(TICK);+ if !ab::available() {+ continue;+ }+ let driving = INFLIGHT.load(Ordering::SeqCst) > 0 || since_last_drive().is_some_and(|d| d < DRIVE_TAIL);+ if driving {+ idle_ticks = 0;+ let hwnds = kicad_hwnds();+ let mode = activity_mode();+ let bar = matches!(mode.as_str(), "progress" | "both");+ let badge = setting_bool("overlayBadges", true);+ let pct = live_percent(&progress::live(false, None));+ for h in hwnds {+ if badge {+ apply_badge(h);+ }+ if bar {+ let p = match pct {+ Some(p) => json!({"state": "normal", "value": p.clamp(1, 100)}),+ None => json!({"state": "indeterminate"}),+ };+ if ab::desktop_taskbar(h, json!({"progress": p})).is_ok() {+ painted.insert(h);+ }+ }+ }+ continue;+ }+ // Not driving: the bars vanish WITH the work; the badge stays five minutes, then goes.+ for h in painted.drain() {+ let _ = ab::desktop_taskbar(h, json!({"progress": {"state": "none"}}));+ }+ idle_ticks += 1;+ if since_last_drive().is_some_and(|d| d >= IDLE_CLEAR) {+ let mut all: HashSet<u64> = kicad_hwnds().into_iter().collect();+ all.extend(with_badged(|b| b.iter().copied().collect::<Vec<_>>()).unwrap_or_default());+ let had = with_badged(|b| !b.is_empty()).unwrap_or(false);+ if had {+ for h in all {+ clear_badge(h);+ }+ }+ if idle_ticks > 5 {+ TICKER_ON.store(false, Ordering::SeqCst);+ return; // the next driving verb starts a fresh ticker+ }+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn only_verbs_that_act_on_kicad_count_as_driving() {+ assert!(counts_as_driving("kicad_open_board", "window"));+ assert!(counts_as_driving("kicad_route_net", "ipc"));+ // the dashboard polls these; they must not keep the badge alive for ever+ assert!(!counts_as_driving("kicad_state", "window"));+ assert!(!counts_as_driving("kicad_window_info", "window"));+ assert!(!counts_as_driving("kicad_routing_state", "ipc"));+ // headless work never touches a KiCad window+ assert!(!counts_as_driving("kicad_run_drc", "cli"));+ assert!(!counts_as_driving("kicad_status", "local"));+ }++ #[test]+ fn the_bar_shows_the_slowest_verb_and_indeterminate_without_an_estimate() {+ assert_eq!(live_percent(&[json!({"percent": 80}), json!({"percent": 35})]), Some(35));+ assert_eq!(live_percent(&[json!({"phase": "x"})]), None);+ assert_eq!(live_percent(&[]), None);+ }++ #[test]+ fn base64_matches_the_standard_alphabet_and_padding() {+ assert_eq!(base64(b""), "");+ assert_eq!(base64(b"f"), "Zg==");+ assert_eq!(base64(b"fo"), "Zm8=");+ assert_eq!(base64(b"foobar"), "Zm9vYmFy");+ }++ #[test]+ fn the_driving_guard_counts_in_and_out() {+ let before = INFLIGHT.load(Ordering::SeqCst);+ {+ let _d = Driving(true);+ INFLIGHT.fetch_add(1, Ordering::SeqCst);+ assert_eq!(INFLIGHT.load(Ordering::SeqCst), before + 1);+ }+ assert_eq!(INFLIGHT.load(Ordering::SeqCst), before);+ let _n = Driving(false); // a read-only verb never decrements+ drop(_n);+ assert_eq!(INFLIGHT.load(Ordering::SeqCst), before);+ }+}
rust/crates/kicad-bridge/src/verbs.rs+2@@ -44,6 +44,8 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal kicad_core::ab::set_caller(kicad_core::ab::caller_from(args, &_caller.thread, &_caller.container, &_caller.reason)); // Every run is timed and logged; the 20 instrumented verbs get the terminal progress block. let mut prog = kicad_core::progress::begin(command, crate::verbs_show::kicad_running);+ // Taskbar: the Adom badge and the progress bar while this verb drives KiCad (dropped at return).+ let _driving = crate::taskbar::note_driving(command, verb.mechanism.as_str()); // Verb groups ported later live in their own files; each returns Some when it owns the verb. if let Some(mut out) = crate::groups::dispatch(state, command, args) { prog.attach(&mut out);
rust/crates/kicad-bridge/src/verbs_libraries.rs+1@@ -96,6 +96,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_set_settings" => { let out = settings::set_settings(args); apply_background_guard();+ crate::taskbar::settings_changed(); out } "kicad_list_design_rules" => design_rules::list_design_rules(arg_str(args, "fab")),