Skip to content

Commit 22872cd

Browse files
synleclaude
andcommitted
fix(macos): make moveToBack/moveToFront actually visible (v6.4.4)
`CGSOrderWindow(below)` alone was a no-op when the lowered window's app was the active app — every active-app window sits above every inactive-app window on macOS, regardless of within-app z-order. So pressing Shift+Ctrl+Cmd+Left moved nothing visible. Fix: after lowering, activate the next app's frontmost window so the lowered app drops into the inactive layer. Then remember the lowered PID in a module-local `Mutex<Option<i32>>` so the next moveToFront can target that PID instead of "currently focused window" (which is now the app we just activated to push the original behind). The remembered PID is consumed on first front-call after a back, giving a natural back/front-pair undo. Falls back to focused window when no memory is set. Windows/Linux don't need this trick (no active-app grouping). Default keybinding switched from window-toggle to app-scope so the visible behavior matches macOS reality: Shift+Ctrl+Super+Left → command/app/moveToBack Shift+Ctrl+Super+Right → command/app/moveToFront Also added `tiling::run_zorder_selftest()` — a debug aid behind `DISPLAY_DJ_ZORDER_SELFTEST=1` that runs all 6 z-order commands with state snapshots between steps. Each platform's `is_focused_window_at_front()` is now `pub(super)` so the shared self-test can observe live state. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
1 parent 210ef93 commit 22872cd

8 files changed

Lines changed: 303 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ Lives in the `tiling/` module — shares focused-window resolution and AX/Win32/
198198
- `command/window/moveToBack`, `command/app/moveToBack`
199199
- `command/window/toggleFrontBack`, `command/app/toggleFrontBack`
200200

201-
**Default keybindings**: `Shift+Ctrl+Super+Left` = window toggle, `Shift+Ctrl+Super+Right` = app toggle (mnemonic: Left = single window, Right = many windows; `Super` = Cmd/Win/Super).
201+
**Default keybindings**: `Shift+Ctrl+Super+Left` = `command/app/moveToBack`, `Shift+Ctrl+Super+Right` = `command/app/moveToFront` (mnemonic: Left = back/away, Right = front/toward you; `Super` = Cmd/Win/Super). App-scope rather than window-scope so the visible behavior is symmetric on macOS — see "Back" below for why single-window scope can't visibly lower the active app's only window.
202+
203+
**Self-test (debug aid)**: Set `DISPLAY_DJ_ZORDER_SELFTEST=1` before launching. Five seconds after startup, `tiling::run_zorder_selftest()` runs all 6 z-order commands on whatever window is currently focused, with state snapshots between steps. Logs everything with a `[zorder-selftest]` prefix. Off by default — running on every launch would manipulate the user's focused window.
202204

203205
Parsing centralized in `tiling::parse_zorder_command()`; dispatch in `tiling::execute_zorder()` which forwards to platform impls. Dispatched in-process from `tray.rs::execute_command()` on a background thread (no sidecar HTTP), so `build_command_url()` returns `None`.
204206

@@ -210,7 +212,7 @@ Parsing centralized in `tiling::parse_zorder_command()`; dispatch in `tiling::ex
210212

211213
### Back
212214

213-
- **macOS**: no public AX API to lower — uses private `CGSOrderWindow(cid, wid, -1, 0)` (CoreGraphics SkyLight, `kCGSOrderBelow`), the standard approach in yabai/Rectangle/AeroSpace. CGS extern declared next to existing `CGSGetActiveSpace`/`CGSMoveWindowsToManagedSpace` in `tiling/macos.rs`. `move_window_to_back` resolves AX → CGWindowID via `_AXUIElementGetWindow`. `move_app_to_back` iterates AXWindows front-first, lowering each — preserves within-app relative order at the bottom of the stack.
215+
- **macOS**: no public AX API to lower — uses private `CGSOrderWindow(cid, wid, -1, 0)` (CoreGraphics SkyLight, `kCGSOrderBelow`), the standard approach in yabai/Rectangle/AeroSpace. CGS extern declared next to existing `CGSGetActiveSpace`/`CGSMoveWindowsToManagedSpace` in `tiling/macos.rs`. `move_window_to_back` resolves AX → CGWindowID via `_AXUIElementGetWindow`. `move_app_to_back` iterates AXWindows front-first, lowering each — preserves within-app relative order at the bottom of the stack. **Critical: `CGSOrderWindow` alone is invisible when the lowered window's app is the active app** — every window of the active app sits above every window of every inactive app on macOS, regardless of within-app z-order. After lowering, `activate_next_app_excluding_pid()` activates the frontmost window's PID from `get_all_windows()` whose owner differs from the lowered app, dropping the lowered app into the inactive layer. The lowered PID is then stored in the module-local `LAST_BACKED_PID: Mutex<Option<i32>>` so a subsequent `move_window_to_front` / `move_app_to_front` can bring that PID back even though focus has shifted to the app we activated. The remembered PID is consumed (cleared) on the first front call after a back, giving natural back/front-pair undo semantics; once consumed, front falls back to "currently focused window." Windows and Linux do not need this trick (no active-app grouping at the WM level), so the remembered-PID logic is macOS-only.
214216
- **Windows**: `SetWindowPos(HWND_BOTTOM, …, SWP_NOACTIVATE)` (Windows transfers focus automatically). App scope iterates HWNDs front-to-back so each `HWND_BOTTOM` drops one window to the absolute bottom.
215217
- **Linux**: `ConfigureWindow(stack_mode = BELOW)` via `lower_window()` — honored by Mutter/KWin/xfwm4.
216218

src-tauri/src/config.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -382,17 +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.
385+
// Z-order: send all windows of focused app to back / bring
386+
// them all to front. Mnemonic: Left = back (away), Right =
387+
// front (toward you). Shift+Ctrl+Super (Cmd on macOS, Win
388+
// on Windows, Super on Linux) is unbound by default in all
389+
// three OSes.
389390
KeyBinding {
390391
key: "Shift+Ctrl+Super+Left".into(),
391-
command: CommandValue::Single("command/window/toggleFrontBack".into()),
392+
command: CommandValue::Single("command/app/moveToBack".into()),
392393
},
393394
KeyBinding {
394395
key: "Shift+Ctrl+Super+Right".into(),
395-
command: CommandValue::Single("command/app/toggleFrontBack".into()),
396+
command: CommandValue::Single("command/app/moveToFront".into()),
396397
},
397398
],
398399
profiles: vec![

src-tauri/src/lib.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,29 @@ pub fn run() {
623623
}
624624
}
625625

626+
// Z-order self-test (debug aid — opt-in via env var).
627+
// When DISPLAY_DJ_ZORDER_SELFTEST=1, spawn a background thread
628+
// that runs `run_zorder_selftest`. The routine sleeps 5s before
629+
// doing anything (so the operator has time to focus the window
630+
// they want to test), then exercises all 6 z-order commands
631+
// with snapshots between steps. Logs everything with a
632+
// `[zorder-selftest]` prefix. Defaults OFF — running this on
633+
// every launch would be jarring (it manipulates whatever
634+
// window is focused).
635+
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
636+
{
637+
if std::env::var("DISPLAY_DJ_ZORDER_SELFTEST")
638+
.ok()
639+
.filter(|v| !v.is_empty() && v != "0")
640+
.is_some()
641+
{
642+
let selftest_handle = app.handle().clone();
643+
std::thread::spawn(move || {
644+
tiling::run_zorder_selftest(&selftest_handle);
645+
});
646+
}
647+
}
648+
626649
// Start night mode schedule checker
627650
let schedule_handle = app.handle().clone();
628651
std::thread::spawn(move || {

src-tauri/src/tiling/linux.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -852,7 +852,10 @@ pub fn move_app_to_back(_app: &AppHandle) {
852852
/// `_NET_CLIENT_LIST_STACKING` returns windows in bottom-to-top z-order,
853853
/// so the LAST entry is topmost. Filter to normal+non-hidden windows
854854
/// (hidden = minimized in EWMH) before comparing.
855-
fn is_focused_window_at_front() -> bool {
855+
///
856+
/// `pub(super)` so the shared z-order self-test in `tiling/mod.rs` can read
857+
/// live front/back state when `DISPLAY_DJ_ZORDER_SELFTEST=1`.
858+
pub(super) fn is_focused_window_at_front() -> bool {
856859
let (conn, screen_num) = match connect() {
857860
Some(c) => c,
858861
None => return false,

src-tauri/src/tiling/macos.rs

Lines changed: 173 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,37 @@
99
//! System Settings > Privacy & Security > Accessibility.
1010
1111
use std::ffi::{c_char, c_void, CString};
12+
use std::sync::Mutex;
1213
use tauri::{AppHandle, Manager};
1314

15+
/// Remembers the PID of the app most recently sent to back via
16+
/// `move_window_to_back` / `move_app_to_back`, so the next
17+
/// `move_window_to_front` / `move_app_to_front` can target that app instead
18+
/// of "whatever is currently focused" — which would just be the *other*
19+
/// app we activated to push the original behind.
20+
///
21+
/// Acts as a single-slot LIFO stack: each back overwrites the previous
22+
/// memory; each front consumes it. If the memory is empty (or the stored
23+
/// PID is no longer alive / has no visible window), front falls back to the
24+
/// currently focused window.
25+
static LAST_BACKED_PID: Mutex<Option<i32>> = Mutex::new(None);
26+
27+
/// Record `pid` as "the app we just sent to back," so a subsequent
28+
/// move-to-front can bring it back even though focus has shifted to a
29+
/// different app. Logs at info level.
30+
fn remember_backed_pid(pid: i32) {
31+
if let Ok(mut g) = LAST_BACKED_PID.lock() {
32+
*g = Some(pid);
33+
log::info!("remember_backed_pid: stored pid={} for next moveToFront", pid);
34+
}
35+
}
36+
37+
/// Take the remembered backed PID, if any. Returns the PID and clears the
38+
/// memory so the next call after this one falls back to the focused window.
39+
fn take_backed_pid() -> Option<i32> {
40+
LAST_BACKED_PID.lock().ok().and_then(|mut g| g.take())
41+
}
42+
1443
use super::{
1544
build_sorted_window_list, calculate_target_rect, find_display_for_window,
1645
layout_across_displays, plan_expose, plan_expose_app, plan_layout_preset, Rect,
@@ -933,6 +962,25 @@ pub fn move_window_to_front(_app: &AppHandle) {
933962
log::warn!("move_window_to_front: Accessibility permission not granted");
934963
return;
935964
}
965+
// If the most recent action was a moveToBack, the focused app is
966+
// whatever we activated to push the original app behind — not the
967+
// window the user wants to bring forward. Prefer the remembered
968+
// "last backed PID" so a back/front pair forms a natural undo.
969+
if let Some(remembered_pid) = take_backed_pid() {
970+
unsafe {
971+
activate_app_by_pid(remembered_pid);
972+
// Raise the frontmost AX window of that app so it's the
973+
// topmost window within the (now-active) app.
974+
if let Some((w, _wid)) = get_all_ax_windows_for_pid(remembered_pid).into_iter().next() {
975+
raise_window(&w);
976+
}
977+
}
978+
log::info!(
979+
"move_window_to_front: brought back remembered pid={} (was sent to back earlier)",
980+
remembered_pid,
981+
);
982+
return;
983+
}
936984
unsafe {
937985
let window = match get_focused_window() {
938986
Some(w) => w,
@@ -941,10 +989,16 @@ pub fn move_window_to_front(_app: &AppHandle) {
941989
return;
942990
}
943991
};
944-
if let Some(pid) = get_window_pid(&window) {
945-
activate_app_by_pid(pid);
992+
let wid = get_window_id(&window);
993+
let pid = get_window_pid(&window);
994+
if let Some(p) = pid {
995+
activate_app_by_pid(p);
946996
}
947997
raise_window(&window);
998+
log::info!(
999+
"move_window_to_front: dispatched (pid={:?}, wid={:?})",
1000+
pid, wid,
1001+
);
9481002
}
9491003
}
9501004

@@ -962,20 +1016,62 @@ pub fn move_app_to_front(_app: &AppHandle) {
9621016
log::warn!("move_app_to_front: Accessibility permission not granted");
9631017
return;
9641018
}
1019+
// Same back/front-pair reasoning as `move_window_to_front`: if a
1020+
// moveToBack just ran, the focused app is the one we activated to
1021+
// push the original behind. Prefer the remembered PID so all of its
1022+
// windows come back together.
1023+
let remembered = take_backed_pid();
9651024
unsafe {
966-
let focused = match get_focused_window() {
967-
Some(w) => w,
968-
None => {
969-
log::info!("move_app_to_front: no focused window");
970-
return;
971-
}
972-
};
973-
let pid = match get_window_pid(&focused) {
974-
Some(p) => p,
975-
None => {
976-
log::info!("move_app_to_front: could not resolve PID for focused window");
977-
return;
1025+
let (focused, pid) = if let Some(p) = remembered {
1026+
// Synthesize a "focused window" from the first AX window of
1027+
// the remembered app. If it has no AX windows, fall through
1028+
// to the regular focused-window path below.
1029+
match get_all_ax_windows_for_pid(p).into_iter().next() {
1030+
Some((w, _wid)) => {
1031+
log::info!(
1032+
"move_app_to_front: bringing back remembered pid={} (was sent to back earlier)",
1033+
p,
1034+
);
1035+
(w, p)
1036+
}
1037+
None => {
1038+
log::info!(
1039+
"move_app_to_front: remembered pid={} has no AX windows; falling back to focused",
1040+
p,
1041+
);
1042+
let f = match get_focused_window() {
1043+
Some(w) => w,
1044+
None => {
1045+
log::info!("move_app_to_front: no focused window");
1046+
return;
1047+
}
1048+
};
1049+
let fp = match get_window_pid(&f) {
1050+
Some(fp) => fp,
1051+
None => {
1052+
log::info!("move_app_to_front: could not resolve PID for focused window");
1053+
return;
1054+
}
1055+
};
1056+
(f, fp)
1057+
}
9781058
}
1059+
} else {
1060+
let f = match get_focused_window() {
1061+
Some(w) => w,
1062+
None => {
1063+
log::info!("move_app_to_front: no focused window");
1064+
return;
1065+
}
1066+
};
1067+
let fp = match get_window_pid(&f) {
1068+
Some(fp) => fp,
1069+
None => {
1070+
log::info!("move_app_to_front: could not resolve PID for focused window");
1071+
return;
1072+
}
1073+
};
1074+
(f, fp)
9791075
};
9801076
// Activate with NSApplicationActivateAllWindows so macOS raises every
9811077
// window of the app above other apps' windows.
@@ -998,6 +1094,41 @@ unsafe fn send_window_to_back_by_id(wid: u32) {
9981094
let _ = CGSOrderWindow(cid, wid, -1, 0);
9991095
}
10001096

1097+
/// Activate the next visible app (any window from a different PID than
1098+
/// `excluded_pid`) so the excluded app loses "active app" status.
1099+
///
1100+
/// On macOS, every window of the active app sits above every window of every
1101+
/// inactive app — that grouping is enforced by the window server. So calling
1102+
/// `CGSOrderWindow(below, 0)` on a window of the *active* app only reorders
1103+
/// it within that app's windows, leaving it visually on top of all other
1104+
/// apps' windows. To genuinely push the user's window behind everything, we
1105+
/// also have to activate a different app, which makes the original app
1106+
/// inactive and drops all its windows below the newly active one.
1107+
///
1108+
/// Picks the frontmost window in the global z-order whose PID differs from
1109+
/// `excluded_pid`. Returns true if an app was activated, false if no other
1110+
/// app has a visible normal-layer window (e.g. only one app is on screen).
1111+
unsafe fn activate_next_app_excluding_pid(excluded_pid: i32) -> bool {
1112+
// get_all_windows() returns normal-layer (layer 0), on-screen,
1113+
// non-tiny windows in front-to-back z-order — exactly the candidate
1114+
// set we want for "what should become active instead".
1115+
for w in get_all_windows() {
1116+
if w.owner_pid != excluded_pid && w.owner_pid > 0 {
1117+
log::info!(
1118+
"activate_next_app_excluding_pid: activating pid={} ('{}') wid={}",
1119+
w.owner_pid, w.owner_name, w.window_id,
1120+
);
1121+
activate_app_by_pid(w.owner_pid);
1122+
return true;
1123+
}
1124+
}
1125+
log::info!(
1126+
"activate_next_app_excluding_pid: no other app with a visible window (excluded_pid={})",
1127+
excluded_pid,
1128+
);
1129+
false
1130+
}
1131+
10011132
/// Send the focused window to the back of the global z-order.
10021133
///
10031134
/// There is no public AX API for "lower window," so we use the private
@@ -1026,7 +1157,23 @@ pub fn move_window_to_back(_app: &AppHandle) {
10261157
return;
10271158
}
10281159
};
1160+
let pid = get_window_pid(&window).unwrap_or(0);
10291161
send_window_to_back_by_id(wid);
1162+
// Lowering alone is not visible if this window's app is the active
1163+
// app on macOS — the active app's windows always sit above every
1164+
// other app's windows. Activate another app to drop this app
1165+
// (and its now-lowered window) into the inactive layer.
1166+
if pid > 0 {
1167+
activate_next_app_excluding_pid(pid);
1168+
// Remember which PID we just sent back so a subsequent
1169+
// moveToFront can bring it back, even though focus has now
1170+
// shifted to the app we just activated.
1171+
remember_backed_pid(pid);
1172+
}
1173+
log::info!(
1174+
"move_window_to_back: CGSOrderWindow sent (wid={}, pid={})",
1175+
wid, pid,
1176+
);
10301177
}
10311178
}
10321179

@@ -1062,6 +1209,14 @@ pub fn move_app_to_back(_app: &AppHandle) {
10621209
for (_w, wid) in get_all_ax_windows_for_pid(pid) {
10631210
send_window_to_back_by_id(wid);
10641211
}
1212+
// Same reasoning as `move_window_to_back`: lowering is invisible
1213+
// while this app is the active app. Activate another app to push
1214+
// every window of this app into the inactive layer.
1215+
activate_next_app_excluding_pid(pid);
1216+
// Remember which PID we just sent back so a subsequent
1217+
// moveToFront can bring its windows back.
1218+
remember_backed_pid(pid);
1219+
log::info!("move_app_to_back: dispatched (pid={})", pid);
10651220
}
10661221
}
10671222

@@ -1070,7 +1225,10 @@ pub fn move_app_to_back(_app: &AppHandle) {
10701225
/// `CGWindowListCopyWindowInfo` (via `get_all_windows()`) returns normal
10711226
/// (layer-0) on-screen windows in front-to-back z-order. We compare the
10721227
/// focused window's CGWindowID against the first entry.
1073-
fn is_focused_window_at_front() -> bool {
1228+
///
1229+
/// `pub(super)` so the shared z-order self-test in `tiling/mod.rs` can read
1230+
/// live front/back state when `DISPLAY_DJ_ZORDER_SELFTEST=1`.
1231+
pub(super) fn is_focused_window_at_front() -> bool {
10741232
let focused_id = unsafe {
10751233
match get_focused_window().and_then(|w| get_window_id(&w)) {
10761234
Some(id) => id as i64,

0 commit comments

Comments
 (0)