Skip to content

Commit 1f58336

Browse files
Zuka MargvelashviliZuka Margvelashvili
authored andcommitted
Release 0.2.1
1 parent a05aa78 commit 1f58336

File tree

8 files changed

+153
-27
lines changed

8 files changed

+153
-27
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Changelog
2+
3+
## 0.2.1 - 2026-03-07
4+
5+
- Fixed window targeting so snap actions ignore shell UI like the Start menu, tray popups, and tool windows.
6+
- Added backend tests for snap-target filtering to prevent regressions.
7+
- Included current 0.2.x improvements in the release branch, including start-hidden tray behavior and the repo validation scripts.
8+
9+
## 0.2.0
10+
11+
- Added the hidden tray startup flow for the main app window.
12+
- Added frontend and backend validation scripts, tests, and coverage helpers.

app/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "app",
33
"private": true,
4-
"version": "0.2.0",
4+
"version": "0.2.1",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

app/src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src-tauri/Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "app"
3-
version = "0.2.0"
3+
version = "0.2.1"
44
description = "RectangleWin - window snapping with keyboard shortcuts"
55
authors = ["you"]
66
edition = "2021"
@@ -32,4 +32,3 @@ windows = { version = "0.58", features = [
3232
"Win32_UI_Input_KeyboardAndMouse",
3333
"Win32_UI_WindowsAndMessaging",
3434
] }
35-

app/src-tauri/src/manager.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use crate::rect::{EngineRect, Rect};
1010
#[cfg(windows)]
1111
use crate::win32::{
1212
enum_monitors, get_cursor_pos, get_foreground_window, get_monitor_from_point,
13-
get_monitor_from_window, get_process_image_name, set_cursor_pos, set_foreground_window,
14-
set_window_bounds, try_get_monitor_info, try_get_window_bounds,
13+
get_monitor_from_window, get_process_image_name, resolve_snap_target_window, set_cursor_pos,
14+
set_foreground_window, set_window_bounds, try_get_monitor_info, try_get_window_bounds,
1515
};
1616
#[cfg(windows)]
1717
use std::collections::HashMap;
@@ -266,7 +266,10 @@ impl WindowManager {
266266
_hwnd_override: Option<windows::Win32::Foundation::HWND>,
267267
options: &ExecuteOptions,
268268
) -> bool {
269-
let hwnd = match _hwnd_override.or_else(get_foreground_window) {
269+
let hwnd = match _hwnd_override
270+
.and_then(resolve_snap_target_window)
271+
.or_else(get_foreground_window)
272+
{
270273
Some(h) => h,
271274
None => return false,
272275
};

app/src-tauri/src/win32/impl_.rs

Lines changed: 129 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
//! Windows implementation of Win32 wrappers.
22
33
use crate::rect::Rect;
4-
use windows::Win32::Foundation::{CloseHandle, RECT as WinRect, BOOL, HWND, LPARAM, POINT};
4+
use windows::Win32::Foundation::{CloseHandle, BOOL, HWND, LPARAM, POINT, RECT as WinRect};
55
use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS};
66
use windows::Win32::Graphics::Gdi::{
7-
EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MonitorFromPoint, MonitorFromWindow,
7+
EnumDisplayMonitors, GetMonitorInfoW, MonitorFromPoint, MonitorFromWindow, HMONITOR,
88
MONITORINFOEXW, MONITOR_DEFAULTTONEAREST,
99
};
1010
use windows::Win32::System::Threading::{
11-
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_NAME_NATIVE,
12-
QueryFullProcessImageNameW,
11+
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_NATIVE, PROCESS_QUERY_LIMITED_INFORMATION,
1312
};
1413
use windows::Win32::UI::WindowsAndMessaging::{
15-
GetAncestor, GetCursorPos, GetForegroundWindow, GetWindowRect, GetWindowThreadProcessId,
16-
IsWindow, SetCursorPos, SetForegroundWindow, SetWindowPos,
17-
GA_ROOT, SET_WINDOW_POS_FLAGS,
14+
GetAncestor, GetCursorPos, GetForegroundWindow, GetWindowLongW, GetWindowRect,
15+
GetWindowThreadProcessId, IsWindow, IsWindowVisible, SetCursorPos, SetForegroundWindow,
16+
SetWindowPos, GA_ROOT, GWL_EXSTYLE, GWL_STYLE, SET_WINDOW_POS_FLAGS, WS_CAPTION,
17+
WS_EX_TOOLWINDOW, WS_MAXIMIZEBOX, WS_MINIMIZEBOX, WS_POPUP, WS_THICKFRAME, WS_VISIBLE,
1818
};
1919

2020
const SWP_NOZORDER_U: u32 = 0x0004;
@@ -38,24 +38,69 @@ fn rect_to_win_rect(r: &Rect) -> WinRect {
3838
}
3939
}
4040

41-
/// Get foreground window handle (root/top-level), or None if invalid.
42-
/// Uses GetAncestor(GA_ROOT) so we always move the top-level window, not a child
43-
/// (e.g. when the Tauri/WebView window is focused, the foreground may be the WebView child).
44-
pub fn get_foreground_window() -> Option<HWND> {
41+
fn has_style(style: u32, flag: u32) -> bool {
42+
(style & flag) == flag
43+
}
44+
45+
fn is_non_processable_popup(style: u32) -> bool {
46+
let is_popup = has_style(style, WS_POPUP.0);
47+
let has_thick_frame = has_style(style, WS_THICKFRAME.0);
48+
let has_caption = has_style(style, WS_CAPTION.0);
49+
let has_minimize_or_maximize =
50+
has_style(style, WS_MINIMIZEBOX.0) || has_style(style, WS_MAXIMIZEBOX.0);
51+
52+
is_popup && !(has_thick_frame && (has_caption || has_minimize_or_maximize))
53+
}
54+
55+
fn is_snap_target_window(hwnd: HWND) -> bool {
56+
unsafe {
57+
if hwnd.0.is_null() || !IsWindow(hwnd).as_bool() || !IsWindowVisible(hwnd).as_bool() {
58+
return false;
59+
}
60+
61+
let style = GetWindowLongW(hwnd, GWL_STYLE) as u32;
62+
let ex_style = GetWindowLongW(hwnd, GWL_EXSTYLE) as u32;
63+
64+
if !has_style(style, WS_VISIBLE.0) {
65+
return false;
66+
}
67+
if has_style(ex_style, WS_EX_TOOLWINDOW.0) {
68+
return false;
69+
}
70+
if is_non_processable_popup(style) {
71+
return false;
72+
}
73+
74+
true
75+
}
76+
}
77+
78+
/// Resolve a caller-provided HWND to a snap target window, filtering out shell popups
79+
/// and utility surfaces like the Start menu or tool windows.
80+
pub fn resolve_snap_target_window(hwnd: HWND) -> Option<HWND> {
4581
unsafe {
46-
let hwnd = GetForegroundWindow();
4782
if hwnd.0.is_null() || !IsWindow(hwnd).as_bool() {
4883
return None;
4984
}
85+
5086
let root = GetAncestor(hwnd, GA_ROOT);
51-
if root.0.is_null() || !IsWindow(root).as_bool() {
52-
Some(hwnd)
87+
let candidate = if root.0.is_null() || !IsWindow(root).as_bool() {
88+
hwnd
5389
} else {
54-
Some(root)
55-
}
90+
root
91+
};
92+
93+
is_snap_target_window(candidate).then_some(candidate)
5694
}
5795
}
5896

97+
/// Get foreground window handle (root/top-level), or None if invalid.
98+
/// Uses GetAncestor(GA_ROOT) so we always move the top-level window, not a child
99+
/// (e.g. when the Tauri/WebView window is focused, the foreground may be the WebView child).
100+
pub fn get_foreground_window() -> Option<HWND> {
101+
unsafe { resolve_snap_target_window(GetForegroundWindow()) }
102+
}
103+
59104
/// Get window bounds (prefer DWM extended frame bounds).
60105
pub fn try_get_window_bounds(hwnd: HWND, use_window_rect: bool) -> Option<Rect> {
61106
unsafe {
@@ -269,4 +314,71 @@ pub fn get_process_image_name(hwnd: HWND) -> Option<String> {
269314
.and_then(|n| n.to_str())
270315
.map(String::from)
271316
}
272-
}
317+
}
318+
319+
#[cfg(all(test, windows))]
320+
mod tests {
321+
use super::{is_non_processable_popup, is_snap_target_window, resolve_snap_target_window};
322+
use windows::core::w;
323+
use windows::Win32::Foundation::{HINSTANCE, HWND};
324+
use windows::Win32::UI::WindowsAndMessaging::{
325+
CreateWindowExW, DefWindowProcW, DestroyWindow, RegisterClassW, HMENU, WNDCLASSW,
326+
WS_CAPTION, WS_CLIPCHILDREN, WS_CLIPSIBLINGS, WS_EX_TOOLWINDOW, WS_MAXIMIZEBOX,
327+
WS_MINIMIZEBOX, WS_OVERLAPPED, WS_POPUP, WS_THICKFRAME, WS_VISIBLE,
328+
};
329+
330+
#[test]
331+
fn popup_menu_styles_are_rejected() {
332+
let style = (WS_POPUP | WS_CLIPCHILDREN | WS_CLIPSIBLINGS | WS_VISIBLE).0;
333+
assert!(is_non_processable_popup(style));
334+
}
335+
336+
#[test]
337+
fn popup_app_styles_are_allowed() {
338+
let style =
339+
(WS_POPUP | WS_THICKFRAME | WS_CAPTION | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_VISIBLE)
340+
.0;
341+
assert!(!is_non_processable_popup(style));
342+
}
343+
344+
#[test]
345+
fn tool_windows_are_not_snap_targets() {
346+
unsafe {
347+
let class_name = w!("RectangleWinImplTests");
348+
let mut wc = WNDCLASSW::default();
349+
wc.lpfnWndProc = Some(wndproc);
350+
wc.lpszClassName = class_name;
351+
let _ = RegisterClassW(&wc);
352+
353+
let hwnd = CreateWindowExW(
354+
WS_EX_TOOLWINDOW,
355+
class_name,
356+
w!("Tool"),
357+
WS_OVERLAPPED | WS_VISIBLE,
358+
0,
359+
0,
360+
100,
361+
100,
362+
HWND::default(),
363+
HMENU::default(),
364+
HINSTANCE::default(),
365+
None,
366+
)
367+
.expect("create tool window");
368+
369+
assert!(!is_snap_target_window(hwnd));
370+
assert_eq!(resolve_snap_target_window(hwnd), None);
371+
372+
let _ = DestroyWindow(hwnd);
373+
}
374+
}
375+
376+
unsafe extern "system" fn wndproc(
377+
hwnd: HWND,
378+
msg: u32,
379+
wparam: windows::Win32::Foundation::WPARAM,
380+
lparam: windows::Win32::Foundation::LPARAM,
381+
) -> windows::Win32::Foundation::LRESULT {
382+
DefWindowProcW(hwnd, msg, wparam, lparam)
383+
}
384+
}

app/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "RectangleWin",
4-
"version": "0.2.0",
4+
"version": "0.2.1",
55
"identifier": "com.rectanglewin.app",
66
"build": {
77
"beforeDevCommand": "npm run dev",

0 commit comments

Comments
 (0)