平台:Windows 11 / Rust / winit 0.30 / softbuffer 0.4 / windows 0.61 关键词:无边框窗口、透明窗口、窗口拖拽、
SetWindowPos、HWND_TOPMOST、DPI 坐标
一、背景:我想做一个桌面宠物
需求很简单:
- 桌面上悬浮一个 96×96 的小窗口 ,无边框、背景透明、始终置顶
- 窗口内容就是一张
icon.png - 鼠标左键能拖动它
- 系统托盘带右键菜单(显示/隐藏、退出)
技术选型上,我选了 winit 建窗口 + softbuffer 软件渲染(不引入 GPU/浏览器内核,保持极轻量),托盘用 tray-icon。
功能很快就跑通了------除了拖拽。
二、初版实现
最容易想到的拖拽写法:按下时记录"窗口位置"和"光标位置",移动时用位移增量去更新窗口位置。
rust
// 左键按下:记录起点
MouseButton::Left => {
self.dragging = true;
if let Ok(pos) = win.outer_position() {
self.drag_window_start = Some((pos.x, pos.y)); // 窗口屏幕坐标
}
self.drag_cursor_start = self.last_cursor; // 光标客户区坐标
}
// 鼠标移动:按位移增量移动
WindowEvent::CursorMoved { position, .. } => {
if self.dragging {
let nx = wx as f64 + (position.x - cx);
let ny = wy as f64 + (position.y - cy);
let _ = win.set_outer_position(PhysicalPosition::new(nx as i32, ny as i32));
}
self.last_cursor = Some(position);
}
能拖,但体验很糟,而且拖完还附带一个隐藏 bug。
三、三个症状
| # | 症状 | 影响 |
|---|---|---|
| 1 | 拖动时有重影,像拖着一条尾巴 | 观感很差 |
| 2 | 拖动不跟手,有轻微滞后/跳动 | 手感黏滞 |
| 3 | 拖过一次后,窗口不再置顶了,会被其他窗口盖住 | 功能失效(这个最隐蔽) |
四、排查过程
4.1 怀疑一:DPI 缩放导致坐标错乱?------ 排除
症状 2「不跟手」最像的经典原因,是逻辑坐标和物理坐标混用。
在 Windows 上,如果系统缩放是 125% / 150%,那么:
- 逻辑坐标(DPI 无关):100 个逻辑像素
- 物理坐标(真实像素):125 个物理像素
如果我把「逻辑坐标的位移」加到「物理坐标的窗口位置」上,拖拽速度就会和鼠标对不上,表现为拖得"慢半拍"。
于是我去翻了 winit 0.30.13 的源码:
rust
// winit-0.30.13/src/event.rs:242
CursorMoved {
device_id: DeviceId,
/// (x,y) coords in pixels relative to the top-left corner of the window.
position: PhysicalPosition<f64>, // ← 注意:是 PhysicalPosition
},
而 outer_position() 返回的也是物理坐标:
rust
// winit-0.30.13/src/window.rs:701
pub fn outer_position(&self) -> Result<PhysicalPosition<i32>, NotSupportedError>
两者都是物理坐标,单位一致,DPI 这个嫌疑排除。
💡 这里有个容易踩的认知误区:很多人印象里 winit 的鼠标事件是逻辑坐标。 但 winit 0.30 的
CursorMoved已经是PhysicalPosition<f64>了。 版本不同行为不同,遇到坐标问题时,查源码比查记忆靠谱。
顺带确认了另一件事:我用的是无边框窗口 ,客户区原点 == 窗口原点,所以"用位移增量计算"这个思路本身是没问题的。
4.2 怀疑二:softbuffer 反复 present 造成残影 ------ 确认
softbuffer 是软件渲染 :每次绘制都要把整块像素缓冲 present() 给系统。
拖拽时发生的事是:
scss
鼠标移动 → 移动窗口 → 触发 RedrawRequested → draw() → softbuffer present()
↑ │
└──────────────────────────────────────────────────────┘
(每次移动都伴随一次全窗口重绘)
"移动窗口"和"重绘窗口"高频交替,而软件渲染的 present 与 DWM(桌面窗口管理器)的合成时机不同步,于是就出现了上一帧残留------也就是重影。
同时,每次 nx as i32 的取整截断会让窗口位置有 1px 级别的跳动,进一步加重了不跟手的感觉。
4.3 一次失败的尝试:WM_SYSCOMMAND / SC_MOVE
查资料时看到一个"经典解法":模拟拖动标题栏。
rust
SendMessageW(hwnd, WM_SYSCOMMAND, SC_MOVE | 0x0002, 0);
这样能让 Windows 系统接管整个拖拽过程,理论上最丝滑。
我满怀信心地改了,结果------完全拖不动了。
原因:这条消息要求当前线程正处于"鼠标按下"的消息循环上下文 中才有效。而 winit 的事件回调(WindowEvent::MouseInput)是异步派发出来的,等我收到这个事件时,早就脱离了那个上下文,系统直接忽略了这条消息。
⚠️ 如果你在 winit / 类似事件循环框架里看到"发
SC_MOVE就能原生拖拽"的说法,要小心------ 它只在原生 Win32 消息处理函数(WndProc)里直接处理WM_NCHITTEST/WM_LBUTTONDOWN时才成立。
另外提一句:这个方案里我还试过 winit 的 start_drag_move(),但 winit 0.30 根本没有这个方法(那是 Tauri/其他框架提供的),编译直接报错。
4.4 真凶:HWND_TOP 悄悄干掉了"始终置顶"
既然不能用 SC_MOVE,那就回到自己移动窗口,但把 set_outer_position 换成更底层的 SetWindowPos。改完能拖了,重影也减轻了------但置顶失效了。
我去翻 winit 是怎么实现 AlwaysOnTop 的:
rust
// winit-0.30.13/src/platform_impl/windows/window_state.rs:341
SetWindowPos(
window,
match (new.contains(WindowFlags::ALWAYS_ON_TOP), ...) {
(true, false) => HWND_TOPMOST, // ← winit 用的是 TOPMOST
(false, false) => HWND_NOTOPMOST,
...
},
...
);
而我自己的移动代码里写的是:
rust
SetWindowPos(hwnd, Some(HWND_TOP), x, y, 0, 0, ...);
// ^^^^^^^^^ 问题在这
这就是根因。HWND_TOP 和 HWND_TOPMOST 看着只差几个字母,语义完全不同:
| 常量 | 含义 |
|---|---|
HWND_TOP |
放到普通窗口层级的顶部 。如果窗口原本是 topmost,会被降级为普通顶层窗口 |
HWND_TOPMOST |
放到置顶层级的顶部,窗口保持"始终置顶" |
也就是说,我每拖一次窗口,就把它从"置顶层"踢回"普通层" 。窗口创建时设的 AlwaysOnTop 就这样被我自己覆盖了。
💡 这是 Windows 窗口编程的经典坑:任何一次
SetWindowPos的调用, 只要你传了 Z 序参数(且没加SWP_NOZORDER),都会重新定义这个窗口的层级 。 想保持层级不变,要么传正确的常量,要么加SWP_NOZORDER标志。
五、最终方案
四个改动,逐个击破。
5.1 移动窗口:SetWindowPos + HWND_TOPMOST
rust
unsafe {
let _ = SetWindowPos(
hwnd,
Some(HWND_TOPMOST), // ✅ 保持置顶,而非 HWND_TOP
nx,
ny,
0,
0,
SWP_NOSIZE | SWP_NOACTIVATE, // 不改尺寸、不抢焦点
);
}
两个细节:
- 去掉了
SWP_SHOWWINDOW------它会强制显示窗口,和"显示/隐藏"功能冲突 - 加了
SWP_NOACTIVATE------拖动时不让窗口抢焦点
winit 的 Window 要拿到 HWND,通过 raw-window-handle:
rust
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
let hwnd = match window.window_handle() {
Ok(h) => match h.as_raw() {
RawWindowHandle::Win32(w) => HWND(w.hwnd.get() as *mut _),
_ => return,
},
Err(_) => return,
};
5.2 消除重影:尺寸比对式重绘
最直觉的做法是"拖拽时完全不重绘":
rust
fn draw(&mut self) {
if self.dragging { return; } // ❌ 有隐患
...
}
这确实消掉了重影,但引入了新问题:如果拖拽过程中系统触发了重绘 (拖到另一块不同 DPI 的屏幕、窗口被遮挡后又露出),跳过绘制会导致 softbuffer 内容失效,窗口直接变成全透明------图标就"消失"了。
改进为尺寸比对:只有当尺寸真的变了才必须重绘。
rust
let size = window.inner_size();
let (width, height) = (size.width, size.height);
// 拖拽中且尺寸没变 → 跳过重绘(避免 present 造成重影)
// 尺寸一旦变化(例如拖到不同 DPI 的屏幕)→ 照常重绘,防止内容丢失
if self.dragging && self.last_size == Some((width, height)) {
return;
}
self.last_size = Some((width, height));
这样既避免了无意义的 present,又不会因为漏绘而丢内容。
另外在鼠标松开时补一次重绘兜底:
rust
WindowEvent::MouseInput {
state: ElementState::Released,
button: MouseButton::Left,
..
} => {
self.dragging = false;
if let Some(win) = &self.window {
win.request_redraw(); // 拖拽期间跳过的绘制,松手时补上
}
}
5.3 拖拽起点:用 GetCursorPos 代替"上一次鼠标位置"
初版用 self.last_cursor(上一次 CursorMoved 事件记录的位置)作为起点,有个隐蔽缺陷:
如果按下鼠标时,还没发生过任何 CursorMoved 事件 (比如窗口恰好显示在光标正下方,或者刚切换显示),last_cursor 是 None,拖拽就完全不生效------表现为"第一次点下去拖不动"。
改成按下时主动查询 光标位置,而且直接取屏幕坐标 ,和 outer_position() 完全同一坐标系:
rust
#[cfg(windows)]
fn cursor_screen_pos() -> Option<(i32, i32)> {
use windows::Win32::Foundation::POINT;
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
let mut pt = POINT::default();
if unsafe { GetCursorPos(&mut pt) }.is_ok() {
Some((pt.x, pt.y))
} else {
None
}
}
移动时也用同一个函数,逻辑变得非常干净:
rust
WindowEvent::CursorMoved { .. } => {
if self.dragging {
if let (Some(win), Some((wx, wy)), Some((cx, cy))) =
(&self.window, self.drag_window_start, self.drag_cursor_start)
{
if let Some((mx, my)) = cursor_screen_pos() {
move_window(win, wx + (mx - cx), wy + (my - cy));
}
}
}
}
全是屏幕坐标,全部 i32,不再有任何坐标系转换和取整问题。
5.4 边界限制:别让宠物跑出屏幕
顺手加的体验优化------用 MonitorFromWindow + GetMonitorInfoW 拿到当前显示器的工作区 ,把窗口 clamp 在范围内:
rust
let hmonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
let mut mi = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
if GetMonitorInfoW(hmonitor, &mut mi).as_bool() {
let max_x = mi.rcWork.right - w;
let max_y = mi.rcWork.bottom - h;
// 工作区比窗口还小的极端情况,避免 max < min 导致 clamp 崩溃
if max_x > min_x { nx = nx.clamp(min_x, max_x); }
if max_y > min_y { ny = ny.clamp(min_y, max_y); }
}
两个注意点:
MONITORINFO.cbSize必须手动填 ,否则GetMonitorInfoW会失败- 用
rcWork(工作区,不含任务栏)而不是rcMonitor(整个屏幕),否则宠物能藏到任务栏下面 clamp(min, max)在min > max时会 panic,所以要先判断
六、完整代码
move_window 的最终版本:
rust
// Windows:取当前光标的「屏幕坐标」(与 outer_position 同一坐标系)
#[cfg(windows)]
fn cursor_screen_pos() -> Option<(i32, i32)> {
use windows::Win32::Foundation::POINT;
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
let mut pt = POINT::default();
if unsafe { GetCursorPos(&mut pt) }.is_ok() {
Some((pt.x, pt.y))
} else {
None
}
}
// Windows 原生移动窗口:SetWindowPos + HWND_TOPMOST,保持置顶且限制在屏幕内
#[cfg(windows)]
fn move_window(window: &Window, x: i32, y: i32) {
use windows::Win32::Foundation::{HWND, RECT};
use windows::Win32::Graphics::Gdi::{
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowRect, SetWindowPos, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOSIZE,
};
let handle = match window.window_handle() {
Ok(h) => h,
Err(_) => return,
};
let hwnd = match handle.as_raw() {
RawWindowHandle::Win32(w) => HWND(w.hwnd.get() as *mut _),
_ => return,
};
unsafe {
// 1. 取窗口尺寸,用于计算边界
let mut rect = RECT::default();
let (w, h) = if GetWindowRect(hwnd, &mut rect).is_ok() {
(rect.right - rect.left, rect.bottom - rect.top)
} else {
(0, 0)
};
// 2. 限制在显示器工作区内
let (mut nx, mut ny) = (x, y);
let hmonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
let mut mi = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
if GetMonitorInfoW(hmonitor, &mut mi).as_bool() {
let min_x = mi.rcWork.left;
let min_y = mi.rcWork.top;
let max_x = mi.rcWork.right - w;
let max_y = mi.rcWork.bottom - h;
if max_x > min_x { nx = nx.clamp(min_x, max_x); }
if max_y > min_y { ny = ny.clamp(min_y, max_y); }
}
// 3. HWND_TOPMOST 保持「始终置顶」
let _ = SetWindowPos(
hwnd,
Some(HWND_TOPMOST),
nx, ny, 0, 0,
SWP_NOSIZE | SWP_NOACTIVATE,
);
}
}
别忘了 Cargo.toml 要开对应的 feature:
toml
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = [
"Win32_UI_WindowsAndMessaging",
"Win32_Foundation",
"Win32_Graphics_Gdi", # MonitorFromWindow / GetMonitorInfoW
] }
raw-window-handle = "0.6"
七、知识点小结
这次踩坑提炼出的 4 个可复用经验:
-
HWND_TOP≠HWND_TOPMOST任何一次带 Z 序参数的SetWindowPos都会重定义窗口层级。想保持层级,用HWND_TOPMOST或加SWP_NOZORDER。框架帮你设置的窗口属性,很可能被你自己的原生 API 调用悄悄覆盖掉。 -
winit 0.30 的
CursorMoved是PhysicalPosition不是逻辑坐标。涉及坐标计算时,先确认两端的单位是否一致,别凭印象。 -
WM_SYSCOMMAND / SC_MOVE不能跨事件循环使用 它依赖"当前线程正处于鼠标按下的消息上下文"。在 winit / Tauri 这类异步派发事件的框架里直接发送是无效的。 -
软件渲染下,"移动"和"重绘"要解耦
softbuffer这类软渲染,每次present都是整块 blit。窗口移动本身不需要重绘内容,要避免在移动路径上触发无意义的重绘------但也别一刀切禁止,否则遇到尺寸变化会丢内容。
八、结果
改动后:
- ✅ 拖拽无重影
- ✅ 拖拽跟手,无滞后
- ✅ 拖完仍然置顶
- ✅ 拖不出屏幕边界
- ✅ 内容不会丢失变透明
整个程序依然保持极轻量------没有引入 WebView,没有 GPU 渲染,纯 Rust 软绘,适合这种"就贴一张图"的桌面宠物场景。
写在最后
回过头看,这个 bug 最有意思的地方在于:三个症状里,最不起眼的那个(置顶失效)反而是唯一真正的"错误" 。重影和不跟手只是体验问题,而 HWND_TOP 是实打实地覆盖了框架设置------而且它非常隐蔽,因为拖拽功能"看起来是正常的",只有当你打开另一个窗口时才会发现宠物被盖住了。
所以排查这类问题时,除了优化性能,也要留意"你的代码是否在无意中覆盖了框架的默认行为"。
如果你也在做类似的桌面小工具,希望这篇能帮你少踩几个坑。欢迎交流。