app
Parts Search
Public Made by Adomby adom
One search box across Mouser, DigiKey and JLCPCB: parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time.
master
John Lauer
v1.1.0: Dock Bar ready page with a settings modal, Say it strip, bundled brand fonts, taskbar progress, and AI drive ve…
50738de
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
//! Windows taskbar progress on the Hydrogen button while a search runs,
//! painted through Adom Desktop's `desktop_taskbar` verb (ITaskbarList3).
//!
//! Determinate bars are sized from this app's own measured search
//! durations (~/.adom/parts-search-timing.json, recent runs weighted),
//! cap at 92% until the search really finishes, then jump to 100 and
//! clear. An AI-driven search (the `show` verb, open-ended) paints an
//! INDETERMINATE marquee instead, so the user sees activity without a
//! made-up estimate. Every bridge call is fire-and-forget on a thread
//! and bounded, so a missing desktop can never slow a search down.
use serde_json::{json, Value};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
const AI_THREAD: &str = "Parts Search";
const WINDOW_TITLE: &str = "Hydrogen";
const CAP_PERCENT: u64 = 92;
fn timing_path() -> std::path::PathBuf { crate::settings::adom_dir().join("parts-search-timing.json") }
/// Average search duration in ms, recent-weighted. None until measured.
pub fn average_ms() -> Option<u64> {
let raw = std::fs::read_to_string(timing_path()).ok()?;
let v: Value = serde_json::from_str(&raw).ok()?;
v.get("search").and_then(|s| s.get("avg_ms")).and_then(|n| n.as_u64()).filter(|n| *n > 0)
}
pub fn record_ms(ms: u64) {
let mut v: Value = std::fs::read_to_string(timing_path()).ok()
.and_then(|r| serde_json::from_str(&r).ok()).unwrap_or(json!({}));
let prev = v.get("search").and_then(|s| s.get("avg_ms")).and_then(|n| n.as_u64()).unwrap_or(0);
let runs = v.get("search").and_then(|s| s.get("runs")).and_then(|n| n.as_u64()).unwrap_or(0);
// Exponential moving average, alpha 0.3: recent runs dominate.
let avg = if prev == 0 { ms } else { (prev as f64 * 0.7 + ms as f64 * 0.3) as u64 };
v["search"] = json!({"avg_ms": avg, "runs": runs + 1, "last_ms": ms});
let _ = std::fs::create_dir_all(crate::settings::adom_dir());
let _ = std::fs::write(timing_path(), serde_json::to_string_pretty(&v).unwrap_or_default());
}
/// Which box? Settings can pin one; otherwise the first connected target.
/// Resolved per run (cheap, ~10ms) so a box that reconnects is found.
fn resolve_target(pinned: &str) -> Option<String> {
if !pinned.trim().is_empty() { return Some(pinned.trim().to_string()); }
let out = Command::new("adom-bridge").arg("targets")
.stdin(Stdio::null()).output().ok()?;
let v: Value = serde_json::from_slice(&out.stdout).ok()?;
v.get("targets")?.as_array()?.first()?.get("name")?.as_str().map(|s| s.to_string())
}
pub fn list_targets() -> Vec<String> {
let Ok(out) = Command::new("adom-bridge").arg("targets").stdin(Stdio::null()).output() else { return vec![] };
let Ok(v) = serde_json::from_slice::<Value>(&out.stdout) else { return vec![] };
v.get("targets").and_then(|t| t.as_array()).map(|a| {
a.iter().filter_map(|t| t.get("name").and_then(|n| n.as_str()).map(|s| s.to_string())).collect()
}).unwrap_or_default()
}
fn paint(target: &str, progress: Value) -> bool {
let args = json!({"titleContains": WINDOW_TITLE, "progress": progress}).to_string();
let out = Command::new("adom-bridge")
.args(["--ai-thread", AI_THREAD, "--target", target, "desktop_taskbar", &args])
.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null())
.output();
match out {
Ok(o) => serde_json::from_slice::<Value>(&o.stdout).ok()
.map(|v| v.get("success").and_then(|b| b.as_bool()).unwrap_or(false)).unwrap_or(false),
Err(_) => false,
}
}
/// Handle returned to the search; call `finish()` when the search ends.
pub struct Bar { done: Arc<AtomicBool> }
impl Bar {
pub fn finish(&self, ok: bool) {
// finish() is idempotent; the worker thread reads the flag.
let _ = ok;
self.done.store(true, Ordering::SeqCst);
}
}
/// Start a bar. `indeterminate` = AI-driven open-ended search.
pub fn start(enabled: bool, pinned_box: &str, indeterminate: bool) -> Option<Bar> {
if !enabled { return None; }
let done = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&done);
let pinned = pinned_box.to_string();
std::thread::spawn(move || {
let Some(target) = resolve_target(&pinned) else { return };
if indeterminate {
if !paint(&target, json!({"state": "indeterminate"})) { return; }
while !flag.load(Ordering::SeqCst) { std::thread::sleep(Duration::from_millis(150)); }
paint(&target, json!({"state": "normal", "value": 100}));
std::thread::sleep(Duration::from_millis(350));
paint(&target, json!({"state": "none"}));
return;
}
let est = average_ms().unwrap_or(4000).max(800) as f64;
let t0 = Instant::now();
if !paint(&target, json!({"state": "normal", "value": 2})) { return; }
let mut last = 2u64;
while !flag.load(Ordering::SeqCst) {
std::thread::sleep(Duration::from_millis(300));
let pct = ((t0.elapsed().as_millis() as f64 / est) * 100.0) as u64;
let pct = pct.min(CAP_PERCENT).max(2);
if pct != last { paint(&target, json!({"state": "normal", "value": pct})); last = pct; }
}
paint(&target, json!({"state": "normal", "value": 100}));
std::thread::sleep(Duration::from_millis(350));
paint(&target, json!({"state": "none"}));
});
Some(Bar { done })
}