跳转至

crates\nfarms-gui\src\pet_window.rs

源码路径:crates\nfarms-gui\src\pet_window.rs · 1229 行

//! 桌宠:一个 Win32 **分层窗口**(逐像素 alpha)。
//!
//! # 为什么不用 egui 的 `show_viewport_immediate`
//!
//! 因为**做不到真透明**,而且这不是猜测 —— 是实测过的:
//!
//! * 立即视口是**渲染进父窗口那一帧**的,没有自己的 GL 表面。所以
//!   `ViewportBuilder::with_transparent(true)` 会被忽略,帧缓冲根本没有
//!   alpha 通道;
//! * 把 `clear_color` 清零也没用,它只会把桌宠变成一个**不透明黑块**
//!   (实测:主窗口里桌宠那块平均亮度 69.7,同一高度带旁边 192.8);
//! * 而且 `clear_color` 是**整帧一次、不按视口分**的,清零会连主窗口一起
//!   拖下水(早先两次尝试就是这么"白屏"的)。
//!
//! # 这里用的办法
//!
//! `UpdateLayeredWindow`:把一张**预乘 alpha** 的 32 位位图整个交给系统,
//! 合成完全由 DWM 负责。于是天然得到:
//!
//! * **真透明** —— 逐像素 alpha,不是抠一个颜色键;
//! * **置顶**、**无边框**、**不进任务栏**(`WS_EX_TOPMOST` /
//!   `WS_POPUP` / `WS_EX_TOOLWINDOW`);
//! * **不闪** —— 整窗一次性替换,没有重绘过程。
//!
//! 这正是桌面宠物在 Windows 上的标准做法,也是在 egui 里做不到的那部分:
//! egui 的所有渲染抽象都假设「窗口有一个不透明背景」。
//!
//! # 为什么要独立线程
//!
//! 窗口要跑自己的消息循环(`GetMessageW`),而主线程已经被 eframe/winit
//! 的事件循环占着。所以这里起一个线程,UI 侧通过 channel 发指令。
//!
//! # 交互
//!
//! * **左键拖动** —— 用 `WM_NCLBUTTONDOWN` + `HTCAPTION` 交给系统拖,
//!   不自己算位移(高 DPI 下自己算会飘);
//! * **右键** —— 直接关掉桌宠(不做弹出菜单:一个宠物不值得一套菜单,
//!   而且菜单要 `TrackPopupMenu`,它对窗口 z 序和前景权有额外要求)。
#![cfg(windows)]

use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicPtr, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
// `PetVisibility` 的实现体在 `pump` 外面,所以这几个窗口 API 得在模块级导入。
// `pump` 里原来那份同名导入可以照旧 —— 内层作用域的导入会遮住外层的,
// 两处指的是同一个东西,不冲突。
use windows_sys::Win32::Foundation::HWND;
use windows_sys::Win32::UI::WindowsAndMessaging::{
    KillTimer, SW_HIDE, SW_SHOW, SetTimer, ShowWindow,
};
// 桌宠的 GDI 资源(见 `GdiSurface`)。这几个类型和释放函数都必须在**模块级**
// 命名 —— 用它们的 `Drop` 是个模块级 impl,不属于 `pump` 那一个函数的作用域。
use windows_sys::Win32::Graphics::Gdi::{
    BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleDC, CreateDIBSection, DIB_RGB_COLORS,
    DeleteDC, DeleteObject, GetDC, HBITMAP, HDC, HGDIOBJ, ReleaseDC, SelectObject,
};

/// 屏幕右下角的默认边距(物理像素)。
const DEFAULT_PET_BASE: i32 = 0;

/// 桌宠**当前**所在的左上角。
///
/// 位置必须单独记,不能让 pump 循环每帧拿初始值去写位置 —— 那样用户一拖动,
/// 33ms 后就被强行拉回原点(实测就是这个症状)。
/// 系统拖动结束时 `WM_EXITSIZEMOVE` 会把新位置写进来。
///
/// ("桌宠现在显示着吗"这件事**不**在这里存一份:真相在 pump 的
/// `PetVisibility::shown` 里,右键收起靠 `PET_HIDDEN` + `DISMISSED` 通知。
/// 而且右键收起**不销毁窗口**,不依赖这份状态去决定"要不要重建"。)
static BASE_X: AtomicI32 = AtomicI32::new(DEFAULT_PET_BASE);
static BASE_Y: AtomicI32 = AtomicI32::new(DEFAULT_PET_BASE);

/// "把窗口藏起来"这个动作**待办**。
///
/// 右键收起发生在 wndproc 里,而窗口的显隐由 pump 的 `PetVisibility` 管 ——
/// wndproc 不该绕过它自己去 `ShowWindow`(那样 `shown` 就和真实状态对不上,
/// 再显示时 `show()` 会以为窗口还开着而什么都不做,宠物就真回不来了)。
/// 所以这里只挂一个待办,pump 下一轮循环自己执行。
static PET_HIDDEN: AtomicBool = AtomicBool::new(false);

/// 用户**右键**把桌宠收起来了(不是勾掉设置里的开关)。
///
/// 只由 wndproc 的 `WM_RBUTTONUP` 分支置位 —— 别的地方不要动它,它是
/// "用户用右键收起了"这件事本身,不是"窗口现在可见吗"的查询口。
///
/// 取走这个标志 —— 取完就清,所以 UI 每帧调它也只会看到一次 true,
/// 与 `take_click()` 同一套约定。
static DISMISSED: AtomicBool = AtomicBool::new(false);

/// 右键收起过一次吗?取走这个标志(取完就清)。
///
/// UI 侧拿到 true 就该把「显示桌宠」的勾一起去掉 —— 否则界面显示"开着"
/// 而桌面上什么都没有,而且用户取消勾选时 `want=false == pet_shown=false`
/// 会让那句 `pet.hide()` **发不出去**,再勾回来时又因为 `want=true !=
/// pet_shown=false` 才补发一次 `show()`:桌宠要"关一下开一下"才回得来。
pub fn dismiss() -> bool {
    DISMISSED.swap(false, Ordering::Relaxed)
}

/// 能改变桌宠窗口显隐的一件事。
///
/// UI 线程发的三条指令都在里面,外加**右键收起** —— 后者发生在 wndproc 里,
/// 不是 channel 来的,但它对窗口做的事和另外三条同级,所以并进同一个入口。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PetEvent {
    /// UI 勾上了「显示桌宠」。
    Show,
    /// UI 取消了勾选。
    Hide,
    /// 用户在桌宠上按了右键。
    Dismiss,
    /// 程序要退了(`PetWindow` 的 `Drop`)。
    Quit,
}

/// `apply` 之后要回头告诉 UI 的事。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PetNotice {
    /// 没什么要通知的,照做即可。
    Ready,
    /// 用户刚刚用右键收起了桌宠 —— UI 得把「显示桌宠」的勾一起去掉。
    Dismissed,
    /// 窗口已经不在(`HWND` 空),这件事做不了。
    Failed,
}

/// pump 消息循环里那份"窗口现在是什么样"的状态。
///
/// 单独拎出来**只是为了能测**:这段逻辑原来是一串局部变量 + 内联副作用,
/// 而它就是出过事的地方 —— 右键那次销毁把窗口弄没了,状态却还写着"显示中",
/// 于是循环对着一个已销毁的 `HWND` 一直调 `UpdateLayeredWindow`,而且再也
/// 显示不回来。抽成纯状态机之后,"收起之后还能不能再显示回来"就能被断言。
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct PetVisibility {
    /// 窗口当前**真的**可见(`ShowWindow` 的最后一次意图)。
    shown: bool,
    /// 定时器开着吗?拿它避免重复 `SetTimer`。
    timer: bool,
    /// 上下浮动的相位。`WM_TIMER` 里加,不显示时也照加 —— 让宠物重新出现时
    /// 接着原来的节奏,而不是每次都从最高点开始(行为同修改前)。
    phase_bits: u32,
}

impl PetVisibility {
    /// 从 pump 的现有局部变量建一份,值原样搬过来。
    fn from_pump(shown: bool, phase: f32) -> Self {
        Self {
            shown,
            timer: false,
            phase_bits: phase.to_bits(),
        }
    }

    fn phase(&self) -> f32 {
        f32::from_bits(self.phase_bits)
    }

    /// `WM_TIMER` 到了:推进浮动相位。
    fn advance(&mut self) {
        let next = self.phase() + 0.12;
        self.phase_bits = next.to_bits();
    }

    /// 把窗口显示出来并起定时器。
    fn show(&mut self, hwnd: HWND) -> bool {
        if hwnd.is_null() {
            return false;
        }
        if !self.shown {
            unsafe { ShowWindow(hwnd, SW_SHOW) };
            self.shown = true;
        }
        // 定时器只在没有的时候起。藏起来时 `hide()` 已经 `KillTimer` 了,
        // 再显示需要重新起;而本来就在显示时重复 `SetTimer` 同 id 虽然无害,
        // 但没必要。
        if !self.timer {
            unsafe { SetTimer(hwnd, PET_TIMER_ID, PET_TICK_MS, None) };
            self.timer = true;
        }
        true
    }

    /// 把窗口藏起来。
    ///
    /// **顺带停掉 33ms 的定时器**:原来只有 `Show` 起定时器、没有任何地方停,
    /// 藏起来之后 `WM_TIMER` 还在按 33ms 往队列里塞消息,纯属白烧 CPU。
    fn hide(&mut self, hwnd: HWND) -> bool {
        if hwnd.is_null() {
            return false;
        }
        if self.shown {
            unsafe { ShowWindow(hwnd, SW_HIDE) };
            self.shown = false;
        }
        if self.timer {
            unsafe { KillTimer(hwnd, PET_TIMER_ID) };
            self.timer = false;
        }
        true
    }

    /// 执行一件事件,返回要不要通知 UI(现在是"刚刚被右键收起了")。
    ///
    /// **所有改变窗口显隐的事都必须经过这里** —— 这是为了让"右键之后还能不能
    /// 显示回来"变成可断言的性质。之前正是因为没有这层:右键那条路自己
    /// `DestroyWindow`,状态机完全不知情,于是 `shown` 永远停在 true、
    /// 循环对着死句柄空转、宠物再也回不来(而单测又够不着 wndproc,守不住)。
    fn apply(&mut self, ev: PetEvent, hwnd: HWND) -> PetNotice {
        match ev {
            PetEvent::Show => {
                if self.show(hwnd) {
                    PetNotice::Ready
                } else {
                    PetNotice::Failed
                }
            }
            PetEvent::Hide => {
                if self.hide(hwnd) {
                    PetNotice::Ready
                } else {
                    PetNotice::Failed
                }
            }
            // 右键收起:**藏起来,不销毁窗口**。
            //
            // 复用的就是 Hide 那条路(`self.hide`),所以两者在窗口层做的事
            // 完全一致 —— 右键不会比"勾掉开关"多出任何破坏性动作。
            PetEvent::Dismiss => {
                if self.hide(hwnd) {
                    PetNotice::Dismissed
                } else {
                    PetNotice::Failed
                }
            }
            // 程序退出:停掉定时器,窗口留给调用方销毁。
            PetEvent::Quit => {
                self.hide(hwnd);
                PetNotice::Ready
            }
        }
    }
}

// ---- 鼠标:区分「点一下」和「拖一下」----
//
// 这两件事要做的事完全不同(点 = 开对话框,拖 = 挪位置),而它们都以
// `WM_LBUTTONDOWN` 开始。所以按下的那一刻**先不拖**,等移动超过阈值再交给系统;
// 松手时还没超过阈值,才算一次点击。
static PRESSED: AtomicBool = AtomicBool::new(false);
static DRAGGING: AtomicBool = AtomicBool::new(false);
static CLICKED: AtomicBool = AtomicBool::new(false);
static PRESS_X: AtomicI32 = AtomicI32::new(0);
static PRESS_Y: AtomicI32 = AtomicI32::new(0);

/// 桌宠被**点击**了(不是拖动)。取走这个标志 —— 取完就清,
/// 所以 UI 每帧调它也只会看到一次 true。
pub fn take_click() -> bool {
    CLICKED.swap(false, Ordering::Relaxed)
}

// 命中测试要按 alpha 判断,所以得让 wndproc 能看到像素。
// 直接存 DIB 的裸指针:DIB 的生命周期和窗口一样长,不会先释放。
static BITS: AtomicPtr<u8> = AtomicPtr::new(std::ptr::null_mut());
static TEX_W: AtomicI32 = AtomicI32::new(0);
static TEX_H: AtomicI32 = AtomicI32::new(0);

/// 桌宠的目标高度(物理像素)。参考图是竖构图 1120x1856,不缩会占半个屏幕。
const PET_MAX_H: u32 = 512;

/// 浮动定时器的 id 与间隔。33ms ≈ 30Hz,桌宠只是个上下浮动的小图,不用更快。
/// 抽成常量是因为现在有 `SetTimer` 和 `KillTimer` 两个地方要用它,写死两处
/// 迟早会对不上号 —— 对不上号的后果是停不掉那个定时器。
const PET_TIMER_ID: usize = 1;
const PET_TICK_MS: u32 = 33;

/// UI 线程发给桌宠线程的指令。
#[derive(Debug, Clone, Copy)]
enum Cmd {
    Show,
    Hide,
    Quit,
}

/// 桌宠窗口的句柄。UI 侧只拿它发指令。
pub struct PetWindow {
    tx: Sender<Cmd>,
}

impl PetWindow {
    /// 起线程并把图加载好。**加载失败不会 panic** —— 桌宠画不出来不该拖垮整个程序。
    pub fn spawn(path: PathBuf) -> Self {
        let (tx, rx) = mpsc::channel();
        std::thread::Builder::new()
            .name("nfarms-pet".to_string())
            .spawn(move || {
                if let Err(e) = run(path, rx) {
                    eprintln!("[pet] 桌宠窗口起不来: {e}");
                }
            })
            .ok();
        Self { tx }
    }

    pub fn show(&self) {
        let _ = self.tx.send(Cmd::Show);
    }

    pub fn hide(&self) {
        let _ = self.tx.send(Cmd::Hide);
    }
}

impl Drop for PetWindow {
    fn drop(&mut self) {
        let _ = self.tx.send(Cmd::Quit);
    }
}

/// 把**连到画面四边**的近白像素抠成透明。
///
/// 泛洪,而不是亮度阈值 —— 参考图里白裙子、白鲸发夹也是白的,
/// 按亮度一刀切会把它们一起抠成洞。只抠连到边界的那片。
fn key_out_background(img: &mut image::RgbaImage) {
    let (w, h) = (img.width(), img.height());
    if w == 0 || h == 0 {
        return;
    }
// 阈值 228:实测参考图的背景**不是纯白**,带一层很淡的灰(233~239)。
// 取 236 时那部分抠不掉,宠物边缘会留一圈极淡的灰框 —— 实测角点
// (233,239,235) 对桌面 (255,255,255),差 55。228 能盖住。
    // **连通判定和"抠成透明"要分开**,这是关键:
    //
    // 参考图左侧有一层更暗的柔和阴影(实测 233,239,235 一带),如果拿同一个
    // 阈值既判定连通又判定透明,泛洪到阴影边缘就停了 —— 结果留下一条 45px 宽的
    // 灰色竖带(实测就是这样)。放宽连通阈值,让它能**穿过**阴影;
    // 至于那个像素到底抠不抠,再按它自己的颜色定。
    let connected = |p: &image::Rgba<u8>| p[0].min(p[1]).min(p[2]) >= 210;
    // **最外一圈先强制透明。** 参考图的最外圈并不"近白"(边缘有一圈更暗的
    // 过渡),泛洪从那里根本起不来 —— 实测会紧贴边界留下一条 2px 的淡色竖带
    // (角点 (233,239,235) 对桌面 (255,255,255))。先把它清掉,泛洪才有起点。
    for x in 0..w {
        img.get_pixel_mut(x, 0).0[3] = 0;
        img.get_pixel_mut(x, h - 1).0[3] = 0;
    }
    for y in 0..h {
        img.get_pixel_mut(0, y).0[3] = 0;
        img.get_pixel_mut(w - 1, y).0[3] = 0;
    }
    let mut seen = vec![false; (w * h) as usize];
    let mut stack: Vec<(u32, u32)> = Vec::new();
    for x in 0..w {
        for y in [0, h - 1] {
            stack.push((x, y));
        }
    }
    for y in 0..h {
        for x in [0, w - 1] {
            stack.push((x, y));
        }
    }
    while let Some((x, y)) = stack.pop() {
        let i = (y * w + x) as usize;
        if seen[i] {
            continue;
        }
        seen[i] = true;
        let p = *img.get_pixel(x, y);
        // **够浅、或已经被强制透明**的像素都能当通道往外扩 ——
        // 少了后面这半条,最外圈清掉了也扩不进来。
        if !(connected(&p) || p.0[3] == 0) {
            continue;
        }
        // 够到之后**按深浅定 alpha**:≥228 全透明;210~228 之间给渐变,
        // 免得阴影边缘出现一条硬边(硬边比原来的灰带还显眼)。
        let m = p[0].min(p[1]).min(p[2]) as u32;
        let a = if m >= 228 {
            0
        } else {
            (((228 - m) * 12).min(255)) as u8
        };
        img.get_pixel_mut(x, y).0[3] = a.min(p.0[3]);
        if x > 0 {
            stack.push((x - 1, y));
        }
        if x + 1 < w {
            stack.push((x + 1, y));
        }
        if y > 0 {
            stack.push((x, y - 1));
        }
        if y + 1 < h {
            stack.push((x, y + 1));
        }
    }
}

/// `dbg` 那份临时日志的上限(字节)。
///
/// 它只在 `NFARMS_PET_DEBUG` 打开时才写,而没人会记得去删它 —— 没有上限的话
/// 一次长时间调试就能让它长到几十 MB,并且一直躺在 `%TEMP%` 里。
const DBG_LOG_MAX_BYTES: u64 = 1 << 20;

/// 超过上限就把旧日志删掉,返回"删了没有"。
///
/// 删而不是轮转:这是插桩,留一份旧的没有任何价值,而多出一个 `.1` 只会让
/// 临时目录里再多一个没人清的文件。
fn reset_log_if_oversized(path: &std::path::Path, max: u64) -> bool {
    match std::fs::metadata(path) {
        Ok(m) if m.len() > max => std::fs::remove_file(path).is_ok(),
        _ => false,
    }
}

/// 临时插桩:把 wndproc 收到的鼠标消息写进文件。
///
/// 点击没反应时,`eprintln` 没用(GUI 没有控制台),而猜"是命中测试挡了
/// 还是消息没来"两者差得很远。落盘是唯一能拿到确凿答案的办法。
fn dbg(msg: &str) {
    if std::env::var("NFARMS_PET_DEBUG").is_err() {
        return;
    }
    let p = std::env::temp_dir().join("nfarms-pet-mouse.log");
    // 先看大小再打开:`append` 打开的文件只会一直长(见 `DBG_LOG_MAX_BYTES`)。
    reset_log_if_oversized(&p, DBG_LOG_MAX_BYTES);
    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
        use std::io::Write;
        let _ = writeln!(f, "{msg}");
    }
}
/// 线程里跑的一切。返回 `Err` 只表示"起不来",运行期的问题不往外抛。
fn run(path: PathBuf, rx: Receiver<Cmd>) -> Result<(), String> {
    // 图先加载好:加载不出来就干脆不起窗口,免得桌面上多一块空白。
    let img = image::ImageReader::open(&path)
        .map_err(|e| format!("打不开 {}: {e}", path.display()))?
        .decode()
        .map_err(|e| format!("解不开 {}: {e}", path.display()))?;
    // **先缩到目标高度再抠底。** 原图 1120x1856,直接建窗口会是一张占半个屏幕的
    // 巨图。512 是按 250 逻辑点 × 2 倍 DPI 留的余量。
    //
    // 缩放放在抠底**之前**:先抠再缩的话,边界那圈半透明像素会被插值糊开,
    // 边缘会发灰。先缩再抠,阈值判断的是最终像素。
    let img = if img.height() > PET_MAX_H {
        img.resize(
            u32::MAX,
            PET_MAX_H,
            image::imageops::FilterType::Lanczos3,
        )
    } else {
        img
    };
    let mut rgba = img.to_rgba8();
    key_out_background(&mut rgba);
    let (w, h) = rgba.dimensions();
    if w == 0 || h == 0 {
        return Err("图是空的".to_string());
    }
    // `UpdateLayeredWindow` 要的是**预乘 alpha** 的 BGRA。
    let mut px: Vec<u8> = Vec::with_capacity((w * h * 4) as usize);
    for p in rgba.pixels() {
        let a = p[3] as u32;
        px.push((p[2] as u32 * a / 255) as u8);
        px.push((p[1] as u32 * a / 255) as u8);
        px.push((p[0] as u32 * a / 255) as u8);
        px.push(p[3]);
    }
    unsafe { pump(w, h, px, rx) }
}

// ---- GDI 资源:必须配对释放 ----
//
// 屏幕 DC、兼容 DC、DIB 位图这三样原来都是裸句柄,而 `pump` 有多条退出路径
// (`Cmd::Quit`、`WM_QUIT`、`CreateWindowExW` 失败)—— **没有一条释放过它们**,
// 全仓 grep `DeleteObject|DeleteDC|ReleaseDC` 是零命中。GDI 句柄属于**进程**:
// 线程结束了也照样占着配额,线程退不退出都白占。
//
// 所以做成 RAII:任何 return(包括以后新加的)都由 `Drop` 兜底。靠人记得在每条
// 出口上写一遍清理是修不住的 —— 这个缺陷本身就是这么来的。
//
// 释放顺序不能换:`SelectObject` 还回去之前删位图会**失败**(选进 DC 的位图
// 删不掉,于是句柄真丢了),而删完位图再删 DC 才不会连位图一起带走;
// `GetDC(NULL)` 拿到的屏幕 DC 只能用 `ReleaseDC(NULL, …)` 还,不是 `DeleteDC`。
struct GdiSurface {
    screen: HDC,
    memdc: HDC,
    dib: HBITMAP,
    /// `SelectObject` 换出来的旧对象,退出前要还回 `memdc`。
    old: HGDIOBJ,
}

impl GdiSurface {
    /// 建屏幕 DC + 兼容 DC + 一块自顶向下 32 位 DIB,并把 `px` 灌进去。
    ///
    /// 失败路径不用手写清理:两个 DC 一建好就先放进 `Self`,后面任何一条
    /// `return Err` 都已经有 `Drop` 兜底。
    unsafe fn create(w: u32, h: u32, px: &[u8]) -> Result<Self, String> {
        // 整个函数体就是一个 `unsafe` 块:里面除了两处算术全是 Win32 调用。
        // 显式写出来,`unsafe_op_in_unsafe_fn` 就不必对着每一行单独告警
        // (这个文件的 `PetVisibility` 那几处也是这么写的)。
        unsafe {
            let screen: HDC = GetDC(std::ptr::null_mut());
            let memdc: HDC = CreateCompatibleDC(screen);
            let mut surface = Self {
                screen,
                memdc,
                dib: std::ptr::null_mut(),
                old: std::ptr::null_mut(),
            };
            if screen.is_null() || memdc.is_null() {
                return Err("CreateCompatibleDC 失败".to_string());
            }
            let bmi = BITMAPINFO {
                bmiHeader: BITMAPINFOHEADER {
                    biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
                    biWidth: w as i32,
                    // **负数 = 自顶向下**:不然图会上下颠倒
                    biHeight: -(h as i32),
                    biPlanes: 1,
                    biBitCount: 32,
                    biCompression: BI_RGB,
                    ..std::mem::zeroed()
                },
                ..std::mem::zeroed()
            };
            let mut bits: *mut core::ffi::c_void = std::ptr::null_mut();
            surface.dib = CreateDIBSection(
                screen,
                &bmi,
                DIB_RGB_COLORS,
                &mut bits,
                std::ptr::null_mut(),
                0,
            );
            if surface.dib.is_null() || bits.is_null() {
                return Err("CreateDIBSection 失败".to_string());
            }
            std::ptr::copy_nonoverlapping(px.as_ptr(), bits as *mut u8, px.len());
            surface.old = SelectObject(memdc, surface.dib as HGDIOBJ);
            // 给命中测试用(见 wndproc 的 WM_NCHITTEST)。`Drop` 里会把它清掉 ——
            // 位图一删这个裸指针就悬空了,留着比空着危险。
            BITS.store(bits as *mut u8, Ordering::Relaxed);
            TEX_W.store(w as i32, Ordering::Relaxed);
            TEX_H.store(h as i32, Ordering::Relaxed);
            Ok(surface)
        }
    }
}

impl Drop for GdiSurface {
    fn drop(&mut self) {
        // 释放的顺序见 `GdiSurface` 上面那段说明,不要调换。
        unsafe {
            if !self.old.is_null() && !self.memdc.is_null() {
                SelectObject(self.memdc, self.old);
            }
            if !self.dib.is_null() {
                DeleteObject(self.dib as HGDIOBJ);
            }
            if !self.memdc.is_null() {
                DeleteDC(self.memdc);
            }
            if !self.screen.is_null() {
                ReleaseDC(std::ptr::null_mut(), self.screen);
            }
            // 位图没了,命中测试不能再拿旧指针去读像素。
            BITS.store(std::ptr::null_mut(), Ordering::Relaxed);
        }
    }
}

#[allow(non_snake_case)]
unsafe fn pump(
    w: u32,
    h: u32,
    px: Vec<u8>,
    rx: Receiver<Cmd>,
) -> Result<(), String> { unsafe {
    use windows_sys::Win32::Foundation::{HWND, POINT, SIZE};
    // 其余 GDI 名字(`CreateDIBSection` / `SelectObject` / 类型…)已经提到模块级,
    // 因为建/放这套资源的 `GdiSurface` 就在那儿。
    use windows_sys::Win32::Graphics::Gdi::{BLENDFUNCTION, AC_SRC_ALPHA, AC_SRC_OVER};
    use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
    use windows_sys::Win32::UI::WindowsAndMessaging::{
        CS_HREDRAW, CS_VREDRAW, CreateWindowExW, DestroyWindow, DispatchMessageW,
        IDC_ARROW, LoadCursorW, MSG, RegisterClassExW, TranslateMessage, ULW_ALPHA,
        UpdateLayeredWindow, WM_QUIT, WM_TIMER, WNDCLASSEXW, WS_EX_LAYERED,
        WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP,
    };

    // ---- 屏幕 DC + 一块 32 位 DIB,把像素灌进去 ----
    //
    // 句柄的释放交给 `GdiSurface` 的 `Drop`(见它的说明):`pump` 有好几条退出
    // 路径,靠人记得在每条上都写一遍 `DeleteObject`/`DeleteDC`/`ReleaseDC`
    // 是修不住的 —— 原来正是一条都没写。
    // 这里**不写** `unsafe` 块:本函数本身就是 `unsafe fn`,
    // 外面再套一层是多余的(clippy 的 `unused_unsafe` 会指出来)。
    let surface = GdiSurface::create(w, h, &px)?;
    let screen = surface.screen;
    let memdc = surface.memdc;
    // 像素已经拷进 DIB,这份 Vec 没用了 —— 这条线程要活到程序退出,
    // 几百 KB 没必要一直挂着。
    drop(px);

    // ---- 窗口类 ----
    let class_name: Vec<u16> = "NFarmsPet\0".encode_utf16().collect();
    let hinst = GetModuleHandleW(std::ptr::null());
    let wc = WNDCLASSEXW {
        cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
        style: CS_HREDRAW | CS_VREDRAW,
        lpfnWndProc: Some(wndproc),
        hInstance: hinst,
        hCursor: LoadCursorW(std::ptr::null_mut(), IDC_ARROW),
        lpszClassName: class_name.as_ptr(),
        ..std::mem::zeroed()
    };
    RegisterClassExW(&wc);

    // ---- 建窗口。**先建在屏幕右下角**,再按 Show/Hide 控制 ----
    let sw = windows_sys::Win32::UI::WindowsAndMessaging::GetSystemMetrics(
        windows_sys::Win32::UI::WindowsAndMessaging::SM_CXSCREEN,
    );
    let sh = windows_sys::Win32::UI::WindowsAndMessaging::GetSystemMetrics(
        windows_sys::Win32::UI::WindowsAndMessaging::SM_CYSCREEN,
    );
    let x0 = sw - w as i32 - 40;
    let y0 = sh - h as i32 - 80;

    let hwnd: HWND = CreateWindowExW(
        WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TOOLWINDOW,
        class_name.as_ptr(),
        std::ptr::null(),
        WS_POPUP,
        x0,
        y0,
        w as i32,
        h as i32,
        std::ptr::null_mut(),
        std::ptr::null_mut(),
        hinst,
        std::ptr::null(),
    );
    if hwnd.is_null() {
        return Err("CreateWindowExW 失败".to_string());
    }

    // 这里原来还有一次 `SetWindowLongPtrW(hwnd, GWLP_USERDATA, hwnd)`,注释写着
    // 「把窗口指针交给 wndproc,它才知道往哪儿发 Quit」。但全仓没有任何地方
    // **读**过 GWLP_USERDATA,而且 wndproc 本来就拿得到 hwnd —— 这个"发 Quit"
    // 从来就没实现过:右键那条路当时走的是 `DestroyWindow`,根本不发 Quit。
    // 写进去没人读的值只会让人以为退出路径是对的,所以删掉。

    let blend = BLENDFUNCTION {
        BlendOp: AC_SRC_OVER as u8,
        BlendFlags: 0,
        SourceConstantAlpha: 255,
        AlphaFormat: AC_SRC_ALPHA as u8,
    };
    let mut dst = POINT { x: x0, y: y0 };
    let size = SIZE {
        cx: w as i32,
        cy: h as i32,
    };
    let src = POINT { x: 0, y: 0 };

    // ---- 消息循环 ----
    // 用 PeekMessage + Sleep 轮询,而不是 GetMessage 阻塞:
    // 阻塞的话 channel 里的 Show/Hide 就没人处理了。33ms ≈ 30Hz,
    // 桌宠只是个上下浮动的小图,不需要更高。
    //
    // 这个 loop 的**正常**出口只有一个:`Cmd::Quit`(程序退出时 `PetWindow`
    // 的 `Drop` 发的)。右键收起**不是**出口 —— 那条路只把窗口藏起来,
    // 线程留着等 `Cmd::Show` 把宠物显示回来(见 wndproc 里的 WM_RBUTTONUP)。
    //
    // 但循环里还是认 `WM_QUIT`(见下面那条 if):它是个防御。原来这里是
    // 真·死循环,万一将来有人在右键那条路补一句 `PostQuitMessage`,取到
    // `WM_QUIT` 却不认的话就会陷入"消息队列里永远躺着一条 WM_QUIT 而没人取走"
    // 的怪状态(`PeekMessageW` 会一直把它捞出来)。认了它,最坏情况也只是
    // 线程干净退出,不会空转。
    //
    // 初始位置记进静态量,之后**只有用户拖动才会改它**
    BASE_X.store(x0, Ordering::Relaxed);
    BASE_Y.store(y0, Ordering::Relaxed);

    let mut msg: MSG = std::mem::zeroed();
    let mut state = PetVisibility::from_pump(false, 0.0);
    loop {
        while windows_sys::Win32::UI::WindowsAndMessaging::PeekMessageW(
            &mut msg,
            std::ptr::null_mut(),
            0,
            0,
            windows_sys::Win32::UI::WindowsAndMessaging::PM_REMOVE,
        ) != 0
        {
            // `WM_QUIT` 是**线程消息**,`PeekMessageW` 取到它时不会交给 wndproc
            // (只有销毁窗口产生的 WM_DESTROY 才走 wndproc),所以必须在这里认。
            if msg.message == WM_QUIT {
                // 认了它就得走得干净:线程带着一个还活着的窗口退出的话,那个
                // 置顶分层窗口会**永远留在桌面上** —— 再没有任何线程能收掉它
                // (UI 侧的 `Show`/`Hide` 送不到,`rx` 那头也没人接了)。
                // 窗口归本线程所有,直接销毁;`GdiSurface` 的 `Drop` 随后释放
                // 位图与两个 DC。
                DestroyWindow(hwnd);
                return Ok(());
            }
            if msg.message == WM_TIMER {
                state.advance();
            }
            TranslateMessage(&msg);
            DispatchMessageW(&msg);
        }
        // 右键收起:执行 wndproc 挂下的待办。
        //
        // 放在消息泵和 channel 之间,是因为 pump 是唯一动 `state` 的地方 ——
        // 窗口显隐全走它,状态才不会和真实窗口对不上(见 `PET_HIDDEN` 的说明)。
        if PET_HIDDEN.swap(false, Ordering::Relaxed) {
            if let PetNotice::Dismissed = state.apply(PetEvent::Dismiss, hwnd) {
                // 同时举手告诉界面:`lib.rs` 那个 `pet_shown`(它记着"上次同步给
                // 桌宠的可见状态")现在是**错的** —— 窗口已经藏了,它还写着 true。
                // 不纠正的话,用户再勾一次开关时 `want(true) != pet_shown(false)`
                // 不成立,那条 `Show` 根本发不出来,宠物就"关一下开一下"才回得来。
                DISMISSED.store(true, Ordering::Relaxed);
            }
        }
        // 处理 UI 线程发来的指令
        while let Ok(cmd) = rx.try_recv() {
            let (ev, quitting) = match cmd {
                Cmd::Show => (PetEvent::Show, false),
                Cmd::Hide => (PetEvent::Hide, false),
                Cmd::Quit => (PetEvent::Quit, true),
            };
            match state.apply(ev, hwnd) {
                // 窗口已经被销毁、指令落空 —— 这条线程没必要再转下去了。
                PetNotice::Failed => return Ok(()),
                PetNotice::Ready | PetNotice::Dismissed => {}
            }
            if quitting {
                // 程序要退了,窗口由这里销毁(`apply` 已经把定时器停掉了)。
                DestroyWindow(hwnd);
                return Ok(());
            }
        }
        if state.shown {
            // 位置每帧从静态量取 —— 用户拖到哪儿就停在哪儿,只叠一点上下浮动
            let bob = state.phase().sin() * 4.0;
            dst.x = BASE_X.load(Ordering::Relaxed);
            dst.y = BASE_Y.load(Ordering::Relaxed) + bob as i32;
            UpdateLayeredWindow(
                hwnd,
                screen,
                &dst,
                &size,
                memdc,
                &src,
                0,
                &blend,
                ULW_ALPHA,
            );
        }
        std::thread::sleep(std::time::Duration::from_millis(33));
    }
}}

// `POINT` / `RECT` 只在 wndproc 里用到,所以 import 就放在它旁边。
// (原来这里挂着一行 `/// 窗口过程:拖动 + 右键退出。` —— 文档注释挂到了一条
// `use` 语句上,离它真正描述的 wndproc 还差一行,属于放错了位置。)
use windows_sys::Win32::Foundation::{POINT, RECT};

/// 窗口过程:拖动 + 右键收起。
unsafe extern "system" fn wndproc(
    hwnd: windows_sys::Win32::Foundation::HWND,
    msg: u32,
    wparam: windows_sys::Win32::Foundation::WPARAM,
    lparam: windows_sys::Win32::Foundation::LPARAM,
) -> windows_sys::Win32::Foundation::LRESULT { unsafe {
    use windows_sys::Win32::UI::Input::KeyboardAndMouse::ReleaseCapture;
    use windows_sys::Win32::UI::WindowsAndMessaging::{
        DefWindowProcW, GetCursorPos, GetWindowRect, HTCAPTION, HTCLIENT, HTTRANSPARENT,
        SendMessageW, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_NCHITTEST,
        WM_NCLBUTTONDOWN, WM_RBUTTONUP,
    };
    match msg {
        // **透明处要让点击穿过去。**
        //
        // 分层窗口默认会吃掉整个矩形内的鼠标事件 —— 而这只宠物的窗口是
        // 309x512,真正有像素的只有中间那一块。不吃掉的话,桌面上那一大片
        // 透明区域的点击全都被它挡了,用户会以为桌面坏了。
        WM_NCHITTEST => {
            let bp = BITS.load(Ordering::Relaxed);
            if bp.is_null() {
                return HTCLIENT as isize;
            }
            let (tw, th) = (TEX_W.load(Ordering::Relaxed), TEX_H.load(Ordering::Relaxed));
            let mut p: POINT = std::mem::zeroed();
            let mut r: RECT = std::mem::zeroed();
            if GetCursorPos(&mut p) == 0 || GetWindowRect(hwnd, &mut r) == 0 {
                return HTCLIENT as isize;
            }
            let (x, y) = (p.x - r.left, p.y - r.top);
            if x >= 0 && y >= 0 && x < tw && y < th {
                // 预乘 BGRA:每像素 4 字节,第 4 个是 alpha
                let idx = ((y * tw + x) * 4 + 3) as usize;
                let a = *bp.add(idx);
                if a < 16 {
                    dbg(&format!("NCHITTEST ({x},{y}) alpha={a} -> TRANSPARENT"));
                    return HTTRANSPARENT as isize;
                }
            }
            HTCLIENT as isize
        }
        WM_LBUTTONDOWN => {
            // **先不拖。** 按下的这一刻还分不清是"点"还是"拖",
            // 记下起点,等移动超过阈值再决定(见 WM_MOUSEMOVE)。
            PRESSED.store(true, Ordering::Relaxed);
            DRAGGING.store(false, Ordering::Relaxed);
            let mut p: POINT = std::mem::zeroed();
            GetCursorPos(&mut p);
            PRESS_X.store(p.x, Ordering::Relaxed);
            PRESS_Y.store(p.y, Ordering::Relaxed);
            dbg(&format!("LBUTTONDOWN at ({},{})", p.x, p.y));
            0
        }
        WM_MOUSEMOVE => {
            if PRESSED.load(Ordering::Relaxed) && !DRAGGING.load(Ordering::Relaxed) {
                let mut p: POINT = std::mem::zeroed();
                GetCursorPos(&mut p);
                // **`moved` 是两轴之和,所以阈值要按"每轴"来想。**
                //
                // 原来取 4:两轴各动 2 像素就到阈值了 —— 真人点一下手抖 2~3 像素
                // 很常见,于是点击会被当成拖动,而拖动的后果是把桌宠挪走。
                // 取 8(≈每轴 4 像素)对真实点击宽容得多,又不至于把"想拖动"
                // 误判成点击(真想拖的人一定会动得比 4 像素多)。
                let moved = (p.x - PRESS_X.load(Ordering::Relaxed)).abs()
                    + (p.y - PRESS_Y.load(Ordering::Relaxed)).abs();
                if moved > 8 {
                    DRAGGING.store(true, Ordering::Relaxed);
                    dbg(&format!("MOUSEMOVE moved={moved} -> DRAG"));
                    // 到这一步才交给系统拖 —— 自己算位移在高 DPI 下会飘
                    ReleaseCapture();
                    SendMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION as usize, 0);
                }
            }
            0
        }
        WM_LBUTTONUP => {
            // 松手时还没进入拖动 → 这是一次**点击** → 开对话框
            let is_click = PRESSED.load(Ordering::Relaxed) && !DRAGGING.load(Ordering::Relaxed);
            dbg(&format!(
                "LBUTTONUP pressed={} dragging={} -> click={is_click}",
                PRESSED.load(Ordering::Relaxed),
                DRAGGING.load(Ordering::Relaxed)
            ));
            if is_click {
                CLICKED.store(true, Ordering::Relaxed);
            }
            PRESSED.store(false, Ordering::Relaxed);
            0
        }
        // 系统拖动结束 —— 把落点记下来,否则下一帧又被拉回原点
        windows_sys::Win32::UI::WindowsAndMessaging::WM_EXITSIZEMOVE => {
            let mut r: windows_sys::Win32::Foundation::RECT = std::mem::zeroed();
            windows_sys::Win32::UI::WindowsAndMessaging::GetWindowRect(hwnd, &mut r);
            BASE_X.store(r.left, std::sync::atomic::Ordering::Relaxed);
            BASE_Y.store(r.top, std::sync::atomic::Ordering::Relaxed);
            0
        }
        WM_RBUTTONUP => {
            // 右键收起桌宠。不做弹出菜单 —— 一个宠物不值得一套菜单,
            // 而且 TrackPopupMenu 对 z 序和前景权有额外要求,容易点不出来。
            //
            // **这里原来是 `DestroyWindow(hwnd)`,那正是这个 bug 的根**:
            // 窗口一销毁,pump 循环里的 `shown` 还是 true,于是它以约 30Hz
            // 继续对已销毁的 HWND 调 `UpdateLayeredWindow`(返回 0,静默失败);
            // 而 UI 侧的 `pet_shown` 也还是 true,界面上开关显示"开着"。
            // 更糟的是窗口没了就再也建不回来 —— `lib.rs` 只在构造界面状态时
            // `PetWindow::spawn` 一次,没有任何重生路径,于是宠物到重启前都回不来。
            //
            // 改成"藏起来":窗口还在,勾回开关就能原样显示出来(位置也还在)。
            //
            // 真正的"藏"不由这里做,而是挂个待办给 pump(`PET_HIDDEN`)——
            // 窗口显隐由 pump 的 `PetVisibility` 独占管理,这里若直接
            // `ShowWindow` 会让 pump 的 `shown` 与真实状态脱节,等 UI 再发
            // `Show` 时 `show()` 以为窗口还开着就什么都不做,宠物照样回不来。
            // 那只是把原来的 bug 换个地方犯。
            //
            // **这里刻意不调 `PostQuitMessage`。**
            //
            // 核实报告给的修法是"销毁窗口 + `PostQuitMessage` 让 pump 退出",
            // 但那要求界面侧能重新 `spawn`(窗口没了就是没了,
            // `PetWindow::spawn` 全程序只在 `lib.rs` 构造界面状态时调一次,
            // 没有任何重生路径)。而"保持窗口存在、只是藏起来"能让
            // `Cmd::Show` 原样把宠物显示回来 —— 那正是用户要的"勾一下就回来",
            // 代价只是这个线程继续以 33ms 醒一次等指令(它本来就是这个设计)。
            //
            // 所以 `PostQuitMessage` 一旦发出去,等于把"宠物回不来"从
            // "窗口被销毁"换成"消息循环已经没了",缺陷照旧。真正需要销毁窗口
            // 的是程序退出那条路(`Cmd::Quit`),不在右键。
            PET_HIDDEN.store(true, Ordering::Relaxed);
            DISMISSED.store(true, Ordering::Relaxed);
            0
        }
        _ => DefWindowProcW(hwnd, msg, wparam, lparam),
    }
}}

// ---------------------------------------------------------------- 视口半透明

/// 让**另一个** egui 视口窗口整体半透明(桌面能透出来)。
///
/// # 为什么只能"整体半透明",不能"背景透明"
///
/// egui 自己的视口做不了逐像素透明 —— **实测过两次**:配齐
/// `with_transparent(true)` 和透明面板之后,把窗口分别放在主窗口背景图上
/// 和桌面壁纸上,同一相对位置读出的像素**完全相同**(R199 G204 B201)。
/// 背景不随背后内容变化,就不是真透明。
///
/// 逐像素只有 Win32 分层窗口 + `UpdateLayeredWindow` 能做到,而那意味着
/// **界面不能在 egui 里画** —— 是重写,不是改配置。
///
/// # 为什么不用 `LWA_COLORKEY`
///
/// 它能把某个颜色整片抹掉,看上去更"透明"。但 egui 的文字是**抗锯齿**的:
/// 笔画边缘的像素是"文字色与背景色的混合",**不等于**指定的那个键色,
/// 所以抹不掉 —— 每个字周围会留一圈键色描边。对一个满屏文字的窗口,
/// 那比半透明难看得多。
///
/// # 代价
///
/// `LWA_ALPHA` 是**整窗统一**的,所以文字也跟着变淡。这是这个 API 的
/// 固有性质。`alpha` 就是旋钮:越小越透、越难读。
///
/// 返回 `false` = 还没找到那个窗口(视口尚未建出来),调用方下一帧再试。
#[cfg(windows)]
pub fn make_translucent(title: &str, alpha: u8) -> bool {
    use windows_sys::Win32::UI::WindowsAndMessaging::{
        FindWindowW, GetWindowLongPtrW, SetLayeredWindowAttributes, SetWindowLongPtrW,
        GWL_EXSTYLE, LWA_ALPHA, WS_EX_LAYERED,
    };
    let mut wide: Vec<u16> = title.encode_utf16().collect();
    wide.push(0);
    // SAFETY: 传的是以 NUL 结尾的 UTF-16,且第一个参数为 null(不指定类名)。
    let hwnd = unsafe { FindWindowW(std::ptr::null(), wide.as_ptr()) };
    if hwnd.is_null() {
        return false;
    }
    unsafe {
        let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
        // 已经加过就不再重复设 —— 这个函数会被每帧调,直到成功为止
        if ex & (WS_EX_LAYERED as isize) == 0 {
            SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex | (WS_EX_LAYERED as isize));
        }
        // 第二个参数是键色,`LWA_ALPHA` 模式下不用,给 0
        SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA);
    }
    true
}

/// 对话框窗口的不透明度(0~255)。**越小越透,也越难读。**
///
/// **这是"透明程度"的唯一旋钮。**
///
/// 一开始取 238(93%) —— 实测确实生效(把窗口放在两种不同背景上,
/// 读出的像素从"完全相同"变成相差 4~5 个色阶),但**差值太小**,
/// 肉眼看仍然像不透明。所以调到 205(约 80%):
/// 透得出来,正文也还读得清。
///
/// 还嫌不透就继续往小调(180 已经很透,但深色壁纸上文字会发灰);
/// 嫌难读就往上调。
#[cfg(windows)]
pub const CHAT_ALPHA: u8 = 205;
#[cfg(test)]
mod tests {
    use super::*;

    /// **泛洪的核心性质**:连到边界的白被抠掉,而**角色内部**的白毫发无伤。
    ///
    /// 这条守的就是「白裙子不会被抠成洞」—— 换成按亮度阈值一刀切,它会。
    #[test]
    fn keys_the_border_white_but_keeps_an_interior_white_blob() {
        let mut img = image::RgbaImage::from_pixel(20, 20, image::Rgba([255, 255, 255, 255]));
        // 中间一圈灰蓝当"角色",把内部的白和边界隔开
        for x in 6..14 {
            for y in 6..14 {
                *img.get_pixel_mut(x, y) = image::Rgba([120, 160, 220, 255]);
            }
        }
        for x in 8..12 {
            for y in 8..12 {
                *img.get_pixel_mut(x, y) = image::Rgba([255, 255, 255, 255]);
            }
        }
        key_out_background(&mut img);
        assert_eq!(img.get_pixel(0, 0).0[3], 0, "边界的白应当被抠掉");
        assert_eq!(
            img.get_pixel(10, 10).0[3],
            255,
            "角色**内部**的纯白不能被抠掉 —— 那正是白裙子"
        );
    }

    /// 阈值要能盖住"不是纯白"的底:参考图背景实测在 233 附近。
    #[test]
    fn keys_a_very_light_grey_background_too() {
        let mut img = image::RgbaImage::from_pixel(8, 8, image::Rgba([233, 239, 235, 255]));
        key_out_background(&mut img);
        assert!(
            img.pixels().all(|p| p.0[3] == 0),
            "233 这一档的浅灰底也要抠掉,否则宠物边缘会留一圈灰框"
        );
    }

    /// 而**明显是角色**的颜色一个都不能动(阈值不能贪心)。
    ///
    /// 断言的是**中心**像素,不是全部:最外一圈是被**强制**清掉的
    /// (见 `key_out_background` 里那段说明),拿它当"角色"没有意义 —— 8x8 的
    /// 全彩图每个像素都在外圈上,那样断言必然失败。
    #[test]
    fn does_not_touch_coloured_pixels() {
        let mut img = image::RgbaImage::from_pixel(20, 20, image::Rgba([57, 95, 179, 255]));
        key_out_background(&mut img);
        assert_eq!(
            img.get_pixel(10, 10).0[3],
            255,
            "深蓝是角色,不能抠(中心像素不在被强制清掉的外圈上)"
        );
    }

    /// 空图不能 panic。
    #[test]
    fn an_empty_image_does_not_panic() {
        let mut img = image::RgbaImage::new(0, 0);
        key_out_background(&mut img);
    }

    // ---- 右键收起:不销毁窗口、还能再显示回来 ----
    //
    // 这一组守的就是那个**已核实的缺陷**:右键原本走 `DestroyWindow`,窗口没了
    // 就再也建不回来(`spawn` 全程序只调一次),而状态还停在"显示中",于是
    // * 消息循环以约 30Hz 对已销毁的 HWND 空转调 `UpdateLayeredWindow`,并且
    // * 宠物到重启前都回不来。
    //
    // 这些用例**不碰真的 Win32 调用** —— 它们只读 `PetVisibility` 自己的字段,
    // 不读 `ShowWindow` 的返回值,所以传一个假句柄进来也不会真去动一个窗口。

    /// 一个够用的**假**窗口句柄。真句柄只可能来自 `CreateWindowExW`,而单测
    /// 里不需要真窗口:上面那些 `unsafe { ShowWindow(..) }` 的返回值没人看。
    fn dummy_hwnd() -> HWND {
        0x1234 as HWND
    }

    /// 右键收起之后,`Show` **仍然**能把宠物显示回来 —— 这正是复发的开关。
    ///
    /// 改回 `DestroyWindow` 会怎么样:窗口没了,`show()` 里那句
    /// `if hwnd.is_null() { return false }` 拦不住一个非空但已失效的句柄
    /// (`DestroyWindow` 之后句柄值还在,只是不再有效),于是 `shown` 被置回 true、
    /// 循环开始对着死句柄刷 `UpdateLayeredWindow`,而宠物再也显示不出来。
    ///
    /// 所以这里断言的是**事件序列**:收起走的是 `hide`(窗口还活着),
    /// 之后 `Show` 必须真的成功、定时器必须重新起来。
    ///
    /// **故意不碰 `PET_HIDDEN` / `DISMISSED` 那两个静态量**:它们是进程级的,
    /// 而测试是多线程并行跑的,一个用例去读写它们会把另一条用例搅黄
    /// (第一版就是这么挂的)。这里只考 `apply` 这个纯状态机。
    #[test]
    fn right_click_hides_the_window_and_a_later_show_brings_the_pet_back() {
        let hwnd = dummy_hwnd();
        let mut v = PetVisibility::from_pump(true, 0.0);
        assert!(v.shown, "前提:宠物本来是显示着的");

        // 右键收起。必须只是"藏起来"(窗口还在),并且要通知 UI 去把勾去掉。
        assert_eq!(
            v.apply(PetEvent::Dismiss, hwnd),
            PetNotice::Dismissed,
            "右键收起要告诉界面一声,否则界面会一直显示'开着'"
        );
        assert!(!v.shown, "右键之后窗口必须是藏起来的");
        assert!(!v.timer, "藏着的时候那 33ms 的定时器也该停掉,别空烧 CPU");

        // 用户把「显示桌宠」勾回来:`Cmd::Show` 必须真的能显示出来。
        assert_eq!(
            v.apply(PetEvent::Show, hwnd),
            PetNotice::Ready,
            "右键收起**不销毁窗口**,所以 show 必须成功 —— 返回 Failed 说明窗口没了,\
             宠物就又回不来了"
        );
        assert!(v.shown, "show 之后必须是显示着的,否则宠物不出现");
        assert!(v.timer, "重新显示之后定时器要起来,否则宠物不会上下浮动");

        // 再收起、再显示一次,同样要成立(用户可能反复开关)。
        assert_eq!(v.apply(PetEvent::Dismiss, hwnd), PetNotice::Dismissed);
        assert_eq!(v.apply(PetEvent::Show, hwnd), PetNotice::Ready);
        assert!(v.shown && v.timer, "第二次收起再显示之后同样要能回来");
    }

    /// 窗口真的没了(`HWND` 空)时,`apply` 必须如实报失败,不能假装成功。
    ///
    /// 这条守的是"别装作显示成功了"这一半:如果这里返回 `Ready`,pump 会
    /// 以为窗口回来了,继续对着死句柄空转 —— 正是原来那个症状。
    #[test]
    fn apply_reports_failure_when_the_window_is_gone() {
        let mut v = PetVisibility::from_pump(true, 0.0);
        assert_eq!(
            v.apply(PetEvent::Show, std::ptr::null_mut()),
            PetNotice::Failed
        );
        assert_eq!(
            v.apply(PetEvent::Hide, std::ptr::null_mut()),
            PetNotice::Failed
        );
    }

    /// `dismiss()` 这个一次性信号:取到一次 true,之后自己清掉。
    ///
    /// UI 侧就是靠它把「显示桌宠」的勾去掉的 —— 如果它是"常真"的,用户每次
    /// 手动勾上桌宠都会被它立刻又按回去,桌宠将永远打不开。所以要的是
    /// **一次性**通知,不是状态查询。
    ///
    /// 这里直接置位(没有走 wndproc,那需要真窗口 + 真消息循环)。
    #[test]
    fn the_dismiss_signal_is_one_shot() {
        DISMISSED.store(true, Ordering::Relaxed);
        assert!(dismiss(), "第一次取应当拿到 true(界面据此把勾去掉)");
        assert!(!dismiss(), "取过一次就该清掉,不能每次调用都返回 true");
    }

    // ---- GDI 句柄:配对释放 ----
    //
    // 守的是核实报告里那条「GDI 资源没有配对释放」:屏幕 DC、兼容 DC、DIB
    // 位图原来全是裸句柄,`pump` 的每一条退出路径都不管它们。
    //
    // 这一组**不用假句柄**:拿一个假 `HBITMAP` 去调 `DeleteObject` 是未定义
    // 行为,而且"到底释放了没有"只有真句柄问得出来。所以真建一份再 `drop`。

    /// 这块位图句柄还有效吗?已删除的句柄上 `GetObjectW` 返回 0。
    fn bitmap_is_alive(dib: HBITMAP) -> bool {
        let mut bm: windows_sys::Win32::Graphics::Gdi::BITMAP = unsafe { std::mem::zeroed() };
        let n = unsafe {
            windows_sys::Win32::Graphics::Gdi::GetObjectW(
                dib as HGDIOBJ,
                std::mem::size_of::<windows_sys::Win32::Graphics::Gdi::BITMAP>() as i32,
                &mut bm as *mut _ as *mut core::ffi::c_void,
            )
        };
        n != 0
    }

    /// `BITS` 是**进程级**的,而两条 GDI 用例都要碰它 —— 测试是多线程并行跑的,
    /// 不加锁就会互相踩(和上面那组"别碰 `PET_HIDDEN`/`DISMISSED`"同一个原因)。
    static GDI_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    /// 取锁;某条用例 panic 过就当没锁(毒化不该让后面的用例全挂)。
    fn gdi_guard() -> std::sync::MutexGuard<'static, ()> {
        GDI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
    }

    /// `GdiSurface` 一 `Drop`,位图必须真的被删掉。
    ///
    /// 这条断言同时守着 `Drop` 里的**顺序**:`DeleteObject` 对"还选在 DC 里"
    /// 的位图会失败(句柄就真丢了),所以位图被删掉这件事本身就证明
    /// `SelectObject` 先把旧对象还回去了。
    ///
    /// 顺手也断言命中测试那份裸指针被清空 —— 位图删了还留着它,下一次
    /// `WM_NCHITTEST` 就是读已释放内存。
    #[test]
    fn the_gdi_surface_releases_its_bitmap_on_drop() {
        let _guard = gdi_guard();
        let px = vec![0u8; 4 * 4 * 4];
        let surface = unsafe { GdiSurface::create(4, 4, &px) }
            .expect("测试机是 Windows,GDI 对象应当建得出来");
        let dib = surface.dib;
        assert!(!dib.is_null(), "CreateDIBSection 应当给出一个位图句柄");
        assert!(bitmap_is_alive(dib), "前提:刚建出来的位图当然还有效");
        assert!(
            !BITS.load(Ordering::Relaxed).is_null(),
            "前提:建好之后命中测试拿得到像素指针"
        );

        drop(surface);

        assert!(
            !bitmap_is_alive(dib),
            "Drop 之后位图必须已经被 DeleteObject —— 否则就是核实报告里那条句柄泄漏"
        );
        assert!(
            BITS.load(Ordering::Relaxed).is_null(),
            "位图删了,命中测试那份裸指针必须清掉,不能留悬空指针"
        );
    }

    /// 建失败时也不能漏句柄:两个 DC 已经建好、`CreateDIBSection` 失败,
    /// 返回的仍是一条 `Err`(清理走 `Drop`,不靠失败分支手写)。
    ///
    /// 用一个**只可能失败**的尺寸(0x0)逼出这条路径。
    #[test]
    fn a_failed_gdi_surface_creation_reports_an_error() {
        let _guard = gdi_guard();
        let err = unsafe { GdiSurface::create(0, 0, &[]) };
        assert_eq!(
            err.err().as_deref(),
            Some("CreateDIBSection 失败"),
            "0x0 的 DIB 建不出来,应当报错而不是假装成功"
        );
    }

    // ---- 临时日志:不能无限长 ----

    /// 调试日志超过上限要被清掉,没超就一个字都不能动。
    ///
    /// 守的是核实报告里「日志文件还会无限追加」那一半:`%TEMP%` 里的插桩文件
    /// 没人会记得删,只能自己封顶。
    #[test]
    fn the_debug_log_is_cleared_once_it_passes_the_cap() {
        let p = std::env::temp_dir().join(format!("nfarms-pet-log-cap-{}.log", std::process::id()));
        let _ = std::fs::remove_file(&p);
        std::fs::write(&p, vec![b'x'; 64]).expect("临时目录应当可写");

        assert!(
            !reset_log_if_oversized(&p, 1024),
            "没超上限就什么都不该做"
        );
        assert!(p.exists(), "没超上限时日志要留着,否则调试信息全丢");

        assert!(reset_log_if_oversized(&p, 32), "超上限必须把它清掉");
        assert!(!p.exists(), "清掉之后文件不该还在");

        // 文件本来就不在时也不能出错(第一次插桩就是这个情形)。
        assert!(!reset_log_if_oversized(&p, 32), "文件不存在时应当安静返回");
        let _ = std::fs::remove_file(&p);
    }
}