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 | 15 | .claude/ |
| 16 | 16 | CLAUDE.md |
| 17 | 17 | .ref/ |
| 18 | +.refs/ | |
| 18 | 19 | AGENTS.md |
| 19 | 20 | nohup.out |
tarmac/src/config/lua.rsmodified@@ -840,6 +840,7 @@ fn parse_action(action: &str) -> Result<Action, &'static str> { | ||
| 840 | 840 | "close" => Ok(Action::CloseWindow), |
| 841 | 841 | "equalize" => Ok(Action::Equalize), |
| 842 | 842 | "toggle_float" => Ok(Action::ToggleFloat), |
| 843 | + "unstack" => Ok(Action::Unstack), | |
| 843 | 844 | "workspace_next" => Ok(Action::WorkspaceNext), |
| 844 | 845 | "workspace_prev" => Ok(Action::WorkspacePrev), |
| 845 | 846 | "focus_monitor_next" => Ok(Action::FocusMonitorNext), |
@@ -897,6 +898,11 @@ pub fn default_keybinds(settings: &Settings) -> Vec<LuaKeybind> { | ||
| 897 | 898 | key: Key::Space, |
| 898 | 899 | action: Action::ToggleFloat, |
| 899 | 900 | }, |
| 901 | + LuaKeybind { | |
| 902 | + modifiers: ms, | |
| 903 | + key: Key::U, | |
| 904 | + action: Action::Unstack, | |
| 905 | + }, | |
| 900 | 906 | // Focus |
| 901 | 907 | LuaKeybind { |
| 902 | 908 | modifiers: m, |
@@ -1162,6 +1168,7 @@ pub fn format_action(action: &Action) -> String { | ||
| 1162 | 1168 | Action::WorkspaceNext => "workspace_next".to_string(), |
| 1163 | 1169 | Action::WorkspacePrev => "workspace_prev".to_string(), |
| 1164 | 1170 | Action::ToggleFloat => "toggle_float".to_string(), |
| 1171 | + Action::Unstack => "unstack".to_string(), | |
| 1165 | 1172 | Action::ToggleSpecial(name) => format!("toggle_special {name}"), |
| 1166 | 1173 | Action::MoveToSpecial(name) => format!("move_to_special {name}"), |
| 1167 | 1174 | Action::FocusMonitorNext => "focus_monitor_next".to_string(), |
tarmac/src/core/input.rsmodified@@ -191,6 +191,7 @@ pub enum Action { | ||
| 191 | 191 | FocusMonitorPrev, // Focus previous monitor |
| 192 | 192 | MoveToMonitorNext, // Move window to next monitor |
| 193 | 193 | MoveToMonitorPrev, // Move window to previous monitor |
| 194 | + Unstack, // Restore a stacked subtree to its saved BSP shape | |
| 194 | 195 | Reload, // Hot reload config |
| 195 | 196 | Exit, // Clean exit |
| 196 | 197 | } |
@@ -244,6 +245,7 @@ impl KeybindManager { | ||
| 244 | 245 | // Spawn / close |
| 245 | 246 | mgr.add(m, Key::Return, Action::SpawnTerminal); |
| 246 | 247 | mgr.add(ms, Key::Q, Action::CloseWindow); |
| 248 | + mgr.add(ms, Key::U, Action::Unstack); | |
| 247 | 249 | |
| 248 | 250 | // Focus: Option+hjkl and Option+Arrows |
| 249 | 251 | mgr.add(m, Key::H, Action::Focus(Left)); |
tarmac/src/core/state.rsmodified@@ -345,6 +345,22 @@ impl WmState { | ||
| 345 | 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 | 364 | pub fn process_events(&mut self) { |
| 349 | 365 | let events: Vec<QueuedEvent> = self.event_queue.borrow_mut().drain(..).collect(); |
| 350 | 366 | for queued in events { |
@@ -363,10 +379,7 @@ impl WmState { | ||
| 363 | 379 | let ws_idx = monitor.active_workspace; |
| 364 | 380 | let ws = self.workspaces.get(ws_idx); |
| 365 | 381 | let screen_rect = self.monitor_rect(mi); |
| 366 | - let (gap_inner, gap_outer) = self.workspace_gaps(ws_idx); | |
| 367 | - let geometries = | |
| 368 | - ws.tree | |
| 369 | - .calculate_geometries_with_gaps(screen_rect, gap_inner, gap_outer, true); | |
| 382 | + let geometries = self.workspace_render_geometries(ws_idx, screen_rect); | |
| 370 | 383 | tracing::debug!(monitor = mi, workspace = %ws.id, windows = geometries.len(), |
| 371 | 384 | sr_x = screen_rect.x, sr_y = screen_rect.y, sr_w = screen_rect.width, |
| 372 | 385 | sr_h = screen_rect.height, "apply_layout"); |
@@ -408,10 +421,7 @@ impl WmState { | ||
| 408 | 421 | let ws_idx = monitor.active_workspace; |
| 409 | 422 | let ws = self.workspaces.get(ws_idx); |
| 410 | 423 | let sr = self.monitor_rect(mi); |
| 411 | - let (gap_inner, gap_outer) = self.workspace_gaps(ws_idx); | |
| 412 | - let geoms = ws | |
| 413 | - .tree | |
| 414 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | |
| 424 | + let geoms = self.workspace_focus_geometries(ws_idx, sr); | |
| 415 | 425 | let focused = ws.focused; |
| 416 | 426 | |
| 417 | 427 | for (wid, rect) in &geoms { |
@@ -494,8 +504,6 @@ impl WmState { | ||
| 494 | 504 | .monitor_showing_workspace(ws_idx) |
| 495 | 505 | .map(|mi| self.monitor_rect(mi)) |
| 496 | 506 | .unwrap_or_else(|| self.focused_rect()); |
| 497 | - let (gap_inner, gap_outer) = self.workspace_gaps(ws_idx); | |
| 498 | - | |
| 499 | 507 | // Phase 1: Swap oversized windows into larger tiles. |
| 500 | 508 | // Batch all swaps using BSP geometry (no AX calls), then apply layout |
| 501 | 509 | // once and settle. This is both faster (one layout instead of N) and |
@@ -510,11 +518,7 @@ impl WmState { | ||
| 510 | 518 | // Geometries are computed from the BSP tree (cheap, no AX). |
| 511 | 519 | // After batched swaps, tree geometry reflects the new layout |
| 512 | 520 | // even before apply_layout sends AX commands. |
| 513 | - let geometries = self | |
| 514 | - .workspaces | |
| 515 | - .get(ws_idx) | |
| 516 | - .tree | |
| 517 | - .calculate_geometries_with_gaps(screen_rect, gap_inner, gap_outer, true); | |
| 521 | + let geometries = self.workspace_focus_geometries(ws_idx, screen_rect); | |
| 518 | 522 | if geometries.is_empty() { |
| 519 | 523 | break; |
| 520 | 524 | } |
@@ -578,15 +582,9 @@ impl WmState { | ||
| 578 | 582 | // settling. The settled set prevents ping-pong. |
| 579 | 583 | } |
| 580 | 584 | |
| 581 | - // Phase 2: Float remaining oversized windows locally. | |
| 582 | - // Oversized windows should not silently migrate across workspaces; | |
| 583 | - // keep workspace membership stable and remediate in place. | |
| 585 | + // Phase 2: Replace the smallest conflicting subtree with a stack. | |
| 584 | 586 | loop { |
| 585 | - let geometries = self | |
| 586 | - .workspaces | |
| 587 | - .get(ws_idx) | |
| 588 | - .tree | |
| 589 | - .calculate_geometries_with_gaps(screen_rect, gap_inner, gap_outer, true); | |
| 587 | + let geometries = self.workspace_focus_geometries(ws_idx, screen_rect); | |
| 590 | 588 | if geometries.is_empty() { |
| 591 | 589 | break; |
| 592 | 590 | } |
@@ -614,24 +612,20 @@ impl WmState { | ||
| 614 | 612 | None => break, |
| 615 | 613 | }; |
| 616 | 614 | |
| 617 | - tracing::info!( | |
| 618 | - id = oversized_wid, | |
| 619 | - min_w, | |
| 620 | - min_h, | |
| 621 | - ws = ws_idx + 1, | |
| 622 | - "floating oversized window locally" | |
| 623 | - ); | |
| 624 | - self.workspaces | |
| 625 | - .get_mut(ws_idx) | |
| 626 | - .toggle_float(oversized_wid, screen_rect); | |
| 627 | - if let Some(ax_ref) = self.ax_refs.get(&oversized_wid) { | |
| 628 | - let fx = screen_rect.x + (screen_rect.width - min_w) / 2.0; | |
| 629 | - let fy = screen_rect.y + (screen_rect.height - min_h) / 2.0; | |
| 630 | - let _ = ax_set_position(ax_ref, fx, fy); | |
| 631 | - let _ = ax_set_size(ax_ref, min_w, min_h); | |
| 615 | + if self.workspaces.get_mut(ws_idx).tree.make_stack_for_window(oversized_wid) { | |
| 616 | + tracing::info!( | |
| 617 | + id = oversized_wid, | |
| 618 | + min_w, | |
| 619 | + min_h, | |
| 620 | + ws = ws_idx + 1, | |
| 621 | + "stacking oversized subtree locally" | |
| 622 | + ); | |
| 623 | + self.workspaces.get_mut(ws_idx).tree.set_stack_active(oversized_wid); | |
| 624 | + self.apply_layout(); | |
| 625 | + std::thread::sleep(std::time::Duration::from_millis(50)); | |
| 626 | + } else { | |
| 627 | + break; | |
| 632 | 628 | } |
| 633 | - self.apply_layout(); | |
| 634 | - self.restack_floating_windows(ws_idx); | |
| 635 | 629 | } |
| 636 | 630 | |
| 637 | 631 | processed.push(ws_idx); |
@@ -668,13 +662,21 @@ impl WmState { | ||
| 668 | 662 | let ws = self.active_workspace(); |
| 669 | 663 | let focused = ws.focused; |
| 670 | 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 | 677 | // Try intra-workspace navigation first (only if we have a focused window) |
| 674 | 678 | if let Some(from) = focused { |
| 675 | - let geoms = ws | |
| 676 | - .tree | |
| 677 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | |
| 679 | + let geoms = self.workspace_focus_geometries(self.active_ws_idx(), sr); | |
| 678 | 680 | |
| 679 | 681 | // Check if focused window is at the monitor edge in the requested |
| 680 | 682 | // direction. If so, cross monitors instead of spiraling into the BSP tree. |
@@ -720,10 +722,7 @@ impl WmState { | ||
| 720 | 722 | if let Some(new_mi) = new_mi { |
| 721 | 723 | self.focused_monitor = new_mi; |
| 722 | 724 | let target_sr = self.focused_rect(); |
| 723 | - let target_geoms = self | |
| 724 | - .active_workspace() | |
| 725 | - .tree | |
| 726 | - .calculate_geometries_with_gaps(target_sr, gap_inner, gap_outer, true); | |
| 725 | + let target_geoms = self.workspace_focus_geometries(self.active_ws_idx(), target_sr); | |
| 727 | 726 | |
| 728 | 727 | if let Some(wid) = |
| 729 | 728 | Node::nearest_to_edge(&target_geoms, direction).or(self.active_workspace().focused) |
@@ -762,10 +761,19 @@ impl WmState { | ||
| 762 | 761 | None => return, |
| 763 | 762 | }; |
| 764 | 763 | let sr = self.focused_rect(); |
| 765 | - let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); | |
| 766 | - let geoms = ws | |
| 767 | - .tree | |
| 768 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | |
| 764 | + | |
| 765 | + if matches!(direction, Direction::Left | Direction::Right) | |
| 766 | + && let Some(next) = self | |
| 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 | 778 | // Check if the focused window touches the monitor edge in the |
| 771 | 779 | // requested direction. If so, skip intra-workspace swap and move |
@@ -874,17 +882,22 @@ impl WmState { | ||
| 874 | 882 | // Record focus on the workspace that CONTAINS this window, |
| 875 | 883 | // not the active workspace — during cross-monitor FFM the active |
| 876 | 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 | 886 | let old = self.workspaces.get(ws_idx).focused; |
| 879 | 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 | 889 | self.workspaces.get_mut(ws_idx).record_focus(id); |
| 881 | - old | |
| 890 | + (old, stack_changed) | |
| 882 | 891 | } else { |
| 883 | 892 | let old = self.active_workspace().focused; |
| 884 | 893 | self.active_workspace_mut().raise_floating(id); |
| 894 | + let stack_changed = self.active_workspace_mut().tree.set_stack_active(id); | |
| 885 | 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 | 901 | self.enforce_floating_levels(id); |
| 889 | 902 | |
| 890 | 903 | // Update border colors on focus change |
@@ -1169,20 +1182,17 @@ impl WmState { | ||
| 1169 | 1182 | floating_hit |
| 1170 | 1183 | } else { |
| 1171 | 1184 | // Check tiled windows within the overlay rect |
| 1172 | - let geoms = ws.tree.calculate_geometries_with_gaps( | |
| 1173 | - overlay_rect, | |
| 1174 | - gap_inner, | |
| 1175 | - gap_outer, | |
| 1176 | - true, | |
| 1177 | - ); | |
| 1185 | + let geoms = ws | |
| 1186 | + .tree | |
| 1187 | + .calculate_geometries_with_gaps(overlay_rect, gap_inner, gap_outer, true); | |
| 1178 | 1188 | geoms |
| 1179 | 1189 | .iter() |
| 1190 | + .rev() | |
| 1180 | 1191 | .find(|(_, rect)| rect.contains_point(x, y)) |
| 1181 | 1192 | .map(|(id, _)| *id) |
| 1182 | 1193 | } |
| 1183 | 1194 | } else { |
| 1184 | 1195 | let ws = self.active_workspace(); |
| 1185 | - let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); | |
| 1186 | 1196 | |
| 1187 | 1197 | // Check floating windows first -- they're visually on top |
| 1188 | 1198 | let floating_under = ws |
@@ -1196,14 +1206,11 @@ impl WmState { | ||
| 1196 | 1206 | floating_under |
| 1197 | 1207 | } else { |
| 1198 | 1208 | // Check tiled windows using gap-aware geometry matching actual layout |
| 1199 | - let geoms = ws.tree.calculate_geometries_with_gaps( | |
| 1200 | - self.focused_rect(), | |
| 1201 | - gap_inner, | |
| 1202 | - gap_outer, | |
| 1203 | - true, | |
| 1204 | - ); | |
| 1209 | + let geoms = | |
| 1210 | + self.workspace_render_geometries(self.active_ws_idx(), self.focused_rect()); | |
| 1205 | 1211 | geoms |
| 1206 | 1212 | .iter() |
| 1213 | + .rev() | |
| 1207 | 1214 | .find(|(_, rect)| rect.contains_point(x, y)) |
| 1208 | 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 | 1279 | pub fn click_to_focus(&mut self, x: f64, y: f64) { |
| 1256 | 1280 | let ws = self.active_workspace(); |
| 1257 | - let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); | |
| 1258 | 1281 | |
| 1259 | 1282 | // Check floating first |
| 1260 | 1283 | let floating_hit = ws |
@@ -1267,14 +1290,10 @@ impl WmState { | ||
| 1267 | 1290 | let id = if let Some(fid) = floating_hit { |
| 1268 | 1291 | Some(fid) |
| 1269 | 1292 | } else { |
| 1270 | - let geoms = ws.tree.calculate_geometries_with_gaps( | |
| 1271 | - self.focused_rect(), | |
| 1272 | - gap_inner, | |
| 1273 | - gap_outer, | |
| 1274 | - true, | |
| 1275 | - ); | |
| 1293 | + let geoms = self.workspace_render_geometries(self.active_ws_idx(), self.focused_rect()); | |
| 1276 | 1294 | geoms |
| 1277 | 1295 | .iter() |
| 1296 | + .rev() | |
| 1278 | 1297 | .find(|(_, rect)| rect.contains_point(x, y)) |
| 1279 | 1298 | .map(|(id, _)| *id) |
| 1280 | 1299 | }; |
@@ -1309,12 +1328,7 @@ impl WmState { | ||
| 1309 | 1328 | // Warp to the focused WINDOW center (not monitor center) |
| 1310 | 1329 | if self.mouse_follows_focus { |
| 1311 | 1330 | let sr = self.focused_rect(); |
| 1312 | - let (gap_inner, gap_outer) = self.workspace_gaps(target_idx); | |
| 1313 | - let geoms = self | |
| 1314 | - .workspaces | |
| 1315 | - .get(target_idx) | |
| 1316 | - .tree | |
| 1317 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | |
| 1331 | + let geoms = self.workspace_focus_geometries(target_idx, sr); | |
| 1318 | 1332 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1319 | 1333 | warp_mouse_to_center(rect); |
| 1320 | 1334 | } else { |
@@ -1377,12 +1391,7 @@ impl WmState { | ||
| 1377 | 1391 | self.focus_window(wid); |
| 1378 | 1392 | if self.mouse_follows_focus { |
| 1379 | 1393 | let sr = self.focused_rect(); |
| 1380 | - let (gap_inner, gap_outer) = self.workspace_gaps(target_idx); | |
| 1381 | - let geoms = self | |
| 1382 | - .workspaces | |
| 1383 | - .get(target_idx) | |
| 1384 | - .tree | |
| 1385 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | |
| 1394 | + let geoms = self.workspace_focus_geometries(target_idx, sr); | |
| 1386 | 1395 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1387 | 1396 | warp_mouse_to_center(rect); |
| 1388 | 1397 | } else { |
@@ -1802,11 +1811,7 @@ impl WmState { | ||
| 1802 | 1811 | self.focus_window(wid); |
| 1803 | 1812 | if self.mouse_follows_focus { |
| 1804 | 1813 | let sr = self.focused_rect(); |
| 1805 | - let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); | |
| 1806 | - let geoms = self | |
| 1807 | - .active_workspace() | |
| 1808 | - .tree | |
| 1809 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | |
| 1814 | + let geoms = self.workspace_focus_geometries(self.active_ws_idx(), sr); | |
| 1810 | 1815 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1811 | 1816 | warp_mouse_to_center(rect); |
| 1812 | 1817 | } else { |
@@ -1836,11 +1841,7 @@ impl WmState { | ||
| 1836 | 1841 | self.focus_window(wid); |
| 1837 | 1842 | if self.mouse_follows_focus { |
| 1838 | 1843 | let sr = self.focused_rect(); |
| 1839 | - let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); | |
| 1840 | - let geoms = self | |
| 1841 | - .active_workspace() | |
| 1842 | - .tree | |
| 1843 | - .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | |
| 1844 | + let geoms = self.workspace_focus_geometries(self.active_ws_idx(), sr); | |
| 1844 | 1845 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1845 | 1846 | warp_mouse_to_center(rect); |
| 1846 | 1847 | } else { |
@@ -2244,7 +2245,7 @@ impl WmState { | ||
| 2244 | 2245 | } |
| 2245 | 2246 | |
| 2246 | 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 | 2249 | /// Only runs when the target workspace is visible — hidden workspaces have |
| 2249 | 2250 | /// stale window sizes and will be checked when switched to. |
| 2250 | 2251 | fn fix_oversized_on_target(&mut self, ws_idx: usize, moved_wid: super::window::WindowId) { |
@@ -2257,16 +2258,7 @@ impl WmState { | ||
| 2257 | 2258 | std::thread::sleep(std::time::Duration::from_millis(100)); |
| 2258 | 2259 | |
| 2259 | 2260 | // Check if the moved window actually overflows |
| 2260 | - let geometries = self | |
| 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 | - ); | |
| 2261 | + let geometries = self.workspace_focus_geometries(ws_idx, screen_rect); | |
| 2270 | 2262 | |
| 2271 | 2263 | let (_min_w, _min_h) = match geometries.iter().find(|(wid, _)| *wid == moved_wid) { |
| 2272 | 2264 | Some((_, rect)) => { |
@@ -2286,16 +2278,7 @@ impl WmState { | ||
| 2286 | 2278 | // Try swaps with settled-set logic (same as fix_oversized_windows phase 1) |
| 2287 | 2279 | let mut settled: Vec<super::window::WindowId> = Vec::new(); |
| 2288 | 2280 | loop { |
| 2289 | - let geoms = self | |
| 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 | - ); | |
| 2281 | + let geoms = self.workspace_focus_geometries(ws_idx, screen_rect); | |
| 2299 | 2282 | if geoms.is_empty() { |
| 2300 | 2283 | break; |
| 2301 | 2284 | } |
@@ -2341,27 +2324,20 @@ impl WmState { | ||
| 2341 | 2324 | self.apply_layout(); |
| 2342 | 2325 | settled.push(ow); |
| 2343 | 2326 | } else { |
| 2344 | - // No swap possible — float this window centered | |
| 2345 | - tracing::info!( | |
| 2346 | - id = ow, | |
| 2347 | - min_w = ow_min_w, | |
| 2348 | - min_h = ow_min_h, | |
| 2349 | - ws = ws_idx + 1, | |
| 2350 | - "floating oversized window on target workspace" | |
| 2351 | - ); | |
| 2352 | - self.workspaces | |
| 2353 | - .get_mut(ws_idx) | |
| 2354 | - .toggle_float(ow, screen_rect); | |
| 2355 | - if let Some(ax_ref) = self.ax_refs.get(&ow) { | |
| 2356 | - let fx = screen_rect.x + (screen_rect.width - ow_min_w) / 2.0; | |
| 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); | |
| 2327 | + if self.workspaces.get_mut(ws_idx).tree.make_stack_for_window(ow) { | |
| 2328 | + tracing::info!( | |
| 2329 | + id = ow, | |
| 2330 | + min_w = ow_min_w, | |
| 2331 | + min_h = ow_min_h, | |
| 2332 | + ws = ws_idx + 1, | |
| 2333 | + "stacking oversized subtree on target workspace" | |
| 2334 | + ); | |
| 2335 | + self.workspaces.get_mut(ws_idx).tree.set_stack_active(ow); | |
| 2336 | + self.apply_layout(); | |
| 2337 | + std::thread::sleep(std::time::Duration::from_millis(50)); | |
| 2338 | + } else { | |
| 2339 | + break; | |
| 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 | 2724 | // Record on the workspace that contains this window, |
| 2749 | 2725 | // not the active workspace (same fix as focus_window_impl) |
| 2750 | 2726 | if let Some(ws_idx) = self.workspaces.find_window(id) { |
| 2727 | + self.workspaces.get_mut(ws_idx).tree.set_stack_active(id); | |
| 2751 | 2728 | self.workspaces.get_mut(ws_idx).record_focus(id); |
| 2752 | 2729 | } |
| 2753 | 2730 | } |
@@ -2935,6 +2912,7 @@ fn hide_target_for_frame( | ||
| 2935 | 2912 | mod tests { |
| 2936 | 2913 | use super::*; |
| 2937 | 2914 | use crate::core::monitor::Monitor; |
| 2915 | + use crate::core::tree::Direction; | |
| 2938 | 2916 | |
| 2939 | 2917 | #[test] |
| 2940 | 2918 | fn hide_anchor_frame_defaults_without_monitors() { |
@@ -3151,4 +3129,63 @@ mod tests { | ||
| 3151 | 3129 | state.sync_workspace_visibility(); |
| 3152 | 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 | 67 | pub enum Node { |
| 66 | 68 | Internal { |
| 67 | 69 | split: SplitDirection, |
@@ -69,6 +71,11 @@ pub enum Node { | ||
| 69 | 71 | left: Box<Node>, |
| 70 | 72 | right: Box<Node>, |
| 71 | 73 | }, |
| 74 | + Stack { | |
| 75 | + windows: Vec<WindowId>, | |
| 76 | + active: usize, | |
| 77 | + previous: Box<Node>, | |
| 78 | + }, | |
| 72 | 79 | Leaf { |
| 73 | 80 | window: Option<WindowId>, |
| 74 | 81 | }, |
@@ -89,10 +96,20 @@ impl Node { | ||
| 89 | 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 | 108 | pub fn window_count(&self) -> usize { |
| 93 | 109 | match self { |
| 94 | 110 | Node::Leaf { window: Some(_) } => 1, |
| 95 | 111 | Node::Leaf { window: None } => 0, |
| 112 | + Node::Stack { windows, .. } => windows.len(), | |
| 96 | 113 | Node::Internal { left, right, .. } => left.window_count() + right.window_count(), |
| 97 | 114 | } |
| 98 | 115 | } |
@@ -101,6 +118,7 @@ impl Node { | ||
| 101 | 118 | match self { |
| 102 | 119 | Node::Leaf { window: Some(w) } => vec![*w], |
| 103 | 120 | Node::Leaf { window: None } => vec![], |
| 121 | + Node::Stack { windows, .. } => windows.clone(), | |
| 104 | 122 | Node::Internal { left, right, .. } => { |
| 105 | 123 | let mut ws = left.windows(); |
| 106 | 124 | ws.extend(right.windows()); |
@@ -113,6 +131,7 @@ impl Node { | ||
| 113 | 131 | match self { |
| 114 | 132 | Node::Leaf { window: Some(w) } => *w == window, |
| 115 | 133 | Node::Leaf { window: None } => false, |
| 134 | + Node::Stack { windows, .. } => windows.contains(&window), | |
| 116 | 135 | Node::Internal { left, right, .. } => left.contains(window) || right.contains(window), |
| 117 | 136 | } |
| 118 | 137 | } |
@@ -120,6 +139,9 @@ impl Node { | ||
| 120 | 139 | pub fn first_window(&self) -> Option<WindowId> { |
| 121 | 140 | match self { |
| 122 | 141 | Node::Leaf { window } => *window, |
| 142 | + Node::Stack { | |
| 143 | + windows, active, .. | |
| 144 | + } => windows.get(*active).copied().or_else(|| windows.first().copied()), | |
| 123 | 145 | Node::Internal { left, right, .. } => { |
| 124 | 146 | left.first_window().or_else(|| right.first_window()) |
| 125 | 147 | } |
@@ -185,6 +207,19 @@ impl Node { | ||
| 185 | 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 | 231 | true |
| 197 | 232 | } |
| 198 | 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 | 258 | Node::Internal { left, right, .. } => { |
| 200 | 259 | if left.remove(window) { |
| 201 | 260 | if left.is_empty() { |
@@ -219,6 +278,10 @@ impl Node { | ||
| 219 | 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 | 285 | /// Calculate geometries with inner and outer gaps. |
| 223 | 286 | /// `gap_outer` is applied to the root rect edges. |
| 224 | 287 | /// `gap_inner` is the space between adjacent windows (half applied to each side). |
@@ -244,6 +307,35 @@ impl Node { | ||
| 244 | 307 | match self { |
| 245 | 308 | Node::Leaf { window: Some(w) } => vec![(*w, padded)], |
| 246 | 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 | 339 | Node::Internal { |
| 248 | 340 | split, |
| 249 | 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 | 437 | /// Equalize all split ratios to 0.5. |
| 283 | 438 | pub fn equalize(&mut self) { |
| 284 | 439 | if let Node::Internal { |
@@ -311,6 +466,27 @@ impl Node { | ||
| 311 | 466 | } |
| 312 | 467 | } |
| 313 | 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 | 490 | Node::Internal { left, right, .. } => { |
| 315 | 491 | left.swap_impl(a, b, found_a, found_b); |
| 316 | 492 | right.swap_impl(a, b, found_a, found_b); |
@@ -451,7 +627,7 @@ impl Node { | ||
| 451 | 627 | /// Resize the split affecting a window in the given direction. |
| 452 | 628 | pub fn resize(&mut self, window: WindowId, direction: Direction, delta: f32) -> bool { |
| 453 | 629 | match self { |
| 454 | - Node::Leaf { .. } => false, | |
| 630 | + Node::Leaf { .. } | Node::Stack { .. } => false, | |
| 455 | 631 | Node::Internal { |
| 456 | 632 | split, |
| 457 | 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 | 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 | 245 | Action::ToggleFloat => state.toggle_float(), |
| 246 | + Action::Unstack => state.unstack_focused(), | |
| 246 | 247 | Action::ToggleSpecial(ref name) => state.toggle_special(name), |
| 247 | 248 | Action::MoveToSpecial(ref name) => state.move_to_special(name), |
| 248 | 249 | Action::FocusMonitorNext => { |
@@ -324,6 +325,7 @@ gar.bind("mod+return", "spawn_terminal") | ||
| 324 | 325 | gar.bind("mod+shift+q", "close") |
| 325 | 326 | gar.bind("mod+e", "equalize") |
| 326 | 327 | gar.bind("mod+shift+space", "toggle_float") |
| 328 | +gar.bind("mod+shift+u", "unstack") | |
| 327 | 329 | |
| 328 | 330 | -- Focus |
| 329 | 331 | gar.bind("mod+h", "focus left") |
@@ -495,6 +497,10 @@ fn process_ipc_command( | ||
| 495 | 497 | state.equalize(); |
| 496 | 498 | Response::ok_empty() |
| 497 | 499 | } |
| 500 | + "unstack" => { | |
| 501 | + state.unstack_focused(); | |
| 502 | + Response::ok_empty() | |
| 503 | + } | |
| 498 | 504 | "workspace" => { |
| 499 | 505 | if let Some(target) = request |
| 500 | 506 | .args |
@@ -570,6 +576,17 @@ fn process_ipc_command( | ||
| 570 | 576 | .map(|w| w.app_name.clone()) |
| 571 | 577 | .unwrap_or_default(); |
| 572 | 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 | 591 | // Query live AX data for current title and geometry |
| 575 | 592 | let (title, x, y, width, height) = |
@@ -589,6 +606,7 @@ fn process_ipc_command( | ||
| 589 | 606 | "x": x, "y": y, |
| 590 | 607 | "width": width, "height": height, |
| 591 | 608 | "floating": floating, |
| 609 | + "stack": stack, | |
| 592 | 610 | "workspace": state.active_workspace().id.to_string(), |
| 593 | 611 | })) |
| 594 | 612 | } else { |
@@ -658,12 +676,10 @@ fn process_ipc_command( | ||
| 658 | 676 | .monitor_showing_workspace(ws_idx) |
| 659 | 677 | .map(|mi| state.monitor_rect(mi)) |
| 660 | 678 | .unwrap_or_else(|| state.focused_rect()); |
| 661 | - let geoms = ws.tree.calculate_geometries_with_gaps( | |
| 662 | - sr, | |
| 663 | - state.gap_inner, | |
| 664 | - state.gap_outer, | |
| 665 | - true, | |
| 666 | - ); | |
| 679 | + let (gap_inner, gap_outer) = state.workspace_gaps(ws_idx); | |
| 680 | + let geoms = ws | |
| 681 | + .tree | |
| 682 | + .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true); | |
| 667 | 683 | let windows: Vec<serde_json::Value> = geoms |
| 668 | 684 | .iter() |
| 669 | 685 | .map(|(wid, rect)| { |
@@ -672,11 +688,22 @@ fn process_ipc_command( | ||
| 672 | 688 | .get(*wid) |
| 673 | 689 | .map(|w| w.app_name.clone()) |
| 674 | 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 | 699 | serde_json::json!({ |
| 676 | 700 | "id": wid, |
| 677 | 701 | "app_name": app_name, |
| 678 | 702 | "x": rect.x, "y": rect.y, |
| 679 | 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 | 709 | .collect(); |
tarmacctl/src/main.rsmodified@@ -38,6 +38,7 @@ fn main() { | ||
| 38 | 38 | eprintln!(" resize <left|right|up|down> Resize split in direction"); |
| 39 | 39 | eprintln!(" close Close focused window"); |
| 40 | 40 | eprintln!(" equalize Equalize all splits"); |
| 41 | + eprintln!(" unstack Restore the focused stacked subtree"); | |
| 41 | 42 | eprintln!(" workspace <1-10> Switch workspace"); |
| 42 | 43 | eprintln!(" move-to-workspace <1-10> Move window to workspace"); |
| 43 | 44 | eprintln!(" toggle-floating Toggle floating state"); |