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
//! 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 })
}