master
John Lauer v2.0.701 (insiders): Annotate starts in Multi mode: draw several shapes, press Enter to finish. Space toggles Multi and… 1c42ac3 1mo ago
//! The taskbar ACTIVITY signal (Node v2.0.289 to v2.0.304, `taskbarActivity` = progress | flash | off,
//! default progress). In `progress` mode pup NEVER paints orange: a determinate bar sized to the
//! running average of each operation (icon apply from open until the icon lands, screenshots), an
//! alert is a short full-bar pulse, and the Windows 11 attention tint a background-born window gets
//! is cleared through ab's `desktop_clear_window_attention` (FLASHW_STOP alone never clears it).
//! `flash` = the old finite four-count orange flash on open and on alert. `off` = nothing.
//! openFlash / autoFlash / agentActivity were removed in Node 2.0.292; the Rust port carried them
//! as dead keys until 2.0.425 (John, 2026-09-06: "you seemed to have strayed from that").

use crate::Ctx;
use pup_core::Caller;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;

const DEFAULT_EST_MS: [(&str, u64); 4] = [("icon-apply", 2200), ("screenshot", 900), ("navigate", 2500), ("open", 3200)];
static TIMING: Mutex<Option<HashMap<String, (u64, u64)>>> = Mutex::new(None);   // op -> (n, avgMs), persisted

pub fn mode(ctx: &Ctx) -> String { let m = ctx.settings.get("taskbarActivity", "progress"); if m == "flash" || m == "off" { m } else { "progress".into() } }
pub fn minimal_touch(ctx: &Ctx) -> bool { ctx.settings.get("minimalTouch", "on") == "on" }
fn timing_file(ctx: &Ctx) -> std::path::PathBuf { ctx.host.state_dir().join("pup-verb-timing.json") }

fn load(ctx: &Ctx) {
    let mut g = TIMING.lock().unwrap();
    if g.is_some() { return; }
    let mut m = HashMap::new();
    if let Ok(s) = std::fs::read_to_string(timing_file(ctx)) { if let Ok(v) = serde_json::from_str::<Value>(&s) { if let Some(o) = v.as_object() { for (k, e) in o { m.insert(k.clone(), (e["n"].as_u64().unwrap_or(0), e["avgMs"].as_u64().unwrap_or(0))); } } } }
    *g = Some(m);
}
pub fn estimate_ms(ctx: &Ctx, op: &str) -> u64 {
    load(ctx);
    let g = TIMING.lock().unwrap();
    g.as_ref().and_then(|m| m.get(op)).filter(|(n, a)| *n > 0 && *a > 0).map(|(_, a)| *a)
        .unwrap_or_else(|| DEFAULT_EST_MS.iter().find(|(k, _)| *k == op).map(|(_, v)| *v).unwrap_or(1200))
}
fn record(ctx: &Ctx, op: &str, ms: u64) {
    if ms > 120_000 { return; }
    load(ctx);
    let snapshot = {
        let mut g = TIMING.lock().unwrap(); let m = g.as_mut().unwrap();
        let e = m.entry(op.to_string()).or_insert((0, ms));
        e.1 = if e.0 == 0 { ms } else { (e.1 * 3 + ms) / 4 };   // Node: avg*0.75 + ms*0.25, recent runs weigh more
        e.0 = (e.0 + 1).min(100_000);
        json!(m.iter().map(|(k, (n, a))| (k.clone(), json!({ "n": n, "avgMs": a }))).collect::<serde_json::Map<_, _>>())
    };
    let _ = std::fs::write(timing_file(ctx), snapshot.to_string());
}

fn push(ctx: &Ctx, hwnd: i64, value: Option<u64>) {
    let p = match value { Some(v) => json!({ "state": "normal", "value": (v.min(100) as f64) / 100.0 }), None => json!({ "state": "none" }) };
    let _ = ctx.ab.call("desktop_taskbar", json!({ "hwnd": hwnd, "progress": p }), &crate::identity::internal_caller(), Duration::from_secs(3));
}

/// Node withTaskbarProgress: a bar that fills over the running-average estimate for `op`, capped at
/// 92% until the work really finishes, then 100% and cleared. Records the true duration.
pub fn with_progress<T>(ctx: &Ctx, sid: &str, op: &str, work: impl FnOnce() -> T) -> T {
    let Some(hwnd) = ctx.sessions.get(sid).and_then(|s| s.hwnd) else { return work() };
    let r = progress_on_hwnd(ctx, hwnd, op, work);
    ctx.sessions.with_mut(sid, |x| x.progress_count += 1);
    r
}

/// The ONE progress-bar implementation: a bar that fills over the running-average estimate for `op`,
/// capped at 92% until the work finishes, then 100% and cleared. Both entry points use it — the
/// session-keyed `with_progress` and the extension lane (which has no session), so the lane no longer
/// carries a hand-rolled flat bar (John, 2026-09-13: "why ... you aren't sharing your own code").
pub fn progress_on_hwnd<T>(ctx: &Ctx, hwnd: i64, op: &str, work: impl FnOnce() -> T) -> T {
    // 2.0.699: minimalTouch no longer gates the bar. It is on by default, so this line made the default
    // "progress" activity paint nothing for anyone (John, 2026-10-05: "when do you animate the progress bar now?").
    // taskbarActivity alone decides, as the Settings doc always said the per-feature settings do.
    if !cfg!(windows) || mode(ctx) != "progress" { return work(); }
    let Some(arc) = ctx.arc() else { return work() };
    let est = estimate_ms(ctx, op).max(200); let started = std::time::Instant::now();
    let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); let d2 = done.clone();
    push(ctx, hwnd, Some(2));
    let arc2 = arc.clone();
    std::thread::Builder::new().name("taskbar-progress".into()).spawn(move || {
        while !d2.load(std::sync::atomic::Ordering::Relaxed) {
            std::thread::sleep(Duration::from_millis(180));
            if d2.load(std::sync::atomic::Ordering::Relaxed) { break; }
            let f = (started.elapsed().as_millis() as f64 / est as f64).min(0.92);
            push(&arc2, hwnd, Some(((f * 100.0) as u64).max(2)));
        }
    }).ok();
    let r = work();
    done.store(true, std::sync::atomic::Ordering::Relaxed);
    record(ctx, op, started.elapsed().as_millis() as u64);
    push(ctx, hwnd, Some(100));
    std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(220)); push(&arc, hwnd, None); });
    r
}

/// Node startIconApplyProgress: from open (earliest) until the taskbar icon actually lands (latest),
/// timed to the running average of past icon applies; resolves the hwnd once it exists.
pub fn start_icon_apply(ctx: Arc<Ctx>, sid: String) {
    if !cfg!(windows) || mode(&ctx) != "progress" { return; }   // 2.0.699: not gated by minimalTouch (see progress_on_hwnd)
    let est = estimate_ms(&ctx, "icon-apply").max(200);
    std::thread::Builder::new().name("icon-apply-progress".into()).spawn(move || {
        let started = std::time::Instant::now(); let mut hwnd: Option<i64> = None; let mut landed = false;
        for _ in 0..40 {
            let Some(s) = ctx.sessions.get(&sid) else { break };
            if s.icon_applied { landed = true; break; }
            if hwnd.is_none() { hwnd = s.hwnd; }
            if let Some(h) = hwnd { let f = (started.elapsed().as_millis() as f64 / est as f64).min(0.92); push(&ctx, h, Some(((f * 100.0) as u64).max(2))); }
            std::thread::sleep(Duration::from_millis(200));
        }
        if let Some(h) = hwnd { if landed { record(&ctx, "icon-apply", started.elapsed().as_millis() as u64); push(&ctx, h, Some(100)); std::thread::sleep(Duration::from_millis(220)); } push(&ctx, h, None); }
    }).ok();
}
pub fn mark_icon_applied(ctx: &Ctx, sid: &str) { ctx.sessions.with_mut(sid, |x| x.icon_applied = true); }

/// Node osClearFlashByActivate (v1.8.45 / v2.0.27): the Windows 11 attention tint a background-born
/// window wears (the OS flashes a window it denied the foreground) clears only on activation; ab's
/// `desktop_clear_window_attention` does the off-screen activate-and-restore with net-zero focus.
/// Skipped when the window already IS the foreground (nothing to clear, and it would be a no-op).
pub fn clear_attention(ctx: &Ctx, hwnd: i64, caller: &Caller) -> String {
    if !cfg!(windows) { return "not-windows".into(); }
    if let Ok(fg) = ctx.ab.call("desktop_get_foreground", json!({}), caller, Duration::from_secs(4)) { if fg["hwnd"].as_i64() == Some(hwnd) { return "already-foreground".into(); } }
    let r = match ctx.ab.call("desktop_clear_window_attention", json!({ "hwnd": hwnd }), caller, Duration::from_secs(8)) {
        Ok(r) => if r["cleared"].as_bool() == Some(true) || r["ok"].as_bool() == Some(true) { "cleared".to_string() } else { "error".to_string() },
        Err(e) => format!("error: {e}"),
    };
    // 2.0.585 (adom-bridge#199): ab's clear RAISES the window to z1 (measured: z16 -> z1 on every call), which undid
    // every park at open and after every navigation. We only get here when the window is NOT the user's foreground
    // (checked above), so putting it back at the bottom restores the resting state and can never take a window
    // from the user. Remove once ab's verb stops touching placement.
    // 2.0.588: re-check AFTER the clear (the user may have raised it during the round trip): a foreground window
    // is the user's and is never touched.
    if ctx.host.foreground() != hwnd { ctx.host.send_to_bottom_no_activate(hwnd); }
    r
}

/// The birth signal: `flash` = finite four-count orange flash (never forever); `progress` = the
/// icon-apply bar (started by the caller) plus the attention-tint clear; `off` = only the clear.
/// 2.0.637: clear the attention tint the open/navigate flash leaves, WITHOUT ab. ab's clear verb raises and
/// briefly activates the window (adom-bridge#199): the z-watch saw every newborn become the FOREGROUND window
/// for 40-80 ms about a second after the jump list committed, which the foreground gate caught one run in three.
/// Let the pulses show, then stop the flash natively; a window the user has foregrounded is left alone.
pub fn clear_attention_later(ctx: &Ctx, hwnd: i64) {
    if !cfg!(windows) { return; }
    let Some(a) = ctx.arc() else { return };
    std::thread::Builder::new().name("flash-stop".into()).spawn(move || {
        std::thread::sleep(Duration::from_millis(2500));
        if a.host.foreground() != hwnd { a.host.stop_flash(hwnd); }
    }).ok();
}
pub fn on_open(ctx: &Ctx, sid: &str, hwnd: i64, caller: &Caller) {
    if !cfg!(windows) { return; }
    if mode(ctx) == "flash" && !minimal_touch(ctx) { let _ = ctx.ab.call("desktop_flash_window", json!({ "hwnd": hwnd, "mode": "count", "count": 4 }), caller, Duration::from_secs(6)); ctx.sessions.with_mut(sid, |x| x.flash_count += 1); }
}