Dragging a Desktop Pet in Rust: The HWND_TOP Trap That Silently Kills Always-On-Top
Platform: Windows 11 / Rust / winit 0.30 / softbuffer 0.4 / windows 0.61 Keywords: borderless window, transparent window, window dragging,
SetWindowPos,HWND_TOPMOST, DPI coordinates
1. Background: I wanted to make a desktop pet
The requirements were simple:
- A 96×96 small window floating on the desktop, borderless, transparent background, always on top
- The window content is just an
icon.png - It can be dragged with the left mouse button
- A system tray with a right-click menu (show/hide, quit)
For the tech stack, I chose winit for window creation + softbuffer for software rendering (no GPU/browser engine, keeping it extremely lightweight), and tray-icon for the tray.
The features worked quickly—except for dragging.
2. Initial implementation
The most obvious dragging approach: record the "window position" and "cursor position" on press, then update the window position using the displacement delta on move.
// Left button pressed: record starting points
MouseButton::Left => {
self.dragging = true;
if let Ok(pos) = win.outer_position() {
self.drag_window_start = Some((pos.x, pos.y)); // window screen coordinates
}
self.drag_cursor_start = self.last_cursor; // cursor client-area coordinates
}
// Mouse moved: move by displacement delta
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);
}
It dragged, but the experience was terrible, and it also carried a hidden bug after dragging.
3. Three symptoms
| # | Symptom | Impact |
|---|---|---|
| 1 | Ghosting while dragging, like dragging a tail | Looks bad |
| 2 | Dragging doesn't follow the hand, slight lag/jitter | Sticky feel |
| 3 | After dragging once, the window is no longer on top, gets covered by other windows | Functionality broken (this is the most hidden) |
4. Investigation process
4.1 Suspicion 1: DPI scaling causing coordinate chaos? — Ruled out
The most classic cause for symptom 2 "not following the hand" is mixing logical coordinates and physical coordinates.
On Windows, if the system scaling is 125% / 150%:
- Logical coordinates (DPI-independent): 100 logical pixels
- Physical coordinates (real pixels): 125 physical pixels
If I add a "logical coordinate displacement" to a "physical coordinate window position", the drag speed won't match the mouse, appearing to drag "half a beat slow".
So I went to check the winit 0.30.13 source code:
// 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>, // ← Note: it's PhysicalPosition
},
And outer_position() also returns physical coordinates:
// winit-0.30.13/src/window.rs:701
pub fn outer_position(&self) -> Result<PhysicalPosition<i32>, NotSupportedError>
Both are physical coordinates, units are consistent, DPI suspicion ruled out.
💡 There's an easy misconception here: many people remember winit's mouse events as logical coordinates. But winit 0.30's
CursorMovedis alreadyPhysicalPosition<f64>. Behavior differs by version; when encountering coordinate issues, checking the source is more reliable than checking memory.
I also confirmed another thing: I'm using a borderless window, client area origin == window origin, so the idea of "calculating using displacement delta" itself is not wrong.
4.2 Suspicion 2: softbuffer repeatedly presenting causing ghosting — Confirmed
softbuffer is software rendering: every draw must present() the entire pixel buffer to the system.
What happens during dragging is:
mouse move → move window → trigger RedrawRequested → draw() → softbuffer present()
↑ │
└──────────────────────────────────────────────────────┘
(every move is accompanied by a full window redraw)
"Moving window" and "redrawing window" alternate at high frequency, and software rendering's present is not synchronized with DWM (Desktop Window Manager) compositing timing, resulting in previous frame residue—that is, ghosting.
Meanwhile, the nx as i32 truncation each time causes 1px-level jitter in window position, further worsening the not-following-hand feeling.
4.3 A failed attempt: WM_SYSCOMMAND / SC_MOVE
While researching, I saw a "classic solution": simulate dragging the title bar.
SendMessageW(hwnd, WM_SYSCOMMAND, SC_MOVE | 0x0002, 0);
This lets the Windows system take over the entire drag process, theoretically the smoothest.
I changed it with full confidence, and the result—completely undraggable.
Reason: this message requires that the current thread is in the "mouse button pressed" message loop context to be effective. But winit's event callbacks (WindowEvent::MouseInput) are dispatched asynchronously; by the time I receive this event, I've long left that context, and the system simply ignores the message.
⚠️ If you see claims in winit / similar event loop frameworks that "sending
SC_MOVEgives native dragging", be careful— it only works when directly handlingWM_NCHITTEST/WM_LBUTTONDOWNinside a native Win32 message handler (WndProc).
One more note: in this approach I also tried winit's start_drag_move(), but winit 0.30 simply doesn't have this method (it's provided by Tauri/other frameworks), compilation errored directly.
4.4 The real culprit: HWND_TOP silently killed "always on top"
Since SC_MOVE couldn't be used, I went back to moving the window myself, but replaced set_outer_position with the lower-level SetWindowPos. After the change, dragging worked, ghosting was reduced—but always-on-top broke.
I went to check how winit implements AlwaysOnTop:
// 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 uses TOPMOST
(false, false) => HWND_NOTOPMOST,
...
},
...
);
But in my own move code I wrote:
SetWindowPos(hwnd, Some(HWND_TOP), x, y, 0, 0, ...);
// ^^^^^^^^^ problem here
This is the root cause. HWND_TOP and HWND_TOPMOST look only a few letters apart, but their semantics are completely different:
| Constant | Meaning |
|---|---|
HWND_TOP |
Place at the top of the normal window Z-order. If the window was originally topmost, it gets demoted to a normal top-level window |
HWND_TOPMOST |
Place at the top of the topmost Z-order, window stays "always on top" |
In other words, every time I dragged the window, I kicked it from the "topmost layer" back to the "normal layer". The AlwaysOnTop set at window creation was overwritten by myself.
💡 This is a classic pitfall in Windows window programming: any
SetWindowPoscall, as long as you pass a Z-order parameter (and don't addSWP_NOZORDER), will redefine this window's layer. To keep the layer unchanged, either pass the correct constant, or add theSWP_NOZORDERflag.
5. Final solution
Four changes, each targeting one problem.
5.1 Moving window: SetWindowPos + HWND_TOPMOST
unsafe {
let _ = SetWindowPos(
hwnd,
Some(HWND_TOPMOST), // ✅ keep topmost, not HWND_TOP
nx,
ny,
0,
0,
SWP_NOSIZE | SWP_NOACTIVATE, // don't change size, don't steal focus
);
}
Two details:
- Removed
SWP_SHOWWINDOW—it forces the window to show, conflicting with "show/hide" functionality - Added
SWP_NOACTIVATE—prevents the window from stealing focus while dragging
To get the HWND from winit's Window, use raw-window-handle:
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 Eliminating ghosting: size-comparison-based redraw
The most intuitive approach is "don't redraw at all while dragging":
fn draw(&mut self) {
if self.dragging { return; } // ❌ has hidden danger
...
}
This indeed eliminates ghosting, but introduces a new problem: if the system triggers a redraw during dragging (dragged to a different DPI screen, window covered then revealed), skipping drawing causes softbuffer content to become invalid, and the window turns completely transparent—the icon "disappears".
Improved to size comparison: only redraw when the size actually changes.
let size = window.inner_size();
let (width, height) = (size.width, size.height);
// Dragging and size unchanged → skip redraw (avoid present causing ghosting)
// Size changed (e.g., dragged to different DPI screen) → redraw normally, prevent content loss
if self.dragging && self.last_size == Some((width, height)) {
return;
}
self.last_size = Some((width, height));
This avoids meaningless present while not losing content due to missed draws.
Additionally, do a catch-up redraw on mouse release:
WindowEvent::MouseInput {
state: ElementState::Released,
button: MouseButton::Left,
..
} => {
self.dragging = false;
if let Some(win) = &self.window {
win.request_redraw(); // redraw skipped during drag, catch up on release
}
}
5.3 Drag starting point: use GetCursorPos instead of "last mouse position"
The initial version used self.last_cursor (position recorded from the last CursorMoved event) as the starting point, which has a hidden flaw:
If no CursorMoved event has occurred yet when the mouse is pressed (e.g., the window happens to appear right under the cursor, or just switched to show), last_cursor is None, and dragging doesn't work at all—manifesting as "can't drag on the first click".
Changed to actively query the cursor position on press, and directly get screen coordinates, in the exact same coordinate system as 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
}
}
Use the same function on move, making the logic very clean:
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));
}
}
}
}
All screen coordinates, all i32, no more coordinate system conversions or truncation issues.
5.4 Boundary restriction: don't let the pet run off screen
An experience optimization added on the side—use MonitorFromWindow + GetMonitorInfoW to get the current monitor's work area, and clamp the window within it:
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;
// Extreme case where work area is smaller than window, avoid max < min causing clamp panic
if max_x > min_x { nx = nx.clamp(min_x, max_x); }
if max_y > min_y { ny = ny.clamp(min_y, max_y); }
}
Two points to note:
MONITORINFO.cbSizemust be filled manually, otherwiseGetMonitorInfoWwill fail- Use
rcWork(work area, excluding taskbar) notrcMonitor(entire screen), otherwise the pet can hide under the taskbar clamp(min, max)panics whenmin > max, so check first
6. Complete code
Final version of move_window:
// Windows: get current cursor's "screen coordinates" (same coordinate system as 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 native window move: SetWindowPos + HWND_TOPMOST, keep topmost and confine to screen
#[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. Get window size for boundary calculation
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. Confine within monitor work area
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 keeps "always on top"
let _ = SetWindowPos(
hwnd,
Some(HWND_TOPMOST),
nx, ny, 0, 0,
SWP_NOSIZE | SWP_NOACTIVATE,
);
}
}
Don't forget to enable the corresponding features in Cargo.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"
7. Knowledge summary
4 reusable lessons extracted from this debugging session:
HWND_TOP≠HWND_TOPMOSTAnySetWindowPoscall with a Z-order parameter redefines the window layer. To keep the layer, useHWND_TOPMOSTor addSWP_NOZORDER. Window properties set by the framework can be silently overwritten by your own native API calls.winit 0.30's
CursorMovedisPhysicalPositionNot logical coordinates. When doing coordinate calculations, first confirm that units on both ends are consistent; don't rely on memory.WM_SYSCOMMAND / SC_MOVEcannot be used across event loops It depends on "the current thread being in the mouse-pressed message context". Sending it directly in frameworks like winit / Tauri that dispatch events asynchronously is ineffective.Under software rendering, "moving" and "redrawing" must be decoupled With soft rendering like
softbuffer, everypresentis a full blit. Window movement itself doesn't need content redraw; avoid triggering meaningless redraws on the move path—but also don't blanket-ban them, or content will be lost on size changes.
8. Result
After the changes:
- ✅ Dragging no ghosting
- ✅ Dragging follows the hand, no lag
- ✅ After dragging still on top
- ✅ Can't drag off screen boundaries
- ✅ Content won't be lost or become transparent
The entire program remains extremely lightweight—no WebView introduced, no GPU rendering, pure Rust software rendering, suitable for this "just paste an image" desktop pet scenario.
Final words
Looking back, the most interesting part of this bug is: among the three symptoms, the least noticeable one (topmost failure) was actually the only real "error". Ghosting and not-following-hand were just experience issues, while HWND_TOP concretely overwrote the framework setting—and it was very hidden, because the drag functionality "looked normal", and you'd only discover the pet was covered when you opened another window.
So when troubleshooting this kind of problem, besides optimizing performance, also pay attention to "whether your code is unintentionally overwriting the framework's default behavior".
If you're also making similar desktop gadgets, hope this helps you avoid a few pitfalls. Welcome to discuss.