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);
}
}