Skip to content

Commit 005bdf4

Browse files
synleclaude
andcommitted
feat: add command/window/toggleFrontBack and command/app/toggleFrontBack
Stateless z-order toggle: if the focused window is the global topmost, the toggle sends it (or the whole app) to the back; otherwise, it brings it to the front. The current state is read from the live z-order on every call — no saved state. Bumps version to 6.4.2 (patch — additive). - New shared pure helper `tiling::is_window_at_front(focused_id, &z)` takes a front-to-back z-ordered list and returns true iff focused_id is the first entry. Each platform builds its own front-to-back list before calling — keeps the toggle semantic identical across platforms and is fully testable without a window server. - macOS: CGWindowListCopyWindowInfo (already front-to-back). - Windows: EnumWindows filtered by IsWindowVisible + should_skip_system_window (Program Manager, TextInputHost, etc.). - Linux/X11: _NET_CLIENT_LIST_STACKING reversed to front-to-back, filtered to non-hidden normal windows. Falls back to _NET_CLIENT_LIST if the WM doesn't expose stacking. - Default keybindings (added to config.rs default key_bindings): - Shift+Ctrl+Super+Left → command/window/toggleFrontBack - Shift+Ctrl+Super+Right → command/app/toggleFrontBack Mnemonic: Left = single (one window), Right = many (whole app). `Super` = Cmd on macOS, Win on Windows, Super on Linux. Both combos are unbound by default in all three OSes. The two existing default- count tests in config.rs (26 → 28) are updated. - Tests: 6 new unit tests — toggleFrontBack parsing for both scopes, is_window_at_front pure helper (true / false at non-front / missing-id / empty list), and the all-six-actions distinctness check. build_command_url tests cover the new commands return None (in-process dispatch, not HTTP-routed). - Tray.rs dispatch is unchanged — the wildcard match on command/window/* and command/app/* added in cycle 2 already routes any new command through parse_zorder_command, so the toggle variants light up automatically. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
1 parent 51a9e62 commit 005bdf4

8 files changed

Lines changed: 308 additions & 20 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Large diffs are not rendered by default.

src-tauri/src/config.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,18 @@ impl Default for Preferences {
382382
key: "Ctrl+Down".into(),
383383
command: CommandValue::Single("command/tile/exposeApp".into()),
384384
},
385+
// Z-order: toggle focused window front/back.
386+
// Mnemonic: Left = single (one window), Right = many (whole app).
387+
// Shift+Ctrl+Super (Cmd on macOS, Win on Windows, Super on Linux)
388+
// is unbound by default in all three OSes.
389+
KeyBinding {
390+
key: "Shift+Ctrl+Super+Left".into(),
391+
command: CommandValue::Single("command/window/toggleFrontBack".into()),
392+
},
393+
KeyBinding {
394+
key: "Shift+Ctrl+Super+Right".into(),
395+
command: CommandValue::Single("command/app/toggleFrontBack".into()),
396+
},
385397
],
386398
profiles: vec![
387399
Profile {
@@ -807,7 +819,7 @@ mod tests {
807819
let prefs = Preferences::default();
808820
assert!(!prefs.show_individual_displays);
809821
assert_eq!(prefs.min_brightness, 10);
810-
assert_eq!(prefs.key_bindings.len(), 26);
822+
assert_eq!(prefs.key_bindings.len(), 28);
811823
}
812824

813825
#[test]
@@ -1021,7 +1033,7 @@ mod tests {
10211033
let loaded: Preferences =
10221034
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10231035
assert_eq!(loaded.min_brightness, 10);
1024-
assert_eq!(loaded.key_bindings.len(), 26);
1036+
assert_eq!(loaded.key_bindings.len(), 28);
10251037

10261038
std::fs::remove_dir_all(&dir).ok();
10271039
}

src-tauri/src/tiling/linux.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,74 @@ pub fn move_app_to_back(_app: &AppHandle) {
847847
}
848848
}
849849

850+
/// Check if the focused window is the global topmost.
851+
///
852+
/// `_NET_CLIENT_LIST_STACKING` returns windows in bottom-to-top z-order,
853+
/// so the LAST entry is topmost. Filter to normal+non-hidden windows
854+
/// (hidden = minimized in EWMH) before comparing.
855+
fn is_focused_window_at_front() -> bool {
856+
let (conn, screen_num) = match connect() {
857+
Some(c) => c,
858+
None => return false,
859+
};
860+
let root = conn.setup().roots[screen_num].root;
861+
let focused = match get_focused_window(&conn, root) {
862+
Some(w) => w,
863+
None => return false,
864+
};
865+
let stacking_atom = match intern_atom(&conn, "_NET_CLIENT_LIST_STACKING") {
866+
Some(a) => a,
867+
None => return false,
868+
};
869+
// Reverse to get front-to-back order, then filter out non-normal /
870+
// minimized windows so the comparison reflects what the user sees.
871+
let mut front_to_back: Vec<i64> = get_window_list(&conn, root, stacking_atom)
872+
.into_iter()
873+
.rev()
874+
.filter(|&w| is_normal_window(&conn, w) && !is_window_hidden(&conn, w))
875+
.map(|w| w as i64)
876+
.collect();
877+
// If the WM doesn't expose _NET_CLIENT_LIST_STACKING, fall back to
878+
// _NET_CLIENT_LIST (mapping order — not z-order, but better than nothing).
879+
if front_to_back.is_empty() {
880+
if let Some(client_list_atom) = intern_atom(&conn, "_NET_CLIENT_LIST") {
881+
front_to_back = get_window_list(&conn, root, client_list_atom)
882+
.into_iter()
883+
.rev()
884+
.filter(|&w| is_normal_window(&conn, w) && !is_window_hidden(&conn, w))
885+
.map(|w| w as i64)
886+
.collect();
887+
}
888+
}
889+
super::is_window_at_front(focused as i64, &front_to_back)
890+
}
891+
892+
/// Toggle the focused window's z-order: front if it isn't, back if it is.
893+
pub fn toggle_window_front_back(app: &AppHandle) {
894+
if !is_x11_available() {
895+
log::warn!("toggle_window_front_back: X11 not available");
896+
return;
897+
}
898+
if is_focused_window_at_front() {
899+
move_window_to_back(app);
900+
} else {
901+
move_window_to_front(app);
902+
}
903+
}
904+
905+
/// Toggle the focused app's z-order: front if it isn't, back if it is.
906+
pub fn toggle_app_front_back(app: &AppHandle) {
907+
if !is_x11_available() {
908+
log::warn!("toggle_app_front_back: X11 not available");
909+
return;
910+
}
911+
if is_focused_window_at_front() {
912+
move_app_to_back(app);
913+
} else {
914+
move_app_to_front(app);
915+
}
916+
}
917+
850918
// ---------------------------------------------------------------------------
851919
// Public API
852920
// ---------------------------------------------------------------------------

src-tauri/src/tiling/macos.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,51 @@ pub fn move_app_to_back(_app: &AppHandle) {
10651065
}
10661066
}
10671067

1068+
/// Check if the focused window is the global topmost window.
1069+
///
1070+
/// `CGWindowListCopyWindowInfo` (via `get_all_windows()`) returns normal
1071+
/// (layer-0) on-screen windows in front-to-back z-order. We compare the
1072+
/// focused window's CGWindowID against the first entry.
1073+
fn is_focused_window_at_front() -> bool {
1074+
let focused_id = unsafe {
1075+
match get_focused_window().and_then(|w| get_window_id(&w)) {
1076+
Some(id) => id as i64,
1077+
None => return false,
1078+
}
1079+
};
1080+
let z_order: Vec<i64> = get_all_windows().iter().map(|w| w.window_id).collect();
1081+
super::is_window_at_front(focused_id, &z_order)
1082+
}
1083+
1084+
/// Toggle: if the focused window is the global topmost, send it to back;
1085+
/// otherwise bring it to front. Stateless — decided per-call from the live
1086+
/// z-order, not from saved state.
1087+
pub fn toggle_window_front_back(app: &AppHandle) {
1088+
if !unsafe { AXIsProcessTrusted() } {
1089+
log::warn!("toggle_window_front_back: Accessibility permission not granted");
1090+
return;
1091+
}
1092+
if is_focused_window_at_front() {
1093+
move_window_to_back(app);
1094+
} else {
1095+
move_window_to_front(app);
1096+
}
1097+
}
1098+
1099+
/// Toggle: if the focused window is the global topmost, send the whole app
1100+
/// to back; otherwise bring the whole app to front.
1101+
pub fn toggle_app_front_back(app: &AppHandle) {
1102+
if !unsafe { AXIsProcessTrusted() } {
1103+
log::warn!("toggle_app_front_back: Accessibility permission not granted");
1104+
return;
1105+
}
1106+
if is_focused_window_at_front() {
1107+
move_app_to_back(app);
1108+
} else {
1109+
move_app_to_front(app);
1110+
}
1111+
}
1112+
10681113
/// Set a window rect by PID and CGWindowID. Used by shared plan_* functions.
10691114
fn set_window_rect_by_id(pid: i32, wid: u32, rect: &Rect) {
10701115
unsafe {

src-tauri/src/tiling/mod.rs

Lines changed: 117 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,8 +1136,8 @@ pub fn start_tile_snap(app: AppHandle) {
11361136

11371137
/// Z-order action requested via `command/window/...` or `command/app/...`.
11381138
///
1139-
/// Two scopes (focused window vs. all windows of focused app) × two directions
1140-
/// (front, back) are exposed. The toggle variant will be added in a later cycle.
1139+
/// Two scopes (focused window vs. all windows of focused app) × three actions
1140+
/// (front, back, toggle).
11411141
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11421142
pub enum WindowZOrderAction {
11431143
/// Raise the focused window above all other windows (across apps).
@@ -1151,25 +1151,45 @@ pub enum WindowZOrderAction {
11511151
WindowToBack,
11521152
/// Lower every window of the focused app below all other apps' windows.
11531153
AppToBack,
1154+
/// If the focused window is the global topmost → send it to back;
1155+
/// otherwise → bring it to the front. Stateless (decided per-call from
1156+
/// the live z-order, not from any saved state).
1157+
WindowToggleFrontBack,
1158+
/// If the focused window is the global topmost → send the whole app's
1159+
/// windows to back; otherwise → bring the whole app to front.
1160+
AppToggleFrontBack,
11541161
}
11551162

11561163
/// Parse a z-order command string. Returns `None` if it isn't a z-order command.
11571164
///
11581165
/// Recognized commands:
1159-
/// - `command/window/moveToFront` → [`WindowZOrderAction::WindowToFront`]
1160-
/// - `command/app/moveToFront` → [`WindowZOrderAction::AppToFront`]
1161-
/// - `command/window/moveToBack` → [`WindowZOrderAction::WindowToBack`]
1162-
/// - `command/app/moveToBack` → [`WindowZOrderAction::AppToBack`]
1166+
/// - `command/window/moveToFront` → [`WindowZOrderAction::WindowToFront`]
1167+
/// - `command/app/moveToFront` → [`WindowZOrderAction::AppToFront`]
1168+
/// - `command/window/moveToBack` → [`WindowZOrderAction::WindowToBack`]
1169+
/// - `command/app/moveToBack` → [`WindowZOrderAction::AppToBack`]
1170+
/// - `command/window/toggleFrontBack` → [`WindowZOrderAction::WindowToggleFrontBack`]
1171+
/// - `command/app/toggleFrontBack` → [`WindowZOrderAction::AppToggleFrontBack`]
11631172
pub fn parse_zorder_command(command: &str) -> Option<WindowZOrderAction> {
11641173
match command {
11651174
"command/window/moveToFront" => Some(WindowZOrderAction::WindowToFront),
11661175
"command/app/moveToFront" => Some(WindowZOrderAction::AppToFront),
11671176
"command/window/moveToBack" => Some(WindowZOrderAction::WindowToBack),
11681177
"command/app/moveToBack" => Some(WindowZOrderAction::AppToBack),
1178+
"command/window/toggleFrontBack" => Some(WindowZOrderAction::WindowToggleFrontBack),
1179+
"command/app/toggleFrontBack" => Some(WindowZOrderAction::AppToggleFrontBack),
11691180
_ => None,
11701181
}
11711182
}
11721183

1184+
/// Pure helper: true iff `focused_id` is the first entry in a front-to-back
1185+
/// z-ordered window list. Each platform builds its own front-to-back list
1186+
/// (CGWindowList on macOS, EnumWindows on Windows, reversed
1187+
/// `_NET_CLIENT_LIST_STACKING` on Linux) and calls this helper to keep the
1188+
/// "is at front" semantic identical across platforms.
1189+
pub fn is_window_at_front(focused_id: i64, front_to_back_z_order: &[i64]) -> bool {
1190+
front_to_back_z_order.first().copied() == Some(focused_id)
1191+
}
1192+
11731193
/// Execute a z-order action. Dispatches to the active platform implementation.
11741194
/// No-op (with a warning log) on platforms where z-order is not supported.
11751195
pub fn execute_zorder(app: &AppHandle, action: WindowZOrderAction) {
@@ -1226,6 +1246,32 @@ pub fn execute_zorder(app: &AppHandle, action: WindowZOrderAction) {
12261246
log::warn!("zorder: app/moveToBack not supported on this platform");
12271247
}
12281248
}
1249+
WindowZOrderAction::WindowToggleFrontBack => {
1250+
#[cfg(target_os = "macos")]
1251+
macos::toggle_window_front_back(app);
1252+
#[cfg(target_os = "windows")]
1253+
windows::toggle_window_front_back(app);
1254+
#[cfg(target_os = "linux")]
1255+
linux::toggle_window_front_back(app);
1256+
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
1257+
{
1258+
let _ = app;
1259+
log::warn!("zorder: window/toggleFrontBack not supported on this platform");
1260+
}
1261+
}
1262+
WindowZOrderAction::AppToggleFrontBack => {
1263+
#[cfg(target_os = "macos")]
1264+
macos::toggle_app_front_back(app);
1265+
#[cfg(target_os = "windows")]
1266+
windows::toggle_app_front_back(app);
1267+
#[cfg(target_os = "linux")]
1268+
linux::toggle_app_front_back(app);
1269+
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
1270+
{
1271+
let _ = app;
1272+
log::warn!("zorder: app/toggleFrontBack not supported on this platform");
1273+
}
1274+
}
12291275
}
12301276
}
12311277

@@ -2502,16 +2548,72 @@ mod tests {
25022548
assert_eq!(parse_zorder_command("command/window/movetoback"), None);
25032549
}
25042550

2505-
/// All four z-order actions are distinct.
2551+
/// All six z-order actions are distinct.
25062552
#[test]
25072553
fn test_zorder_actions_are_distinct() {
2508-
let front_window = parse_zorder_command("command/window/moveToFront");
2509-
let front_app = parse_zorder_command("command/app/moveToFront");
2510-
let back_window = parse_zorder_command("command/window/moveToBack");
2511-
let back_app = parse_zorder_command("command/app/moveToBack");
2512-
assert_ne!(front_window, front_app);
2513-
assert_ne!(front_window, back_window);
2514-
assert_ne!(back_window, back_app);
2515-
assert_ne!(front_app, back_app);
2554+
let actions = [
2555+
parse_zorder_command("command/window/moveToFront"),
2556+
parse_zorder_command("command/app/moveToFront"),
2557+
parse_zorder_command("command/window/moveToBack"),
2558+
parse_zorder_command("command/app/moveToBack"),
2559+
parse_zorder_command("command/window/toggleFrontBack"),
2560+
parse_zorder_command("command/app/toggleFrontBack"),
2561+
];
2562+
for (i, a) in actions.iter().enumerate() {
2563+
assert!(a.is_some(), "action {} should parse", i);
2564+
for (j, b) in actions.iter().enumerate() {
2565+
if i != j {
2566+
assert_ne!(a, b, "actions {} and {} should differ", i, j);
2567+
}
2568+
}
2569+
}
2570+
}
2571+
2572+
/// `command/window/toggleFrontBack` parses to WindowToggleFrontBack.
2573+
#[test]
2574+
fn test_parse_zorder_window_toggle() {
2575+
assert_eq!(
2576+
parse_zorder_command("command/window/toggleFrontBack"),
2577+
Some(WindowZOrderAction::WindowToggleFrontBack),
2578+
);
2579+
}
2580+
2581+
/// `command/app/toggleFrontBack` parses to AppToggleFrontBack.
2582+
#[test]
2583+
fn test_parse_zorder_app_toggle() {
2584+
assert_eq!(
2585+
parse_zorder_command("command/app/toggleFrontBack"),
2586+
Some(WindowZOrderAction::AppToggleFrontBack),
2587+
);
2588+
}
2589+
2590+
/// `is_window_at_front` is true when the window ID is the first in the
2591+
/// front-to-back z-order list.
2592+
#[test]
2593+
fn test_is_window_at_front_true() {
2594+
let z_order = vec![100, 200, 300];
2595+
assert!(is_window_at_front(100, &z_order));
2596+
}
2597+
2598+
/// `is_window_at_front` is false when the window is below another in the stack.
2599+
#[test]
2600+
fn test_is_window_at_front_false() {
2601+
let z_order = vec![100, 200, 300];
2602+
assert!(!is_window_at_front(200, &z_order));
2603+
assert!(!is_window_at_front(300, &z_order));
2604+
}
2605+
2606+
/// `is_window_at_front` is false when the window is not in the list at all.
2607+
#[test]
2608+
fn test_is_window_at_front_missing() {
2609+
let z_order = vec![100, 200, 300];
2610+
assert!(!is_window_at_front(999, &z_order));
2611+
}
2612+
2613+
/// `is_window_at_front` is false on an empty list (no windows on screen).
2614+
#[test]
2615+
fn test_is_window_at_front_empty() {
2616+
let z_order: Vec<i64> = Vec::new();
2617+
assert!(!is_window_at_front(100, &z_order));
25162618
}
25172619
}

src-tauri/src/tiling/windows.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,57 @@ pub fn move_app_to_back(_app: &AppHandle) {
644644
}
645645
}
646646

647+
/// Check if the focused window is the global topmost. On Windows the
648+
/// foreground window is generally the topmost, so this almost always
649+
/// returns true unless the user's app is covered by a topmost (always-on-top)
650+
/// system overlay or the focus is on a non-foreground window. Used by the
651+
/// toggle dispatch.
652+
fn is_focused_window_at_front() -> bool {
653+
let foreground = match get_foreground_hwnd() {
654+
Some(h) => h,
655+
None => return false,
656+
};
657+
let foreground_id = foreground.0 as isize as i64;
658+
// Build a front-to-back list of visible top-level HWNDs (filtering
659+
// skip-list system windows like Program Manager / TextInputHost).
660+
let mut z_order: Vec<i64> = Vec::new();
661+
unsafe extern "system" fn cb(hwnd: HWND, lparam: LPARAM) -> BOOL {
662+
let z = &mut *(lparam.0 as *mut Vec<i64>);
663+
if !IsWindowVisible(hwnd).as_bool() {
664+
return TRUE;
665+
}
666+
let process = get_process_name(hwnd);
667+
let title = get_window_title(hwnd);
668+
if super::should_skip_system_window(&process, &title) {
669+
return TRUE;
670+
}
671+
z.push(hwnd.0 as isize as i64);
672+
TRUE
673+
}
674+
unsafe {
675+
let _ = EnumWindows(Some(cb), LPARAM(&mut z_order as *mut Vec<i64> as isize));
676+
}
677+
super::is_window_at_front(foreground_id, &z_order)
678+
}
679+
680+
/// Toggle the focused window's z-order: front if it isn't, back if it is.
681+
pub fn toggle_window_front_back(app: &AppHandle) {
682+
if is_focused_window_at_front() {
683+
move_window_to_back(app);
684+
} else {
685+
move_window_to_front(app);
686+
}
687+
}
688+
689+
/// Toggle the focused app's z-order: front if it isn't, back if it is.
690+
pub fn toggle_app_front_back(app: &AppHandle) {
691+
if is_focused_window_at_front() {
692+
move_app_to_back(app);
693+
} else {
694+
move_app_to_front(app);
695+
}
696+
}
697+
647698
// ---------------------------------------------------------------------------
648699
// Public API
649700
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)