Add stacked overflow remediation
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
92e1d140acce39acc344f0b90da4826e7a0543f3- Parents
-
ae35f6c - Tree
e1036a4
92e1d14
92e1d140acce39acc344f0b90da4826e7a0543f3ae35f6c
e1036a4| Status | File | + | - |
|---|---|---|---|
| M |
.gitignore
|
1 | 0 |
| M |
tarmac/src/config/lua.rs
|
7 | 0 |
| M |
tarmac/src/core/input.rs
|
2 | 0 |
| M |
tarmac/src/core/state.rs
|
175 | 138 |
| M |
tarmac/src/core/tree.rs
|
375 | 2 |
| M |
tarmac/src/main.rs
|
33 | 6 |
| M |
tarmacctl/src/main.rs
|
1 | 0 |
.gitignoremodified@@ -15,5 +15,6 @@ docs/ | |||
| 15 | .claude/ | 15 | .claude/ |
| 16 | CLAUDE.md | 16 | CLAUDE.md |
| 17 | .ref/ | 17 | .ref/ |
| 18 | +.refs/ | ||
| 18 | AGENTS.md | 19 | AGENTS.md |
| 19 | nohup.out | 20 | nohup.out |
tarmac/src/config/lua.rsmodified@@ -840,6 +840,7 @@ fn parse_action(action: &str) -> Result<Action, &'static str> { | |||
| 840 | "close" => Ok(Action::CloseWindow), | 840 | "close" => Ok(Action::CloseWindow), |
| 841 | "equalize" => Ok(Action::Equalize), | 841 | "equalize" => Ok(Action::Equalize), |
| 842 | "toggle_float" => Ok(Action::ToggleFloat), | 842 | "toggle_float" => Ok(Action::ToggleFloat), |
| 843 | + "unstack" => Ok(Action::Unstack), | ||
| 843 | "workspace_next" => Ok(Action::WorkspaceNext), | 844 | "workspace_next" => Ok(Action::WorkspaceNext), |
| 844 | "workspace_prev" => Ok(Action::WorkspacePrev), | 845 | "workspace_prev" => Ok(Action::WorkspacePrev), |
| 845 | "focus_monitor_next" => Ok(Action::FocusMonitorNext), | 846 | "focus_monitor_next" => Ok(Action::FocusMonitorNext), |
@@ -897,6 +898,11 @@ pub fn default_keybinds(settings: &Settings) -> Vec<LuaKeybind> { | |||
| 897 | key: Key::Space, | 898 | key: Key::Space, |
| 898 | action: Action::ToggleFloat, | 899 | action: Action::ToggleFloat, |
| 899 | }, | 900 | }, |
| 901 | + LuaKeybind { | ||
| 902 | + modifiers: ms, | ||
| 903 | + key: Key::U, | ||
| 904 | + action: Action::Unstack, | ||
| 905 | + }, | ||
| 900 | // Focus | 906 | // Focus |
| 901 | LuaKeybind { | 907 | LuaKeybind { |
| 902 | modifiers: m, | 908 | modifiers: m, |
@@ -1162,6 +1168,7 @@ pub fn format_action(action: &Action) -> String { | |||
| 1162 | Action::WorkspaceNext => "workspace_next".to_string(), | 1168 | Action::WorkspaceNext => "workspace_next".to_string(), |
| 1163 | Action::WorkspacePrev => "workspace_prev".to_string(), | 1169 | Action::WorkspacePrev => "workspace_prev".to_string(), |
| 1164 | Action::ToggleFloat => "toggle_float".to_string(), | 1170 | Action::ToggleFloat => "toggle_float".to_string(), |
| 1171 | + Action::Unstack => "unstack".to_string(), | ||
| 1165 | Action::ToggleSpecial(name) => format!("toggle_special {name}"), | 1172 | Action::ToggleSpecial(name) => format!("toggle_special {name}"), |
| 1166 | Action::MoveToSpecial(name) => format!("move_to_special {name}"), | 1173 | Action::MoveToSpecial(name) => format!("move_to_special {name}"), |
| 1167 | Action::FocusMonitorNext => "focus_monitor_next".to_string(), | 1174 | Action::FocusMonitorNext => "focus_monitor_next".to_string(), |
tarmac/src/core/input.rsmodified@@ -191,6 +191,7 @@ pub enum Action { | |||
| 191 | FocusMonitorPrev, // Focus previous monitor | 191 | FocusMonitorPrev, // Focus previous monitor |
| 192 | MoveToMonitorNext, // Move window to next monitor | 192 | MoveToMonitorNext, // Move window to next monitor |
| 193 | MoveToMonitorPrev, // Move window to previous monitor | 193 | MoveToMonitorPrev, // Move window to previous monitor |
| 194 | + Unstack, // Restore a stacked subtree to its saved BSP shape | ||
| 194 | Reload, // Hot reload config | 195 | Reload, // Hot reload config |
| 195 | Exit, // Clean exit | 196 | Exit, // Clean exit |
| 196 | } | 197 | } |
@@ -244,6 +245,7 @@ impl KeybindManager { | |||
| 244 | // Spawn / close | 245 | // Spawn / close |
| 245 | mgr.add(m, Key::Return, Action::SpawnTerminal); | 246 | mgr.add(m, Key::Return, Action::SpawnTerminal); |
| 246 | mgr.add(ms, Key::Q, Action::CloseWindow); | 247 | mgr.add(ms, Key::Q, Action::CloseWindow); |
| 248 | + mgr.add(ms, Key::U, Action::Unstack); | ||
| 247 | 249 | ||
| 248 | // Focus: Option+hjkl and Option+Arrows | 250 | // Focus: Option+hjkl and Option+Arrows |
| 249 | mgr.add(m, Key::H, Action::Focus(Left)); | 251 | mgr.add(m, Key::H, Action::Focus(Left)); |
tarmac/src/core/state.rsmodified@@ -345,6 +345,22 @@ impl WmState { | |||
| 345 | self.ax_refs.get(&id) | 345 | self.ax_refs.get(&id) |
| 346 | } | 346 | } |
| 347 | 347 | ||
| 348 | + fn workspace_render_geometries(&self, ws_idx: usize, rect: Rect) -> Vec<(WindowId, Rect)> { | ||
| 349 | + let (gap_inner, gap_outer) = self.workspace_gaps(ws_idx); | ||
| 350 | + self.workspaces | ||
| 351 | + .get(ws_idx) | ||
| 352 | + .tree | ||
| 353 | + .calculate_geometries_with_gaps(rect, gap_inner, gap_outer, true) | ||
| 354 | + } | ||
| 355 | + | ||
| 356 | + fn workspace_focus_geometries(&self, ws_idx: usize, rect: Rect) -> Vec<(WindowId, Rect)> { | ||
| 357 | + let (gap_inner, gap_outer) = self.workspace_gaps(ws_idx); | ||
| 358 | + self.workspaces | ||
| 359 | + .get(ws_idx) | ||
| 360 | + .tree | ||
| 361 | + .calculate_focus_geometries_with_gaps(rect, gap_inner, gap_outer, true) | ||
| 362 | + } | ||
| 363 | + | ||
| 348 | pub fn process_events(&mut self) { | 364 | pub fn process_events(&mut self) { |
| 349 | let events: Vec<QueuedEvent> = self.event_queue.borrow_mut().drain(..).collect(); | 365 | let events: Vec<QueuedEvent> = self.event_queue.borrow_mut().drain(..).collect(); |
| 350 | for queued in events { | 366 | for queued in events { |
@@ -363,10 +379,7 @@ impl WmState { | |||
| 363 | let ws_idx = monitor.active_workspace; | 379 | let ws_idx = monitor.active_workspace; |
| 364 | let ws = self.workspaces.get(ws_idx); | 380 | let ws = self.workspaces.get(ws_idx); |
| 365 | let screen_rect = self.monitor_rect(mi); | 381 | let screen_rect = self.monitor_rect(mi); |
| 366 | - let (gap_inner, gap_outer) = self.workspace_gaps(ws_idx); | 382 | + let geometries = self.workspace_render_geometries(ws_idx, screen_rect); |
| 367 | - let geometries = | ||
| 368 | - ws.tree | ||
| 369 | - .calculate_geometries_with_gaps(screen_rect, gap_inner, gap_outer, true); | ||
| 370 | tracing::debug!(monitor = mi, workspace = %ws.id, windows = geometries.len(), | 383 | tracing::debug!(monitor = mi, workspace = %ws.id, windows = geometries.len(), |
| 371 | sr_x = screen_rect.x, sr_y = screen_rect.y, sr_w = screen_rect.width, | 384 | sr_x = screen_rect.x, sr_y = screen_rect.y, sr_w = screen_rect.width, |
| 372 | sr_h = screen_rect.height, "apply_layout"); | 385 | sr_h = screen_rect.height, "apply_layout"); |
@@ -408,10 +421,7 @@ impl WmState { | |||
| 408 | let ws_idx = monitor.active_workspace; | 421 | let ws_idx = monitor.active_workspace; |
| 409 | let ws = self.workspaces.get(ws_idx); | 422 | let ws = self.workspaces.get(ws_idx); |
| 410 | let sr = self.monitor_rect(mi); | 423 | let sr = self.monitor_rect(mi); |
| 411 | - let (gap_inner, gap_outer) = self.workspace_gaps(ws_idx); | 424 | + let geoms = self.workspace_focus_geometries(ws_idx, sr); |
| 412 | - let geoms = ws | ||
| 413 | - .tree | ||
| 414 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | ||
| 415 | let focused = ws.focused; | 425 | let focused = ws.focused; |
| 416 | 426 | ||
| 417 | for (wid, rect) in &geoms { | 427 | for (wid, rect) in &geoms { |
@@ -494,8 +504,6 @@ impl WmState { | |||
| 494 | .monitor_showing_workspace(ws_idx) | 504 | .monitor_showing_workspace(ws_idx) |
| 495 | .map(|mi| self.monitor_rect(mi)) | 505 | .map(|mi| self.monitor_rect(mi)) |
| 496 | .unwrap_or_else(|| self.focused_rect()); | 506 | .unwrap_or_else(|| self.focused_rect()); |
| 497 | - let (gap_inner, gap_outer) = self.workspace_gaps(ws_idx); | ||
| 498 | - | ||
| 499 | // Phase 1: Swap oversized windows into larger tiles. | 507 | // Phase 1: Swap oversized windows into larger tiles. |
| 500 | // Batch all swaps using BSP geometry (no AX calls), then apply layout | 508 | // Batch all swaps using BSP geometry (no AX calls), then apply layout |
| 501 | // once and settle. This is both faster (one layout instead of N) and | 509 | // once and settle. This is both faster (one layout instead of N) and |
@@ -510,11 +518,7 @@ impl WmState { | |||
| 510 | // Geometries are computed from the BSP tree (cheap, no AX). | 518 | // Geometries are computed from the BSP tree (cheap, no AX). |
| 511 | // After batched swaps, tree geometry reflects the new layout | 519 | // After batched swaps, tree geometry reflects the new layout |
| 512 | // even before apply_layout sends AX commands. | 520 | // even before apply_layout sends AX commands. |
| 513 | - let geometries = self | 521 | + let geometries = self.workspace_focus_geometries(ws_idx, screen_rect); |
| 514 | - .workspaces | ||
| 515 | - .get(ws_idx) | ||
| 516 | - .tree | ||
| 517 | - .calculate_geometries_with_gaps(screen_rect, gap_inner, gap_outer, true); | ||
| 518 | if geometries.is_empty() { | 522 | if geometries.is_empty() { |
| 519 | break; | 523 | break; |
| 520 | } | 524 | } |
@@ -578,15 +582,9 @@ impl WmState { | |||
| 578 | // settling. The settled set prevents ping-pong. | 582 | // settling. The settled set prevents ping-pong. |
| 579 | } | 583 | } |
| 580 | 584 | ||
| 581 | - // Phase 2: Float remaining oversized windows locally. | 585 | + // Phase 2: Replace the smallest conflicting subtree with a stack. |
| 582 | - // Oversized windows should not silently migrate across workspaces; | ||
| 583 | - // keep workspace membership stable and remediate in place. | ||
| 584 | loop { | 586 | loop { |
| 585 | - let geometries = self | 587 | + let geometries = self.workspace_focus_geometries(ws_idx, screen_rect); |
| 586 | - .workspaces | ||
| 587 | - .get(ws_idx) | ||
| 588 | - .tree | ||
| 589 | - .calculate_geometries_with_gaps(screen_rect, gap_inner, gap_outer, true); | ||
| 590 | if geometries.is_empty() { | 588 | if geometries.is_empty() { |
| 591 | break; | 589 | break; |
| 592 | } | 590 | } |
@@ -614,24 +612,20 @@ impl WmState { | |||
| 614 | None => break, | 612 | None => break, |
| 615 | }; | 613 | }; |
| 616 | 614 | ||
| 617 | - tracing::info!( | 615 | + if self.workspaces.get_mut(ws_idx).tree.make_stack_for_window(oversized_wid) { |
| 618 | - id = oversized_wid, | 616 | + tracing::info!( |
| 619 | - min_w, | 617 | + id = oversized_wid, |
| 620 | - min_h, | 618 | + min_w, |
| 621 | - ws = ws_idx + 1, | 619 | + min_h, |
| 622 | - "floating oversized window locally" | 620 | + ws = ws_idx + 1, |
| 623 | - ); | 621 | + "stacking oversized subtree locally" |
| 624 | - self.workspaces | 622 | + ); |
| 625 | - .get_mut(ws_idx) | 623 | + self.workspaces.get_mut(ws_idx).tree.set_stack_active(oversized_wid); |
| 626 | - .toggle_float(oversized_wid, screen_rect); | 624 | + self.apply_layout(); |
| 627 | - if let Some(ax_ref) = self.ax_refs.get(&oversized_wid) { | 625 | + std::thread::sleep(std::time::Duration::from_millis(50)); |
| 628 | - let fx = screen_rect.x + (screen_rect.width - min_w) / 2.0; | 626 | + } else { |
| 629 | - let fy = screen_rect.y + (screen_rect.height - min_h) / 2.0; | 627 | + break; |
| 630 | - let _ = ax_set_position(ax_ref, fx, fy); | ||
| 631 | - let _ = ax_set_size(ax_ref, min_w, min_h); | ||
| 632 | } | 628 | } |
| 633 | - self.apply_layout(); | ||
| 634 | - self.restack_floating_windows(ws_idx); | ||
| 635 | } | 629 | } |
| 636 | 630 | ||
| 637 | processed.push(ws_idx); | 631 | processed.push(ws_idx); |
@@ -668,13 +662,21 @@ impl WmState { | |||
| 668 | let ws = self.active_workspace(); | 662 | let ws = self.active_workspace(); |
| 669 | let focused = ws.focused; | 663 | let focused = ws.focused; |
| 670 | let sr = self.focused_rect(); | 664 | let sr = self.focused_rect(); |
| 671 | - let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); | 665 | + |
| 666 | + if let Some(from) = focused | ||
| 667 | + && matches!(direction, Direction::Left | Direction::Right) | ||
| 668 | + && let Some(next) = self | ||
| 669 | + .active_workspace_mut() | ||
| 670 | + .tree | ||
| 671 | + .cycle_stack(from, matches!(direction, Direction::Right)) | ||
| 672 | + { | ||
| 673 | + self.focus_window(next); | ||
| 674 | + return; | ||
| 675 | + } | ||
| 672 | 676 | ||
| 673 | // Try intra-workspace navigation first (only if we have a focused window) | 677 | // Try intra-workspace navigation first (only if we have a focused window) |
| 674 | if let Some(from) = focused { | 678 | if let Some(from) = focused { |
| 675 | - let geoms = ws | 679 | + let geoms = self.workspace_focus_geometries(self.active_ws_idx(), sr); |
| 676 | - .tree | ||
| 677 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | ||
| 678 | 680 | ||
| 679 | // Check if focused window is at the monitor edge in the requested | 681 | // Check if focused window is at the monitor edge in the requested |
| 680 | // direction. If so, cross monitors instead of spiraling into the BSP tree. | 682 | // direction. If so, cross monitors instead of spiraling into the BSP tree. |
@@ -720,10 +722,7 @@ impl WmState { | |||
| 720 | if let Some(new_mi) = new_mi { | 722 | if let Some(new_mi) = new_mi { |
| 721 | self.focused_monitor = new_mi; | 723 | self.focused_monitor = new_mi; |
| 722 | let target_sr = self.focused_rect(); | 724 | let target_sr = self.focused_rect(); |
| 723 | - let target_geoms = self | 725 | + let target_geoms = self.workspace_focus_geometries(self.active_ws_idx(), target_sr); |
| 724 | - .active_workspace() | ||
| 725 | - .tree | ||
| 726 | - .calculate_geometries_with_gaps(target_sr, gap_inner, gap_outer, true); | ||
| 727 | 726 | ||
| 728 | if let Some(wid) = | 727 | if let Some(wid) = |
| 729 | Node::nearest_to_edge(&target_geoms, direction).or(self.active_workspace().focused) | 728 | Node::nearest_to_edge(&target_geoms, direction).or(self.active_workspace().focused) |
@@ -762,10 +761,19 @@ impl WmState { | |||
| 762 | None => return, | 761 | None => return, |
| 763 | }; | 762 | }; |
| 764 | let sr = self.focused_rect(); | 763 | let sr = self.focused_rect(); |
| 765 | - let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); | 764 | + |
| 766 | - let geoms = ws | 765 | + if matches!(direction, Direction::Left | Direction::Right) |
| 767 | - .tree | 766 | + && let Some(next) = self |
| 768 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | 767 | + .active_workspace_mut() |
| 768 | + .tree | ||
| 769 | + .reorder_stack(focused, matches!(direction, Direction::Right)) | ||
| 770 | + { | ||
| 771 | + self.apply_layout(); | ||
| 772 | + self.focus_window(next); | ||
| 773 | + return; | ||
| 774 | + } | ||
| 775 | + | ||
| 776 | + let geoms = self.workspace_focus_geometries(self.active_ws_idx(), sr); | ||
| 769 | 777 | ||
| 770 | // Check if the focused window touches the monitor edge in the | 778 | // Check if the focused window touches the monitor edge in the |
| 771 | // requested direction. If so, skip intra-workspace swap and move | 779 | // requested direction. If so, skip intra-workspace swap and move |
@@ -874,17 +882,22 @@ impl WmState { | |||
| 874 | // Record focus on the workspace that CONTAINS this window, | 882 | // Record focus on the workspace that CONTAINS this window, |
| 875 | // not the active workspace — during cross-monitor FFM the active | 883 | // not the active workspace — during cross-monitor FFM the active |
| 876 | // workspace might be different from the window's workspace. | 884 | // workspace might be different from the window's workspace. |
| 877 | - let old_focused = if let Some(ws_idx) = self.workspaces.find_window(id) { | 885 | + let (old_focused, stack_focus_changed) = if let Some(ws_idx) = self.workspaces.find_window(id) { |
| 878 | let old = self.workspaces.get(ws_idx).focused; | 886 | let old = self.workspaces.get(ws_idx).focused; |
| 879 | self.workspaces.get_mut(ws_idx).raise_floating(id); | 887 | self.workspaces.get_mut(ws_idx).raise_floating(id); |
| 888 | + let stack_changed = self.workspaces.get_mut(ws_idx).tree.set_stack_active(id); | ||
| 880 | self.workspaces.get_mut(ws_idx).record_focus(id); | 889 | self.workspaces.get_mut(ws_idx).record_focus(id); |
| 881 | - old | 890 | + (old, stack_changed) |
| 882 | } else { | 891 | } else { |
| 883 | let old = self.active_workspace().focused; | 892 | let old = self.active_workspace().focused; |
| 884 | self.active_workspace_mut().raise_floating(id); | 893 | self.active_workspace_mut().raise_floating(id); |
| 894 | + let stack_changed = self.active_workspace_mut().tree.set_stack_active(id); | ||
| 885 | self.active_workspace_mut().record_focus(id); | 895 | self.active_workspace_mut().record_focus(id); |
| 886 | - old | 896 | + (old, stack_changed) |
| 887 | }; | 897 | }; |
| 898 | + if stack_focus_changed { | ||
| 899 | + self.apply_layout(); | ||
| 900 | + } | ||
| 888 | self.enforce_floating_levels(id); | 901 | self.enforce_floating_levels(id); |
| 889 | 902 | ||
| 890 | // Update border colors on focus change | 903 | // Update border colors on focus change |
@@ -1169,20 +1182,17 @@ impl WmState { | |||
| 1169 | floating_hit | 1182 | floating_hit |
| 1170 | } else { | 1183 | } else { |
| 1171 | // Check tiled windows within the overlay rect | 1184 | // Check tiled windows within the overlay rect |
| 1172 | - let geoms = ws.tree.calculate_geometries_with_gaps( | 1185 | + let geoms = ws |
| 1173 | - overlay_rect, | 1186 | + .tree |
| 1174 | - gap_inner, | 1187 | + .calculate_geometries_with_gaps(overlay_rect, gap_inner, gap_outer, true); |
| 1175 | - gap_outer, | ||
| 1176 | - true, | ||
| 1177 | - ); | ||
| 1178 | geoms | 1188 | geoms |
| 1179 | .iter() | 1189 | .iter() |
| 1190 | + .rev() | ||
| 1180 | .find(|(_, rect)| rect.contains_point(x, y)) | 1191 | .find(|(_, rect)| rect.contains_point(x, y)) |
| 1181 | .map(|(id, _)| *id) | 1192 | .map(|(id, _)| *id) |
| 1182 | } | 1193 | } |
| 1183 | } else { | 1194 | } else { |
| 1184 | let ws = self.active_workspace(); | 1195 | let ws = self.active_workspace(); |
| 1185 | - let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); | ||
| 1186 | 1196 | ||
| 1187 | // Check floating windows first -- they're visually on top | 1197 | // Check floating windows first -- they're visually on top |
| 1188 | let floating_under = ws | 1198 | let floating_under = ws |
@@ -1196,14 +1206,11 @@ impl WmState { | |||
| 1196 | floating_under | 1206 | floating_under |
| 1197 | } else { | 1207 | } else { |
| 1198 | // Check tiled windows using gap-aware geometry matching actual layout | 1208 | // Check tiled windows using gap-aware geometry matching actual layout |
| 1199 | - let geoms = ws.tree.calculate_geometries_with_gaps( | 1209 | + let geoms = |
| 1200 | - self.focused_rect(), | 1210 | + self.workspace_render_geometries(self.active_ws_idx(), self.focused_rect()); |
| 1201 | - gap_inner, | ||
| 1202 | - gap_outer, | ||
| 1203 | - true, | ||
| 1204 | - ); | ||
| 1205 | geoms | 1211 | geoms |
| 1206 | .iter() | 1212 | .iter() |
| 1213 | + .rev() | ||
| 1207 | .find(|(_, rect)| rect.contains_point(x, y)) | 1214 | .find(|(_, rect)| rect.contains_point(x, y)) |
| 1208 | .map(|(id, _)| *id) | 1215 | .map(|(id, _)| *id) |
| 1209 | } | 1216 | } |
@@ -1252,9 +1259,25 @@ impl WmState { | |||
| 1252 | } | 1259 | } |
| 1253 | } | 1260 | } |
| 1254 | 1261 | ||
| 1262 | + pub fn unstack_focused(&mut self) { | ||
| 1263 | + let focused = match self.effective_focused() { | ||
| 1264 | + Some(f) => f, | ||
| 1265 | + None => return, | ||
| 1266 | + }; | ||
| 1267 | + | ||
| 1268 | + let Some(ws_idx) = self.workspaces.find_window(focused) else { | ||
| 1269 | + return; | ||
| 1270 | + }; | ||
| 1271 | + | ||
| 1272 | + if self.workspaces.get_mut(ws_idx).tree.unstack(focused) { | ||
| 1273 | + self.apply_layout(); | ||
| 1274 | + self.focus_window(focused); | ||
| 1275 | + tracing::info!(id = focused, "restored stacked subtree"); | ||
| 1276 | + } | ||
| 1277 | + } | ||
| 1278 | + | ||
| 1255 | pub fn click_to_focus(&mut self, x: f64, y: f64) { | 1279 | pub fn click_to_focus(&mut self, x: f64, y: f64) { |
| 1256 | let ws = self.active_workspace(); | 1280 | let ws = self.active_workspace(); |
| 1257 | - let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); | ||
| 1258 | 1281 | ||
| 1259 | // Check floating first | 1282 | // Check floating first |
| 1260 | let floating_hit = ws | 1283 | let floating_hit = ws |
@@ -1267,14 +1290,10 @@ impl WmState { | |||
| 1267 | let id = if let Some(fid) = floating_hit { | 1290 | let id = if let Some(fid) = floating_hit { |
| 1268 | Some(fid) | 1291 | Some(fid) |
| 1269 | } else { | 1292 | } else { |
| 1270 | - let geoms = ws.tree.calculate_geometries_with_gaps( | 1293 | + let geoms = self.workspace_render_geometries(self.active_ws_idx(), self.focused_rect()); |
| 1271 | - self.focused_rect(), | ||
| 1272 | - gap_inner, | ||
| 1273 | - gap_outer, | ||
| 1274 | - true, | ||
| 1275 | - ); | ||
| 1276 | geoms | 1294 | geoms |
| 1277 | .iter() | 1295 | .iter() |
| 1296 | + .rev() | ||
| 1278 | .find(|(_, rect)| rect.contains_point(x, y)) | 1297 | .find(|(_, rect)| rect.contains_point(x, y)) |
| 1279 | .map(|(id, _)| *id) | 1298 | .map(|(id, _)| *id) |
| 1280 | }; | 1299 | }; |
@@ -1309,12 +1328,7 @@ impl WmState { | |||
| 1309 | // Warp to the focused WINDOW center (not monitor center) | 1328 | // Warp to the focused WINDOW center (not monitor center) |
| 1310 | if self.mouse_follows_focus { | 1329 | if self.mouse_follows_focus { |
| 1311 | let sr = self.focused_rect(); | 1330 | let sr = self.focused_rect(); |
| 1312 | - let (gap_inner, gap_outer) = self.workspace_gaps(target_idx); | 1331 | + let geoms = self.workspace_focus_geometries(target_idx, sr); |
| 1313 | - let geoms = self | ||
| 1314 | - .workspaces | ||
| 1315 | - .get(target_idx) | ||
| 1316 | - .tree | ||
| 1317 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | ||
| 1318 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { | 1332 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1319 | warp_mouse_to_center(rect); | 1333 | warp_mouse_to_center(rect); |
| 1320 | } else { | 1334 | } else { |
@@ -1377,12 +1391,7 @@ impl WmState { | |||
| 1377 | self.focus_window(wid); | 1391 | self.focus_window(wid); |
| 1378 | if self.mouse_follows_focus { | 1392 | if self.mouse_follows_focus { |
| 1379 | let sr = self.focused_rect(); | 1393 | let sr = self.focused_rect(); |
| 1380 | - let (gap_inner, gap_outer) = self.workspace_gaps(target_idx); | 1394 | + let geoms = self.workspace_focus_geometries(target_idx, sr); |
| 1381 | - let geoms = self | ||
| 1382 | - .workspaces | ||
| 1383 | - .get(target_idx) | ||
| 1384 | - .tree | ||
| 1385 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | ||
| 1386 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { | 1395 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1387 | warp_mouse_to_center(rect); | 1396 | warp_mouse_to_center(rect); |
| 1388 | } else { | 1397 | } else { |
@@ -1802,11 +1811,7 @@ impl WmState { | |||
| 1802 | self.focus_window(wid); | 1811 | self.focus_window(wid); |
| 1803 | if self.mouse_follows_focus { | 1812 | if self.mouse_follows_focus { |
| 1804 | let sr = self.focused_rect(); | 1813 | let sr = self.focused_rect(); |
| 1805 | - let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); | 1814 | + let geoms = self.workspace_focus_geometries(self.active_ws_idx(), sr); |
| 1806 | - let geoms = self | ||
| 1807 | - .active_workspace() | ||
| 1808 | - .tree | ||
| 1809 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | ||
| 1810 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { | 1815 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1811 | warp_mouse_to_center(rect); | 1816 | warp_mouse_to_center(rect); |
| 1812 | } else { | 1817 | } else { |
@@ -1836,11 +1841,7 @@ impl WmState { | |||
| 1836 | self.focus_window(wid); | 1841 | self.focus_window(wid); |
| 1837 | if self.mouse_follows_focus { | 1842 | if self.mouse_follows_focus { |
| 1838 | let sr = self.focused_rect(); | 1843 | let sr = self.focused_rect(); |
| 1839 | - let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); | 1844 | + let geoms = self.workspace_focus_geometries(self.active_ws_idx(), sr); |
| 1840 | - let geoms = self | ||
| 1841 | - .active_workspace() | ||
| 1842 | - .tree | ||
| 1843 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | ||
| 1844 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { | 1845 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1845 | warp_mouse_to_center(rect); | 1846 | warp_mouse_to_center(rect); |
| 1846 | } else { | 1847 | } else { |
@@ -2244,7 +2245,7 @@ impl WmState { | |||
| 2244 | } | 2245 | } |
| 2245 | 2246 | ||
| 2246 | /// After moving a window to a specific workspace, try swaps to fix overflow. | 2247 | /// After moving a window to a specific workspace, try swaps to fix overflow. |
| 2247 | - /// If no swap works, float the window centered instead of evicting. | 2248 | + /// If no swap works, replace the smallest conflicting subtree with a stack. |
| 2248 | /// Only runs when the target workspace is visible — hidden workspaces have | 2249 | /// Only runs when the target workspace is visible — hidden workspaces have |
| 2249 | /// stale window sizes and will be checked when switched to. | 2250 | /// stale window sizes and will be checked when switched to. |
| 2250 | fn fix_oversized_on_target(&mut self, ws_idx: usize, moved_wid: super::window::WindowId) { | 2251 | fn fix_oversized_on_target(&mut self, ws_idx: usize, moved_wid: super::window::WindowId) { |
@@ -2257,16 +2258,7 @@ impl WmState { | |||
| 2257 | std::thread::sleep(std::time::Duration::from_millis(100)); | 2258 | std::thread::sleep(std::time::Duration::from_millis(100)); |
| 2258 | 2259 | ||
| 2259 | // Check if the moved window actually overflows | 2260 | // Check if the moved window actually overflows |
| 2260 | - let geometries = self | 2261 | + let geometries = self.workspace_focus_geometries(ws_idx, screen_rect); |
| 2261 | - .workspaces | ||
| 2262 | - .get(ws_idx) | ||
| 2263 | - .tree | ||
| 2264 | - .calculate_geometries_with_gaps( | ||
| 2265 | - screen_rect, | ||
| 2266 | - self.workspace_gaps(ws_idx).0, | ||
| 2267 | - self.workspace_gaps(ws_idx).1, | ||
| 2268 | - true, | ||
| 2269 | - ); | ||
| 2270 | 2262 | ||
| 2271 | let (_min_w, _min_h) = match geometries.iter().find(|(wid, _)| *wid == moved_wid) { | 2263 | let (_min_w, _min_h) = match geometries.iter().find(|(wid, _)| *wid == moved_wid) { |
| 2272 | Some((_, rect)) => { | 2264 | Some((_, rect)) => { |
@@ -2286,16 +2278,7 @@ impl WmState { | |||
| 2286 | // Try swaps with settled-set logic (same as fix_oversized_windows phase 1) | 2278 | // Try swaps with settled-set logic (same as fix_oversized_windows phase 1) |
| 2287 | let mut settled: Vec<super::window::WindowId> = Vec::new(); | 2279 | let mut settled: Vec<super::window::WindowId> = Vec::new(); |
| 2288 | loop { | 2280 | loop { |
| 2289 | - let geoms = self | 2281 | + let geoms = self.workspace_focus_geometries(ws_idx, screen_rect); |
| 2290 | - .workspaces | ||
| 2291 | - .get(ws_idx) | ||
| 2292 | - .tree | ||
| 2293 | - .calculate_geometries_with_gaps( | ||
| 2294 | - screen_rect, | ||
| 2295 | - self.workspace_gaps(ws_idx).0, | ||
| 2296 | - self.workspace_gaps(ws_idx).1, | ||
| 2297 | - true, | ||
| 2298 | - ); | ||
| 2299 | if geoms.is_empty() { | 2282 | if geoms.is_empty() { |
| 2300 | break; | 2283 | break; |
| 2301 | } | 2284 | } |
@@ -2341,27 +2324,20 @@ impl WmState { | |||
| 2341 | self.apply_layout(); | 2324 | self.apply_layout(); |
| 2342 | settled.push(ow); | 2325 | settled.push(ow); |
| 2343 | } else { | 2326 | } else { |
| 2344 | - // No swap possible — float this window centered | 2327 | + if self.workspaces.get_mut(ws_idx).tree.make_stack_for_window(ow) { |
| 2345 | - tracing::info!( | 2328 | + tracing::info!( |
| 2346 | - id = ow, | 2329 | + id = ow, |
| 2347 | - min_w = ow_min_w, | 2330 | + min_w = ow_min_w, |
| 2348 | - min_h = ow_min_h, | 2331 | + min_h = ow_min_h, |
| 2349 | - ws = ws_idx + 1, | 2332 | + ws = ws_idx + 1, |
| 2350 | - "floating oversized window on target workspace" | 2333 | + "stacking oversized subtree on target workspace" |
| 2351 | - ); | 2334 | + ); |
| 2352 | - self.workspaces | 2335 | + self.workspaces.get_mut(ws_idx).tree.set_stack_active(ow); |
| 2353 | - .get_mut(ws_idx) | 2336 | + self.apply_layout(); |
| 2354 | - .toggle_float(ow, screen_rect); | 2337 | + std::thread::sleep(std::time::Duration::from_millis(50)); |
| 2355 | - if let Some(ax_ref) = self.ax_refs.get(&ow) { | 2338 | + } else { |
| 2356 | - let fx = screen_rect.x + (screen_rect.width - ow_min_w) / 2.0; | 2339 | + break; |
| 2357 | - let fy = screen_rect.y + (screen_rect.height - ow_min_h) / 2.0; | ||
| 2358 | - let _ = ax_set_position(ax_ref, fx, fy); | ||
| 2359 | - let _ = ax_set_size(ax_ref, ow_min_w, ow_min_h); | ||
| 2360 | } | 2340 | } |
| 2361 | - use crate::platform::skylight::{K_CG_FLOATING_WINDOW_LEVEL, set_window_level}; | ||
| 2362 | - set_window_level(ow, K_CG_FLOATING_WINDOW_LEVEL); | ||
| 2363 | - self.apply_layout(); | ||
| 2364 | - // Continue checking — other windows may still overflow | ||
| 2365 | } | 2341 | } |
| 2366 | } | 2342 | } |
| 2367 | } | 2343 | } |
@@ -2748,6 +2724,7 @@ impl WmState { | |||
| 2748 | // Record on the workspace that contains this window, | 2724 | // Record on the workspace that contains this window, |
| 2749 | // not the active workspace (same fix as focus_window_impl) | 2725 | // not the active workspace (same fix as focus_window_impl) |
| 2750 | if let Some(ws_idx) = self.workspaces.find_window(id) { | 2726 | if let Some(ws_idx) = self.workspaces.find_window(id) { |
| 2727 | + self.workspaces.get_mut(ws_idx).tree.set_stack_active(id); | ||
| 2751 | self.workspaces.get_mut(ws_idx).record_focus(id); | 2728 | self.workspaces.get_mut(ws_idx).record_focus(id); |
| 2752 | } | 2729 | } |
| 2753 | } | 2730 | } |
@@ -2935,6 +2912,7 @@ fn hide_target_for_frame( | |||
| 2935 | mod tests { | 2912 | mod tests { |
| 2936 | use super::*; | 2913 | use super::*; |
| 2937 | use crate::core::monitor::Monitor; | 2914 | use crate::core::monitor::Monitor; |
| 2915 | + use crate::core::tree::Direction; | ||
| 2938 | 2916 | ||
| 2939 | #[test] | 2917 | #[test] |
| 2940 | fn hide_anchor_frame_defaults_without_monitors() { | 2918 | fn hide_anchor_frame_defaults_without_monitors() { |
@@ -3151,4 +3129,63 @@ mod tests { | |||
| 3151 | state.sync_workspace_visibility(); | 3129 | state.sync_workspace_visibility(); |
| 3152 | assert!(!state.is_window_hidden(123)); | 3130 | assert!(!state.is_window_hidden(123)); |
| 3153 | } | 3131 | } |
| 3132 | + | ||
| 3133 | + #[test] | ||
| 3134 | + fn focus_direction_cycles_stacked_windows_left_right() { | ||
| 3135 | + let mut state = WmState::new(); | ||
| 3136 | + state.monitors = vec![Monitor { | ||
| 3137 | + id: 42, | ||
| 3138 | + frame: Rect::new(0.0, 0.0, 1920.0, 1080.0), | ||
| 3139 | + usable_frame: Rect::new(0.0, 33.0, 1920.0, 1047.0), | ||
| 3140 | + is_primary: true, | ||
| 3141 | + active_workspace: 0, | ||
| 3142 | + }]; | ||
| 3143 | + state.sync_workspace_visibility(); | ||
| 3144 | + let screen = state.monitors[0].usable_frame; | ||
| 3145 | + | ||
| 3146 | + { | ||
| 3147 | + let ws = state.workspaces.get_mut(0); | ||
| 3148 | + ws.tree.insert_with_rect(1, None, screen); | ||
| 3149 | + ws.tree.insert_with_rect(2, Some(1), screen); | ||
| 3150 | + ws.tree.insert_with_rect(3, Some(2), screen); | ||
| 3151 | + assert!(ws.tree.make_stack_for_window(3)); | ||
| 3152 | + ws.tree.set_stack_active(3); | ||
| 3153 | + ws.record_focus(3); | ||
| 3154 | + } | ||
| 3155 | + | ||
| 3156 | + state.focus_direction(Direction::Left); | ||
| 3157 | + assert_eq!(state.active_workspace().focused, Some(2)); | ||
| 3158 | + | ||
| 3159 | + state.focus_direction(Direction::Right); | ||
| 3160 | + assert_eq!(state.active_workspace().focused, Some(3)); | ||
| 3161 | + } | ||
| 3162 | + | ||
| 3163 | + #[test] | ||
| 3164 | + fn unstack_focused_restores_original_tree() { | ||
| 3165 | + let mut state = WmState::new(); | ||
| 3166 | + state.monitors = vec![Monitor { | ||
| 3167 | + id: 42, | ||
| 3168 | + frame: Rect::new(0.0, 0.0, 1920.0, 1080.0), | ||
| 3169 | + usable_frame: Rect::new(0.0, 33.0, 1920.0, 1047.0), | ||
| 3170 | + is_primary: true, | ||
| 3171 | + active_workspace: 0, | ||
| 3172 | + }]; | ||
| 3173 | + state.sync_workspace_visibility(); | ||
| 3174 | + let screen = state.monitors[0].usable_frame; | ||
| 3175 | + | ||
| 3176 | + let original = { | ||
| 3177 | + let ws = state.workspaces.get_mut(0); | ||
| 3178 | + ws.tree.insert_with_rect(1, None, screen); | ||
| 3179 | + ws.tree.insert_with_rect(2, Some(1), screen); | ||
| 3180 | + ws.tree.insert_with_rect(3, Some(2), screen); | ||
| 3181 | + let original = ws.tree.clone(); | ||
| 3182 | + assert!(ws.tree.make_stack_for_window(3)); | ||
| 3183 | + ws.tree.set_stack_active(3); | ||
| 3184 | + ws.record_focus(3); | ||
| 3185 | + original | ||
| 3186 | + }; | ||
| 3187 | + | ||
| 3188 | + state.unstack_focused(); | ||
| 3189 | + assert_eq!(state.active_workspace().tree, original); | ||
| 3190 | + } | ||
| 3154 | } | 3191 | } |
tarmac/src/core/tree.rsmodified@@ -61,7 +61,9 @@ impl Rect { | |||
| 61 | } | 61 | } |
| 62 | } | 62 | } |
| 63 | 63 | ||
| 64 | -#[derive(Debug, Clone)] | 64 | +const STACK_REVEAL_OFFSET: f64 = 24.0; |
| 65 | + | ||
| 66 | +#[derive(Debug, Clone, PartialEq)] | ||
| 65 | pub enum Node { | 67 | pub enum Node { |
| 66 | Internal { | 68 | Internal { |
| 67 | split: SplitDirection, | 69 | split: SplitDirection, |
@@ -69,6 +71,11 @@ pub enum Node { | |||
| 69 | left: Box<Node>, | 71 | left: Box<Node>, |
| 70 | right: Box<Node>, | 72 | right: Box<Node>, |
| 71 | }, | 73 | }, |
| 74 | + Stack { | ||
| 75 | + windows: Vec<WindowId>, | ||
| 76 | + active: usize, | ||
| 77 | + previous: Box<Node>, | ||
| 78 | + }, | ||
| 72 | Leaf { | 79 | Leaf { |
| 73 | window: Option<WindowId>, | 80 | window: Option<WindowId>, |
| 74 | }, | 81 | }, |
@@ -89,10 +96,20 @@ impl Node { | |||
| 89 | matches!(self, Node::Leaf { window: None }) | 96 | matches!(self, Node::Leaf { window: None }) |
| 90 | } | 97 | } |
| 91 | 98 | ||
| 99 | + pub fn slot_count(&self) -> usize { | ||
| 100 | + match self { | ||
| 101 | + Node::Leaf { window: Some(_) } => 1, | ||
| 102 | + Node::Leaf { window: None } => 0, | ||
| 103 | + Node::Stack { windows, .. } => usize::from(!windows.is_empty()), | ||
| 104 | + Node::Internal { left, right, .. } => left.slot_count() + right.slot_count(), | ||
| 105 | + } | ||
| 106 | + } | ||
| 107 | + | ||
| 92 | pub fn window_count(&self) -> usize { | 108 | pub fn window_count(&self) -> usize { |
| 93 | match self { | 109 | match self { |
| 94 | Node::Leaf { window: Some(_) } => 1, | 110 | Node::Leaf { window: Some(_) } => 1, |
| 95 | Node::Leaf { window: None } => 0, | 111 | Node::Leaf { window: None } => 0, |
| 112 | + Node::Stack { windows, .. } => windows.len(), | ||
| 96 | Node::Internal { left, right, .. } => left.window_count() + right.window_count(), | 113 | Node::Internal { left, right, .. } => left.window_count() + right.window_count(), |
| 97 | } | 114 | } |
| 98 | } | 115 | } |
@@ -101,6 +118,7 @@ impl Node { | |||
| 101 | match self { | 118 | match self { |
| 102 | Node::Leaf { window: Some(w) } => vec![*w], | 119 | Node::Leaf { window: Some(w) } => vec![*w], |
| 103 | Node::Leaf { window: None } => vec![], | 120 | Node::Leaf { window: None } => vec![], |
| 121 | + Node::Stack { windows, .. } => windows.clone(), | ||
| 104 | Node::Internal { left, right, .. } => { | 122 | Node::Internal { left, right, .. } => { |
| 105 | let mut ws = left.windows(); | 123 | let mut ws = left.windows(); |
| 106 | ws.extend(right.windows()); | 124 | ws.extend(right.windows()); |
@@ -113,6 +131,7 @@ impl Node { | |||
| 113 | match self { | 131 | match self { |
| 114 | Node::Leaf { window: Some(w) } => *w == window, | 132 | Node::Leaf { window: Some(w) } => *w == window, |
| 115 | Node::Leaf { window: None } => false, | 133 | Node::Leaf { window: None } => false, |
| 134 | + Node::Stack { windows, .. } => windows.contains(&window), | ||
| 116 | Node::Internal { left, right, .. } => left.contains(window) || right.contains(window), | 135 | Node::Internal { left, right, .. } => left.contains(window) || right.contains(window), |
| 117 | } | 136 | } |
| 118 | } | 137 | } |
@@ -120,6 +139,9 @@ impl Node { | |||
| 120 | pub fn first_window(&self) -> Option<WindowId> { | 139 | pub fn first_window(&self) -> Option<WindowId> { |
| 121 | match self { | 140 | match self { |
| 122 | Node::Leaf { window } => *window, | 141 | Node::Leaf { window } => *window, |
| 142 | + Node::Stack { | ||
| 143 | + windows, active, .. | ||
| 144 | + } => windows.get(*active).copied().or_else(|| windows.first().copied()), | ||
| 123 | Node::Internal { left, right, .. } => { | 145 | Node::Internal { left, right, .. } => { |
| 124 | left.first_window().or_else(|| right.first_window()) | 146 | left.first_window().or_else(|| right.first_window()) |
| 125 | } | 147 | } |
@@ -185,6 +207,19 @@ impl Node { | |||
| 185 | right.insert_with_rect(new_window, None, right_rect); | 207 | right.insert_with_rect(new_window, None, right_rect); |
| 186 | } | 208 | } |
| 187 | } | 209 | } |
| 210 | + Node::Stack { | ||
| 211 | + windows, | ||
| 212 | + active, | ||
| 213 | + previous, | ||
| 214 | + } => { | ||
| 215 | + let insert_at = target | ||
| 216 | + .and_then(|target_window| windows.iter().position(|wid| *wid == target_window)) | ||
| 217 | + .map(|idx| idx + 1) | ||
| 218 | + .unwrap_or_else(|| (*active + 1).min(windows.len())); | ||
| 219 | + windows.insert(insert_at, new_window); | ||
| 220 | + *active = insert_at; | ||
| 221 | + previous.insert_with_rect(new_window, target, rect); | ||
| 222 | + } | ||
| 188 | } | 223 | } |
| 189 | } | 224 | } |
| 190 | 225 | ||
@@ -196,6 +231,30 @@ impl Node { | |||
| 196 | true | 231 | true |
| 197 | } | 232 | } |
| 198 | Node::Leaf { .. } => false, | 233 | Node::Leaf { .. } => false, |
| 234 | + Node::Stack { | ||
| 235 | + windows, | ||
| 236 | + active, | ||
| 237 | + previous, | ||
| 238 | + } => { | ||
| 239 | + let Some(idx) = windows.iter().position(|wid| *wid == window) else { | ||
| 240 | + return false; | ||
| 241 | + }; | ||
| 242 | + windows.remove(idx); | ||
| 243 | + previous.remove(window); | ||
| 244 | + | ||
| 245 | + if windows.is_empty() { | ||
| 246 | + *self = Node::empty(); | ||
| 247 | + } else if windows.len() == 1 { | ||
| 248 | + *self = Node::Leaf { | ||
| 249 | + window: windows.first().copied(), | ||
| 250 | + }; | ||
| 251 | + } else if *active >= windows.len() { | ||
| 252 | + *active = windows.len() - 1; | ||
| 253 | + } else if idx < *active { | ||
| 254 | + *active -= 1; | ||
| 255 | + } | ||
| 256 | + true | ||
| 257 | + } | ||
| 199 | Node::Internal { left, right, .. } => { | 258 | Node::Internal { left, right, .. } => { |
| 200 | if left.remove(window) { | 259 | if left.remove(window) { |
| 201 | if left.is_empty() { | 260 | if left.is_empty() { |
@@ -219,6 +278,10 @@ impl Node { | |||
| 219 | self.calculate_geometries_with_gaps(rect, 0.0, 0.0, true) | 278 | self.calculate_geometries_with_gaps(rect, 0.0, 0.0, true) |
| 220 | } | 279 | } |
| 221 | 280 | ||
| 281 | + pub fn calculate_focus_geometries(&self, rect: Rect) -> Vec<(WindowId, Rect)> { | ||
| 282 | + self.calculate_focus_geometries_with_gaps(rect, 0.0, 0.0, true) | ||
| 283 | + } | ||
| 284 | + | ||
| 222 | /// Calculate geometries with inner and outer gaps. | 285 | /// Calculate geometries with inner and outer gaps. |
| 223 | /// `gap_outer` is applied to the root rect edges. | 286 | /// `gap_outer` is applied to the root rect edges. |
| 224 | /// `gap_inner` is the space between adjacent windows (half applied to each side). | 287 | /// `gap_inner` is the space between adjacent windows (half applied to each side). |
@@ -244,6 +307,35 @@ impl Node { | |||
| 244 | match self { | 307 | match self { |
| 245 | Node::Leaf { window: Some(w) } => vec![(*w, padded)], | 308 | Node::Leaf { window: Some(w) } => vec![(*w, padded)], |
| 246 | Node::Leaf { window: None } => vec![], | 309 | Node::Leaf { window: None } => vec![], |
| 310 | + Node::Stack { | ||
| 311 | + windows, active, .. | ||
| 312 | + } => { | ||
| 313 | + let active_window = windows.get(*active).copied(); | ||
| 314 | + let mut geoms = Vec::with_capacity(windows.len()); | ||
| 315 | + let mut depth = 0usize; | ||
| 316 | + for (idx, wid) in windows.iter().enumerate() { | ||
| 317 | + if Some(*wid) == active_window { | ||
| 318 | + continue; | ||
| 319 | + } | ||
| 320 | + depth += 1; | ||
| 321 | + geoms.push(( | ||
| 322 | + *wid, | ||
| 323 | + Rect::new( | ||
| 324 | + padded.x + STACK_REVEAL_OFFSET * depth as f64, | ||
| 325 | + padded.y, | ||
| 326 | + padded.width, | ||
| 327 | + padded.height, | ||
| 328 | + ), | ||
| 329 | + )); | ||
| 330 | + if idx == *active { | ||
| 331 | + depth = depth.saturating_sub(1); | ||
| 332 | + } | ||
| 333 | + } | ||
| 334 | + if let Some(wid) = active_window { | ||
| 335 | + geoms.push((wid, padded)); | ||
| 336 | + } | ||
| 337 | + geoms | ||
| 338 | + } | ||
| 247 | Node::Internal { | 339 | Node::Internal { |
| 248 | split, | 340 | split, |
| 249 | ratio, | 341 | ratio, |
@@ -279,6 +371,69 @@ impl Node { | |||
| 279 | } | 371 | } |
| 280 | } | 372 | } |
| 281 | 373 | ||
| 374 | + pub fn calculate_focus_geometries_with_gaps( | ||
| 375 | + &self, | ||
| 376 | + rect: Rect, | ||
| 377 | + gap_inner: f64, | ||
| 378 | + gap_outer: f64, | ||
| 379 | + is_root: bool, | ||
| 380 | + ) -> Vec<(WindowId, Rect)> { | ||
| 381 | + let padded = if is_root && gap_outer > 0.0 { | ||
| 382 | + Rect::new( | ||
| 383 | + rect.x + gap_outer, | ||
| 384 | + rect.y + gap_outer, | ||
| 385 | + (rect.width - 2.0 * gap_outer).max(0.0), | ||
| 386 | + (rect.height - 2.0 * gap_outer).max(0.0), | ||
| 387 | + ) | ||
| 388 | + } else { | ||
| 389 | + rect | ||
| 390 | + }; | ||
| 391 | + | ||
| 392 | + match self { | ||
| 393 | + Node::Leaf { window: Some(w) } => vec![(*w, padded)], | ||
| 394 | + Node::Leaf { window: None } => vec![], | ||
| 395 | + Node::Stack { | ||
| 396 | + windows, active, .. | ||
| 397 | + } => windows | ||
| 398 | + .get(*active) | ||
| 399 | + .copied() | ||
| 400 | + .map(|wid| vec![(wid, padded)]) | ||
| 401 | + .unwrap_or_default(), | ||
| 402 | + Node::Internal { | ||
| 403 | + split, | ||
| 404 | + ratio, | ||
| 405 | + left, | ||
| 406 | + right, | ||
| 407 | + } => { | ||
| 408 | + let half_gap = gap_inner / 2.0; | ||
| 409 | + let (mut left_rect, mut right_rect) = padded.split(*split, *ratio); | ||
| 410 | + | ||
| 411 | + if gap_inner > 0.0 { | ||
| 412 | + match split { | ||
| 413 | + SplitDirection::Vertical => { | ||
| 414 | + left_rect.width = (left_rect.width - half_gap).max(0.0); | ||
| 415 | + right_rect.x += half_gap; | ||
| 416 | + right_rect.width = (right_rect.width - half_gap).max(0.0); | ||
| 417 | + } | ||
| 418 | + SplitDirection::Horizontal => { | ||
| 419 | + left_rect.height = (left_rect.height - half_gap).max(0.0); | ||
| 420 | + right_rect.y += half_gap; | ||
| 421 | + right_rect.height = (right_rect.height - half_gap).max(0.0); | ||
| 422 | + } | ||
| 423 | + } | ||
| 424 | + } | ||
| 425 | + | ||
| 426 | + let mut geoms = left.calculate_focus_geometries_with_gaps( | ||
| 427 | + left_rect, gap_inner, gap_outer, false, | ||
| 428 | + ); | ||
| 429 | + geoms.extend(right.calculate_focus_geometries_with_gaps( | ||
| 430 | + right_rect, gap_inner, gap_outer, false, | ||
| 431 | + )); | ||
| 432 | + geoms | ||
| 433 | + } | ||
| 434 | + } | ||
| 435 | + } | ||
| 436 | + | ||
| 282 | /// Equalize all split ratios to 0.5. | 437 | /// Equalize all split ratios to 0.5. |
| 283 | pub fn equalize(&mut self) { | 438 | pub fn equalize(&mut self) { |
| 284 | if let Node::Internal { | 439 | if let Node::Internal { |
@@ -311,6 +466,27 @@ impl Node { | |||
| 311 | } | 466 | } |
| 312 | } | 467 | } |
| 313 | Node::Leaf { window: None } => {} | 468 | Node::Leaf { window: None } => {} |
| 469 | + Node::Stack { | ||
| 470 | + windows, | ||
| 471 | + active, | ||
| 472 | + previous, | ||
| 473 | + } => { | ||
| 474 | + if let Some(w) = windows.iter_mut().find(|w| **w == a) { | ||
| 475 | + *w = b; | ||
| 476 | + *found_a = true; | ||
| 477 | + } else if let Some(w) = windows.iter_mut().find(|w| **w == b) { | ||
| 478 | + *w = a; | ||
| 479 | + *found_b = true; | ||
| 480 | + } | ||
| 481 | + if let Some(current) = windows.get(*active).copied() { | ||
| 482 | + if current == a { | ||
| 483 | + *active = windows.iter().position(|wid| *wid == b).unwrap_or(*active); | ||
| 484 | + } else if current == b { | ||
| 485 | + *active = windows.iter().position(|wid| *wid == a).unwrap_or(*active); | ||
| 486 | + } | ||
| 487 | + } | ||
| 488 | + previous.swap_impl(a, b, found_a, found_b); | ||
| 489 | + } | ||
| 314 | Node::Internal { left, right, .. } => { | 490 | Node::Internal { left, right, .. } => { |
| 315 | left.swap_impl(a, b, found_a, found_b); | 491 | left.swap_impl(a, b, found_a, found_b); |
| 316 | right.swap_impl(a, b, found_a, found_b); | 492 | right.swap_impl(a, b, found_a, found_b); |
@@ -451,7 +627,7 @@ impl Node { | |||
| 451 | /// Resize the split affecting a window in the given direction. | 627 | /// Resize the split affecting a window in the given direction. |
| 452 | pub fn resize(&mut self, window: WindowId, direction: Direction, delta: f32) -> bool { | 628 | pub fn resize(&mut self, window: WindowId, direction: Direction, delta: f32) -> bool { |
| 453 | match self { | 629 | match self { |
| 454 | - Node::Leaf { .. } => false, | 630 | + Node::Leaf { .. } | Node::Stack { .. } => false, |
| 455 | Node::Internal { | 631 | Node::Internal { |
| 456 | split, | 632 | split, |
| 457 | ratio, | 633 | ratio, |
@@ -496,6 +672,147 @@ impl Node { | |||
| 496 | } | 672 | } |
| 497 | } | 673 | } |
| 498 | } | 674 | } |
| 675 | + | ||
| 676 | + pub fn set_stack_active(&mut self, window: WindowId) -> bool { | ||
| 677 | + match self { | ||
| 678 | + Node::Stack { | ||
| 679 | + windows, | ||
| 680 | + active, | ||
| 681 | + previous: _, | ||
| 682 | + } => { | ||
| 683 | + if let Some(idx) = windows.iter().position(|wid| *wid == window) { | ||
| 684 | + *active = idx; | ||
| 685 | + true | ||
| 686 | + } else { | ||
| 687 | + false | ||
| 688 | + } | ||
| 689 | + } | ||
| 690 | + Node::Internal { left, right, .. } => { | ||
| 691 | + left.set_stack_active(window) || right.set_stack_active(window) | ||
| 692 | + } | ||
| 693 | + Node::Leaf { .. } => false, | ||
| 694 | + } | ||
| 695 | + } | ||
| 696 | + | ||
| 697 | + pub fn stack_info(&self, window: WindowId) -> Option<(Vec<WindowId>, usize)> { | ||
| 698 | + match self { | ||
| 699 | + Node::Stack { | ||
| 700 | + windows, active, .. | ||
| 701 | + } if windows.contains(&window) => Some((windows.clone(), *active)), | ||
| 702 | + Node::Internal { left, right, .. } => left | ||
| 703 | + .stack_info(window) | ||
| 704 | + .or_else(|| right.stack_info(window)), | ||
| 705 | + _ => None, | ||
| 706 | + } | ||
| 707 | + } | ||
| 708 | + | ||
| 709 | + pub fn cycle_stack(&mut self, window: WindowId, forward: bool) -> Option<WindowId> { | ||
| 710 | + match self { | ||
| 711 | + Node::Stack { | ||
| 712 | + windows, active, .. | ||
| 713 | + } if windows.contains(&window) => { | ||
| 714 | + if windows.is_empty() { | ||
| 715 | + return None; | ||
| 716 | + } | ||
| 717 | + let len = windows.len(); | ||
| 718 | + *active = if forward { | ||
| 719 | + (*active + 1) % len | ||
| 720 | + } else if *active == 0 { | ||
| 721 | + len - 1 | ||
| 722 | + } else { | ||
| 723 | + *active - 1 | ||
| 724 | + }; | ||
| 725 | + windows.get(*active).copied() | ||
| 726 | + } | ||
| 727 | + Node::Internal { left, right, .. } => left | ||
| 728 | + .cycle_stack(window, forward) | ||
| 729 | + .or_else(|| right.cycle_stack(window, forward)), | ||
| 730 | + _ => None, | ||
| 731 | + } | ||
| 732 | + } | ||
| 733 | + | ||
| 734 | + pub fn reorder_stack(&mut self, window: WindowId, forward: bool) -> Option<WindowId> { | ||
| 735 | + match self { | ||
| 736 | + Node::Stack { | ||
| 737 | + windows, | ||
| 738 | + active, | ||
| 739 | + previous, | ||
| 740 | + } if windows.contains(&window) => { | ||
| 741 | + let idx = windows.iter().position(|wid| *wid == window)?; | ||
| 742 | + let swap_idx = if forward { | ||
| 743 | + if idx + 1 < windows.len() { | ||
| 744 | + idx + 1 | ||
| 745 | + } else { | ||
| 746 | + 0 | ||
| 747 | + } | ||
| 748 | + } else if idx == 0 { | ||
| 749 | + windows.len() - 1 | ||
| 750 | + } else { | ||
| 751 | + idx - 1 | ||
| 752 | + }; | ||
| 753 | + windows.swap(idx, swap_idx); | ||
| 754 | + *active = swap_idx; | ||
| 755 | + previous.swap(window, windows[idx]); | ||
| 756 | + windows.get(*active).copied() | ||
| 757 | + } | ||
| 758 | + Node::Internal { left, right, .. } => left | ||
| 759 | + .reorder_stack(window, forward) | ||
| 760 | + .or_else(|| right.reorder_stack(window, forward)), | ||
| 761 | + _ => None, | ||
| 762 | + } | ||
| 763 | + } | ||
| 764 | + | ||
| 765 | + pub fn unstack(&mut self, window: WindowId) -> bool { | ||
| 766 | + match self { | ||
| 767 | + Node::Stack { | ||
| 768 | + windows, previous, .. | ||
| 769 | + } if windows.contains(&window) => { | ||
| 770 | + *self = (**previous).clone(); | ||
| 771 | + true | ||
| 772 | + } | ||
| 773 | + Node::Internal { left, right, .. } => left.unstack(window) || right.unstack(window), | ||
| 774 | + _ => false, | ||
| 775 | + } | ||
| 776 | + } | ||
| 777 | + | ||
| 778 | + pub fn make_stack_for_window(&mut self, window: WindowId) -> bool { | ||
| 779 | + self.make_stack_for_window_impl(window) | ||
| 780 | + } | ||
| 781 | + | ||
| 782 | + fn make_stack_for_window_impl(&mut self, window: WindowId) -> bool { | ||
| 783 | + match self { | ||
| 784 | + Node::Internal { left, right, .. } => { | ||
| 785 | + let left_contains = left.contains(window); | ||
| 786 | + let right_contains = right.contains(window); | ||
| 787 | + if !left_contains && !right_contains { | ||
| 788 | + return false; | ||
| 789 | + } | ||
| 790 | + | ||
| 791 | + let child_contains = if left_contains { left } else { right }; | ||
| 792 | + if !matches!(child_contains.as_ref(), Node::Stack { .. }) | ||
| 793 | + && child_contains.slot_count() > 1 | ||
| 794 | + && child_contains.make_stack_for_window_impl(window) | ||
| 795 | + { | ||
| 796 | + return true; | ||
| 797 | + } | ||
| 798 | + | ||
| 799 | + if self.slot_count() > 1 { | ||
| 800 | + let previous = self.clone(); | ||
| 801 | + let windows = self.windows(); | ||
| 802 | + let active = windows.iter().position(|wid| *wid == window).unwrap_or(0); | ||
| 803 | + *self = Node::Stack { | ||
| 804 | + windows, | ||
| 805 | + active, | ||
| 806 | + previous: Box::new(previous), | ||
| 807 | + }; | ||
| 808 | + true | ||
| 809 | + } else { | ||
| 810 | + false | ||
| 811 | + } | ||
| 812 | + } | ||
| 813 | + _ => false, | ||
| 814 | + } | ||
| 815 | + } | ||
| 499 | } | 816 | } |
| 500 | 817 | ||
| 501 | #[cfg(test)] | 818 | #[cfg(test)] |
@@ -1076,4 +1393,60 @@ mod tests { | |||
| 1076 | ); | 1393 | ); |
| 1077 | } | 1394 | } |
| 1078 | } | 1395 | } |
| 1396 | + | ||
| 1397 | + #[test] | ||
| 1398 | + fn make_stack_for_window_replaces_smallest_conflicting_subtree() { | ||
| 1399 | + let mut tree = Node::empty(); | ||
| 1400 | + tree.insert_with_rect(1, None, SCREEN); | ||
| 1401 | + tree.insert_with_rect(2, Some(1), SCREEN); | ||
| 1402 | + tree.insert_with_rect(3, Some(2), SCREEN); | ||
| 1403 | + | ||
| 1404 | + assert!(tree.make_stack_for_window(3)); | ||
| 1405 | + let stack = tree.stack_info(3).expect("window should be stacked"); | ||
| 1406 | + assert_eq!(stack.0, vec![2, 3]); | ||
| 1407 | + assert_eq!(stack.1, 1); | ||
| 1408 | + assert!(tree.contains(1)); | ||
| 1409 | + } | ||
| 1410 | + | ||
| 1411 | + #[test] | ||
| 1412 | + fn stacked_render_geometries_reveal_background_windows() { | ||
| 1413 | + let mut tree = Node::empty(); | ||
| 1414 | + tree.insert_with_rect(1, None, SCREEN); | ||
| 1415 | + tree.insert_with_rect(2, Some(1), SCREEN); | ||
| 1416 | + tree.insert_with_rect(3, Some(2), SCREEN); | ||
| 1417 | + assert!(tree.make_stack_for_window(3)); | ||
| 1418 | + | ||
| 1419 | + let geoms = tree.calculate_geometries(SCREEN); | ||
| 1420 | + let g2 = geoms.iter().find(|(wid, _)| *wid == 2).unwrap().1; | ||
| 1421 | + let g3 = geoms.iter().find(|(wid, _)| *wid == 3).unwrap().1; | ||
| 1422 | + | ||
| 1423 | + assert!(g2.x > g3.x); | ||
| 1424 | + assert_eq!(g2.width, g3.width); | ||
| 1425 | + assert_eq!(g2.height, g3.height); | ||
| 1426 | + } | ||
| 1427 | + | ||
| 1428 | + #[test] | ||
| 1429 | + fn unstack_restores_previous_subtree() { | ||
| 1430 | + let mut tree = Node::empty(); | ||
| 1431 | + tree.insert_with_rect(1, None, SCREEN); | ||
| 1432 | + tree.insert_with_rect(2, Some(1), SCREEN); | ||
| 1433 | + tree.insert_with_rect(3, Some(2), SCREEN); | ||
| 1434 | + let original = tree.clone(); | ||
| 1435 | + | ||
| 1436 | + assert!(tree.make_stack_for_window(3)); | ||
| 1437 | + assert!(tree.unstack(3)); | ||
| 1438 | + assert_eq!(tree, original); | ||
| 1439 | + } | ||
| 1440 | + | ||
| 1441 | + #[test] | ||
| 1442 | + fn removing_from_stack_collapses_to_leaf() { | ||
| 1443 | + let mut tree = Node::empty(); | ||
| 1444 | + tree.insert_with_rect(1, None, SCREEN); | ||
| 1445 | + tree.insert_with_rect(2, Some(1), SCREEN); | ||
| 1446 | + | ||
| 1447 | + assert!(tree.make_stack_for_window(2)); | ||
| 1448 | + assert!(tree.remove(2)); | ||
| 1449 | + | ||
| 1450 | + assert!(matches!(tree, Node::Leaf { window: Some(1) })); | ||
| 1451 | + } | ||
| 1079 | } | 1452 | } |
tarmac/src/main.rsmodified@@ -243,6 +243,7 @@ fn handle_action(action: Action) { | |||
| 243 | }); | 243 | }); |
| 244 | } | 244 | } |
| 245 | Action::ToggleFloat => state.toggle_float(), | 245 | Action::ToggleFloat => state.toggle_float(), |
| 246 | + Action::Unstack => state.unstack_focused(), | ||
| 246 | Action::ToggleSpecial(ref name) => state.toggle_special(name), | 247 | Action::ToggleSpecial(ref name) => state.toggle_special(name), |
| 247 | Action::MoveToSpecial(ref name) => state.move_to_special(name), | 248 | Action::MoveToSpecial(ref name) => state.move_to_special(name), |
| 248 | Action::FocusMonitorNext => { | 249 | Action::FocusMonitorNext => { |
@@ -324,6 +325,7 @@ gar.bind("mod+return", "spawn_terminal") | |||
| 324 | gar.bind("mod+shift+q", "close") | 325 | gar.bind("mod+shift+q", "close") |
| 325 | gar.bind("mod+e", "equalize") | 326 | gar.bind("mod+e", "equalize") |
| 326 | gar.bind("mod+shift+space", "toggle_float") | 327 | gar.bind("mod+shift+space", "toggle_float") |
| 328 | +gar.bind("mod+shift+u", "unstack") | ||
| 327 | 329 | ||
| 328 | -- Focus | 330 | -- Focus |
| 329 | gar.bind("mod+h", "focus left") | 331 | gar.bind("mod+h", "focus left") |
@@ -495,6 +497,10 @@ fn process_ipc_command( | |||
| 495 | state.equalize(); | 497 | state.equalize(); |
| 496 | Response::ok_empty() | 498 | Response::ok_empty() |
| 497 | } | 499 | } |
| 500 | + "unstack" => { | ||
| 501 | + state.unstack_focused(); | ||
| 502 | + Response::ok_empty() | ||
| 503 | + } | ||
| 498 | "workspace" => { | 504 | "workspace" => { |
| 499 | if let Some(target) = request | 505 | if let Some(target) = request |
| 500 | .args | 506 | .args |
@@ -570,6 +576,17 @@ fn process_ipc_command( | |||
| 570 | .map(|w| w.app_name.clone()) | 576 | .map(|w| w.app_name.clone()) |
| 571 | .unwrap_or_default(); | 577 | .unwrap_or_default(); |
| 572 | let floating = state.active_workspace().is_floating(focused_id); | 578 | let floating = state.active_workspace().is_floating(focused_id); |
| 579 | + let stack = state | ||
| 580 | + .active_workspace() | ||
| 581 | + .tree | ||
| 582 | + .stack_info(focused_id) | ||
| 583 | + .map(|(members, active)| { | ||
| 584 | + serde_json::json!({ | ||
| 585 | + "members": members, | ||
| 586 | + "active_index": active, | ||
| 587 | + "active": members.get(active), | ||
| 588 | + }) | ||
| 589 | + }); | ||
| 573 | 590 | ||
| 574 | // Query live AX data for current title and geometry | 591 | // Query live AX data for current title and geometry |
| 575 | let (title, x, y, width, height) = | 592 | let (title, x, y, width, height) = |
@@ -589,6 +606,7 @@ fn process_ipc_command( | |||
| 589 | "x": x, "y": y, | 606 | "x": x, "y": y, |
| 590 | "width": width, "height": height, | 607 | "width": width, "height": height, |
| 591 | "floating": floating, | 608 | "floating": floating, |
| 609 | + "stack": stack, | ||
| 592 | "workspace": state.active_workspace().id.to_string(), | 610 | "workspace": state.active_workspace().id.to_string(), |
| 593 | })) | 611 | })) |
| 594 | } else { | 612 | } else { |
@@ -658,12 +676,10 @@ fn process_ipc_command( | |||
| 658 | .monitor_showing_workspace(ws_idx) | 676 | .monitor_showing_workspace(ws_idx) |
| 659 | .map(|mi| state.monitor_rect(mi)) | 677 | .map(|mi| state.monitor_rect(mi)) |
| 660 | .unwrap_or_else(|| state.focused_rect()); | 678 | .unwrap_or_else(|| state.focused_rect()); |
| 661 | - let geoms = ws.tree.calculate_geometries_with_gaps( | 679 | + let (gap_inner, gap_outer) = state.workspace_gaps(ws_idx); |
| 662 | - sr, | 680 | + let geoms = ws |
| 663 | - state.gap_inner, | 681 | + .tree |
| 664 | - state.gap_outer, | 682 | + .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); |
| 665 | - true, | ||
| 666 | - ); | ||
| 667 | let windows: Vec<serde_json::Value> = geoms | 683 | let windows: Vec<serde_json::Value> = geoms |
| 668 | .iter() | 684 | .iter() |
| 669 | .map(|(wid, rect)| { | 685 | .map(|(wid, rect)| { |
@@ -672,11 +688,22 @@ fn process_ipc_command( | |||
| 672 | .get(*wid) | 688 | .get(*wid) |
| 673 | .map(|w| w.app_name.clone()) | 689 | .map(|w| w.app_name.clone()) |
| 674 | .unwrap_or_default(); | 690 | .unwrap_or_default(); |
| 691 | + let stack = ws.tree.stack_info(*wid); | ||
| 692 | + let stack_index = stack | ||
| 693 | + .as_ref() | ||
| 694 | + .and_then(|(members, _)| members.iter().position(|id| *id == *wid)); | ||
| 695 | + let stack_active = stack | ||
| 696 | + .as_ref() | ||
| 697 | + .map(|(members, active)| members.get(*active) == Some(wid)) | ||
| 698 | + .unwrap_or(false); | ||
| 675 | serde_json::json!({ | 699 | serde_json::json!({ |
| 676 | "id": wid, | 700 | "id": wid, |
| 677 | "app_name": app_name, | 701 | "app_name": app_name, |
| 678 | "x": rect.x, "y": rect.y, | 702 | "x": rect.x, "y": rect.y, |
| 679 | "width": rect.width, "height": rect.height, | 703 | "width": rect.width, "height": rect.height, |
| 704 | + "stacked": stack.is_some(), | ||
| 705 | + "stack_index": stack_index, | ||
| 706 | + "stack_active": stack_active, | ||
| 680 | }) | 707 | }) |
| 681 | }) | 708 | }) |
| 682 | .collect(); | 709 | .collect(); |
tarmacctl/src/main.rsmodified@@ -38,6 +38,7 @@ fn main() { | |||
| 38 | eprintln!(" resize <left|right|up|down> Resize split in direction"); | 38 | eprintln!(" resize <left|right|up|down> Resize split in direction"); |
| 39 | eprintln!(" close Close focused window"); | 39 | eprintln!(" close Close focused window"); |
| 40 | eprintln!(" equalize Equalize all splits"); | 40 | eprintln!(" equalize Equalize all splits"); |
| 41 | + eprintln!(" unstack Restore the focused stacked subtree"); | ||
| 41 | eprintln!(" workspace <1-10> Switch workspace"); | 42 | eprintln!(" workspace <1-10> Switch workspace"); |
| 42 | eprintln!(" move-to-workspace <1-10> Move window to workspace"); | 43 | eprintln!(" move-to-workspace <1-10> Move window to workspace"); |
| 43 | eprintln!(" toggle-floating Toggle floating state"); | 44 | eprintln!(" toggle-floating Toggle floating state"); |