gardesk/tarmac / 92e1d14

Browse files

Add stacked overflow remediation

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
92e1d140acce39acc344f0b90da4826e7a0543f3
Parents
ae35f6c
Tree
e1036a4

7 changed files

StatusFile+-
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/
1515
 .claude/
1616
 CLAUDE.md
1717
 .ref/
18
+.refs/
1819
 AGENTS.md
1920
 nohup.out
tarmac/src/config/lua.rsmodified
@@ -840,6 +840,7 @@ fn parse_action(action: &str) -> Result<Action, &'static str> {
840840
         "close" => Ok(Action::CloseWindow),
841841
         "equalize" => Ok(Action::Equalize),
842842
         "toggle_float" => Ok(Action::ToggleFloat),
843
+        "unstack" => Ok(Action::Unstack),
843844
         "workspace_next" => Ok(Action::WorkspaceNext),
844845
         "workspace_prev" => Ok(Action::WorkspacePrev),
845846
         "focus_monitor_next" => Ok(Action::FocusMonitorNext),
@@ -897,6 +898,11 @@ pub fn default_keybinds(settings: &Settings) -> Vec<LuaKeybind> {
897898
             key: Key::Space,
898899
             action: Action::ToggleFloat,
899900
         },
901
+        LuaKeybind {
902
+            modifiers: ms,
903
+            key: Key::U,
904
+            action: Action::Unstack,
905
+        },
900906
         // Focus
901907
         LuaKeybind {
902908
             modifiers: m,
@@ -1162,6 +1168,7 @@ pub fn format_action(action: &Action) -> String {
11621168
         Action::WorkspaceNext => "workspace_next".to_string(),
11631169
         Action::WorkspacePrev => "workspace_prev".to_string(),
11641170
         Action::ToggleFloat => "toggle_float".to_string(),
1171
+        Action::Unstack => "unstack".to_string(),
11651172
         Action::ToggleSpecial(name) => format!("toggle_special {name}"),
11661173
         Action::MoveToSpecial(name) => format!("move_to_special {name}"),
11671174
         Action::FocusMonitorNext => "focus_monitor_next".to_string(),
tarmac/src/core/input.rsmodified
@@ -191,6 +191,7 @@ pub enum Action {
191191
     FocusMonitorPrev,                 // Focus previous monitor
192192
     MoveToMonitorNext,                // Move window to next monitor
193193
     MoveToMonitorPrev,                // Move window to previous monitor
194
+    Unstack,                          // Restore a stacked subtree to its saved BSP shape
194195
     Reload,                           // Hot reload config
195196
     Exit,                             // Clean exit
196197
 }
@@ -244,6 +245,7 @@ impl KeybindManager {
244245
         // Spawn / close
245246
         mgr.add(m, Key::Return, Action::SpawnTerminal);
246247
         mgr.add(ms, Key::Q, Action::CloseWindow);
248
+        mgr.add(ms, Key::U, Action::Unstack);
247249
 
248250
         // Focus: Option+hjkl and Option+Arrows
249251
         mgr.add(m, Key::H, Action::Focus(Left));
tarmac/src/core/state.rsmodified
@@ -345,6 +345,22 @@ impl WmState {
345345
         self.ax_refs.get(&id)
346346
     }
347347
 
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
+
348364
     pub fn process_events(&mut self) {
349365
         let events: Vec<QueuedEvent> = self.event_queue.borrow_mut().drain(..).collect();
350366
         for queued in events {
@@ -363,10 +379,7 @@ impl WmState {
363379
             let ws_idx = monitor.active_workspace;
364380
             let ws = self.workspaces.get(ws_idx);
365381
             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);
370383
             tracing::debug!(monitor = mi, workspace = %ws.id, windows = geometries.len(),
371384
                 sr_x = screen_rect.x, sr_y = screen_rect.y, sr_w = screen_rect.width,
372385
                 sr_h = screen_rect.height, "apply_layout");
@@ -408,10 +421,7 @@ impl WmState {
408421
             let ws_idx = monitor.active_workspace;
409422
             let ws = self.workspaces.get(ws_idx);
410423
             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);
415425
             let focused = ws.focused;
416426
 
417427
             for (wid, rect) in &geoms {
@@ -494,8 +504,6 @@ impl WmState {
494504
                 .monitor_showing_workspace(ws_idx)
495505
                 .map(|mi| self.monitor_rect(mi))
496506
                 .unwrap_or_else(|| self.focused_rect());
497
-            let (gap_inner, gap_outer) = self.workspace_gaps(ws_idx);
498
-
499507
             // Phase 1: Swap oversized windows into larger tiles.
500508
             // Batch all swaps using BSP geometry (no AX calls), then apply layout
501509
             // once and settle. This is both faster (one layout instead of N) and
@@ -510,11 +518,7 @@ impl WmState {
510518
                     // Geometries are computed from the BSP tree (cheap, no AX).
511519
                     // After batched swaps, tree geometry reflects the new layout
512520
                     // 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);
518522
                     if geometries.is_empty() {
519523
                         break;
520524
                     }
@@ -578,15 +582,9 @@ impl WmState {
578582
                 // settling. The settled set prevents ping-pong.
579583
             }
580584
 
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.
584586
             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);
590588
                 if geometries.is_empty() {
591589
                     break;
592590
                 }
@@ -614,24 +612,20 @@ impl WmState {
614612
                     None => break,
615613
                 };
616614
 
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;
632628
                 }
633
-                self.apply_layout();
634
-                self.restack_floating_windows(ws_idx);
635629
             }
636630
 
637631
             processed.push(ws_idx);
@@ -668,13 +662,21 @@ impl WmState {
668662
         let ws = self.active_workspace();
669663
         let focused = ws.focused;
670664
         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
+        }
672676
 
673677
         // Try intra-workspace navigation first (only if we have a focused window)
674678
         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);
678680
 
679681
             // Check if focused window is at the monitor edge in the requested
680682
             // direction. If so, cross monitors instead of spiraling into the BSP tree.
@@ -720,10 +722,7 @@ impl WmState {
720722
         if let Some(new_mi) = new_mi {
721723
             self.focused_monitor = new_mi;
722724
             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);
727726
 
728727
             if let Some(wid) =
729728
                 Node::nearest_to_edge(&target_geoms, direction).or(self.active_workspace().focused)
@@ -762,10 +761,19 @@ impl WmState {
762761
             None => return,
763762
         };
764763
         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);
769777
 
770778
         // Check if the focused window touches the monitor edge in the
771779
         // requested direction. If so, skip intra-workspace swap and move
@@ -874,17 +882,22 @@ impl WmState {
874882
         // Record focus on the workspace that CONTAINS this window,
875883
         // not the active workspace — during cross-monitor FFM the active
876884
         // 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) {
878886
             let old = self.workspaces.get(ws_idx).focused;
879887
             self.workspaces.get_mut(ws_idx).raise_floating(id);
888
+            let stack_changed = self.workspaces.get_mut(ws_idx).tree.set_stack_active(id);
880889
             self.workspaces.get_mut(ws_idx).record_focus(id);
881
-            old
890
+            (old, stack_changed)
882891
         } else {
883892
             let old = self.active_workspace().focused;
884893
             self.active_workspace_mut().raise_floating(id);
894
+            let stack_changed = self.active_workspace_mut().tree.set_stack_active(id);
885895
             self.active_workspace_mut().record_focus(id);
886
-            old
896
+            (old, stack_changed)
887897
         };
898
+        if stack_focus_changed {
899
+            self.apply_layout();
900
+        }
888901
         self.enforce_floating_levels(id);
889902
 
890903
         // Update border colors on focus change
@@ -1169,20 +1182,17 @@ impl WmState {
11691182
                 floating_hit
11701183
             } else {
11711184
                 // 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);
11781188
                 geoms
11791189
                     .iter()
1190
+                    .rev()
11801191
                     .find(|(_, rect)| rect.contains_point(x, y))
11811192
                     .map(|(id, _)| *id)
11821193
             }
11831194
         } else {
11841195
             let ws = self.active_workspace();
1185
-            let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx());
11861196
 
11871197
             // Check floating windows first -- they're visually on top
11881198
             let floating_under = ws
@@ -1196,14 +1206,11 @@ impl WmState {
11961206
                 floating_under
11971207
             } else {
11981208
                 // 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());
12051211
                 geoms
12061212
                     .iter()
1213
+                    .rev()
12071214
                     .find(|(_, rect)| rect.contains_point(x, y))
12081215
                     .map(|(id, _)| *id)
12091216
             }
@@ -1252,9 +1259,25 @@ impl WmState {
12521259
         }
12531260
     }
12541261
 
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
+
12551279
     pub fn click_to_focus(&mut self, x: f64, y: f64) {
12561280
         let ws = self.active_workspace();
1257
-        let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx());
12581281
 
12591282
         // Check floating first
12601283
         let floating_hit = ws
@@ -1267,14 +1290,10 @@ impl WmState {
12671290
         let id = if let Some(fid) = floating_hit {
12681291
             Some(fid)
12691292
         } 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());
12761294
             geoms
12771295
                 .iter()
1296
+                .rev()
12781297
                 .find(|(_, rect)| rect.contains_point(x, y))
12791298
                 .map(|(id, _)| *id)
12801299
         };
@@ -1309,12 +1328,7 @@ impl WmState {
13091328
                 // Warp to the focused WINDOW center (not monitor center)
13101329
                 if self.mouse_follows_focus {
13111330
                     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);
13181332
                     if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) {
13191333
                         warp_mouse_to_center(rect);
13201334
                     } else {
@@ -1377,12 +1391,7 @@ impl WmState {
13771391
             self.focus_window(wid);
13781392
             if self.mouse_follows_focus {
13791393
                 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);
13861395
                 if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) {
13871396
                     warp_mouse_to_center(rect);
13881397
                 } else {
@@ -1802,11 +1811,7 @@ impl WmState {
18021811
             self.focus_window(wid);
18031812
             if self.mouse_follows_focus {
18041813
                 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);
18101815
                 if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) {
18111816
                     warp_mouse_to_center(rect);
18121817
                 } else {
@@ -1836,11 +1841,7 @@ impl WmState {
18361841
             self.focus_window(wid);
18371842
             if self.mouse_follows_focus {
18381843
                 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);
18441845
                 if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) {
18451846
                     warp_mouse_to_center(rect);
18461847
                 } else {
@@ -2244,7 +2245,7 @@ impl WmState {
22442245
     }
22452246
 
22462247
     /// 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.
22482249
     /// Only runs when the target workspace is visible — hidden workspaces have
22492250
     /// stale window sizes and will be checked when switched to.
22502251
     fn fix_oversized_on_target(&mut self, ws_idx: usize, moved_wid: super::window::WindowId) {
@@ -2257,16 +2258,7 @@ impl WmState {
22572258
         std::thread::sleep(std::time::Duration::from_millis(100));
22582259
 
22592260
         // 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);
22702262
 
22712263
         let (_min_w, _min_h) = match geometries.iter().find(|(wid, _)| *wid == moved_wid) {
22722264
             Some((_, rect)) => {
@@ -2286,16 +2278,7 @@ impl WmState {
22862278
         // Try swaps with settled-set logic (same as fix_oversized_windows phase 1)
22872279
         let mut settled: Vec<super::window::WindowId> = Vec::new();
22882280
         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);
22992282
             if geoms.is_empty() {
23002283
                 break;
23012284
             }
@@ -2341,27 +2324,20 @@ impl WmState {
23412324
                 self.apply_layout();
23422325
                 settled.push(ow);
23432326
             } 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;
23602340
                 }
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
23652341
             }
23662342
         }
23672343
     }
@@ -2748,6 +2724,7 @@ impl WmState {
27482724
                     // Record on the workspace that contains this window,
27492725
                     // not the active workspace (same fix as focus_window_impl)
27502726
                     if let Some(ws_idx) = self.workspaces.find_window(id) {
2727
+                        self.workspaces.get_mut(ws_idx).tree.set_stack_active(id);
27512728
                         self.workspaces.get_mut(ws_idx).record_focus(id);
27522729
                     }
27532730
                 }
@@ -2935,6 +2912,7 @@ fn hide_target_for_frame(
29352912
 mod tests {
29362913
     use super::*;
29372914
     use crate::core::monitor::Monitor;
2915
+    use crate::core::tree::Direction;
29382916
 
29392917
     #[test]
29402918
     fn hide_anchor_frame_defaults_without_monitors() {
@@ -3151,4 +3129,63 @@ mod tests {
31513129
         state.sync_workspace_visibility();
31523130
         assert!(!state.is_window_hidden(123));
31533131
     }
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
+    }
31543191
 }
tarmac/src/core/tree.rsmodified
@@ -61,7 +61,9 @@ impl Rect {
6161
     }
6262
 }
6363
 
64
-#[derive(Debug, Clone)]
64
+const STACK_REVEAL_OFFSET: f64 = 24.0;
65
+
66
+#[derive(Debug, Clone, PartialEq)]
6567
 pub enum Node {
6668
     Internal {
6769
         split: SplitDirection,
@@ -69,6 +71,11 @@ pub enum Node {
6971
         left: Box<Node>,
7072
         right: Box<Node>,
7173
     },
74
+    Stack {
75
+        windows: Vec<WindowId>,
76
+        active: usize,
77
+        previous: Box<Node>,
78
+    },
7279
     Leaf {
7380
         window: Option<WindowId>,
7481
     },
@@ -89,10 +96,20 @@ impl Node {
8996
         matches!(self, Node::Leaf { window: None })
9097
     }
9198
 
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
+
92108
     pub fn window_count(&self) -> usize {
93109
         match self {
94110
             Node::Leaf { window: Some(_) } => 1,
95111
             Node::Leaf { window: None } => 0,
112
+            Node::Stack { windows, .. } => windows.len(),
96113
             Node::Internal { left, right, .. } => left.window_count() + right.window_count(),
97114
         }
98115
     }
@@ -101,6 +118,7 @@ impl Node {
101118
         match self {
102119
             Node::Leaf { window: Some(w) } => vec![*w],
103120
             Node::Leaf { window: None } => vec![],
121
+            Node::Stack { windows, .. } => windows.clone(),
104122
             Node::Internal { left, right, .. } => {
105123
                 let mut ws = left.windows();
106124
                 ws.extend(right.windows());
@@ -113,6 +131,7 @@ impl Node {
113131
         match self {
114132
             Node::Leaf { window: Some(w) } => *w == window,
115133
             Node::Leaf { window: None } => false,
134
+            Node::Stack { windows, .. } => windows.contains(&window),
116135
             Node::Internal { left, right, .. } => left.contains(window) || right.contains(window),
117136
         }
118137
     }
@@ -120,6 +139,9 @@ impl Node {
120139
     pub fn first_window(&self) -> Option<WindowId> {
121140
         match self {
122141
             Node::Leaf { window } => *window,
142
+            Node::Stack {
143
+                windows, active, ..
144
+            } => windows.get(*active).copied().or_else(|| windows.first().copied()),
123145
             Node::Internal { left, right, .. } => {
124146
                 left.first_window().or_else(|| right.first_window())
125147
             }
@@ -185,6 +207,19 @@ impl Node {
185207
                     right.insert_with_rect(new_window, None, right_rect);
186208
                 }
187209
             }
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
+            }
188223
         }
189224
     }
190225
 
@@ -196,6 +231,30 @@ impl Node {
196231
                 true
197232
             }
198233
             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
+            }
199258
             Node::Internal { left, right, .. } => {
200259
                 if left.remove(window) {
201260
                     if left.is_empty() {
@@ -219,6 +278,10 @@ impl Node {
219278
         self.calculate_geometries_with_gaps(rect, 0.0, 0.0, true)
220279
     }
221280
 
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
+
222285
     /// Calculate geometries with inner and outer gaps.
223286
     /// `gap_outer` is applied to the root rect edges.
224287
     /// `gap_inner` is the space between adjacent windows (half applied to each side).
@@ -244,6 +307,35 @@ impl Node {
244307
         match self {
245308
             Node::Leaf { window: Some(w) } => vec![(*w, padded)],
246309
             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
+            }
247339
             Node::Internal {
248340
                 split,
249341
                 ratio,
@@ -279,6 +371,69 @@ impl Node {
279371
         }
280372
     }
281373
 
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
+
282437
     /// Equalize all split ratios to 0.5.
283438
     pub fn equalize(&mut self) {
284439
         if let Node::Internal {
@@ -311,6 +466,27 @@ impl Node {
311466
                 }
312467
             }
313468
             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
+            }
314490
             Node::Internal { left, right, .. } => {
315491
                 left.swap_impl(a, b, found_a, found_b);
316492
                 right.swap_impl(a, b, found_a, found_b);
@@ -451,7 +627,7 @@ impl Node {
451627
     /// Resize the split affecting a window in the given direction.
452628
     pub fn resize(&mut self, window: WindowId, direction: Direction, delta: f32) -> bool {
453629
         match self {
454
-            Node::Leaf { .. } => false,
630
+            Node::Leaf { .. } | Node::Stack { .. } => false,
455631
             Node::Internal {
456632
                 split,
457633
                 ratio,
@@ -496,6 +672,147 @@ impl Node {
496672
             }
497673
         }
498674
     }
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
+    }
499816
 }
500817
 
501818
 #[cfg(test)]
@@ -1076,4 +1393,60 @@ mod tests {
10761393
             );
10771394
         }
10781395
     }
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
+    }
10791452
 }
tarmac/src/main.rsmodified
@@ -243,6 +243,7 @@ fn handle_action(action: Action) {
243243
                     });
244244
                 }
245245
                 Action::ToggleFloat => state.toggle_float(),
246
+                Action::Unstack => state.unstack_focused(),
246247
                 Action::ToggleSpecial(ref name) => state.toggle_special(name),
247248
                 Action::MoveToSpecial(ref name) => state.move_to_special(name),
248249
                 Action::FocusMonitorNext => {
@@ -324,6 +325,7 @@ gar.bind("mod+return", "spawn_terminal")
324325
 gar.bind("mod+shift+q", "close")
325326
 gar.bind("mod+e", "equalize")
326327
 gar.bind("mod+shift+space", "toggle_float")
328
+gar.bind("mod+shift+u", "unstack")
327329
 
328330
 -- Focus
329331
 gar.bind("mod+h", "focus left")
@@ -495,6 +497,10 @@ fn process_ipc_command(
495497
                 state.equalize();
496498
                 Response::ok_empty()
497499
             }
500
+            "unstack" => {
501
+                state.unstack_focused();
502
+                Response::ok_empty()
503
+            }
498504
             "workspace" => {
499505
                 if let Some(target) = request
500506
                     .args
@@ -570,6 +576,17 @@ fn process_ipc_command(
570576
                         .map(|w| w.app_name.clone())
571577
                         .unwrap_or_default();
572578
                     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
+                        });
573590
 
574591
                     // Query live AX data for current title and geometry
575592
                     let (title, x, y, width, height) =
@@ -589,6 +606,7 @@ fn process_ipc_command(
589606
                         "x": x, "y": y,
590607
                         "width": width, "height": height,
591608
                         "floating": floating,
609
+                        "stack": stack,
592610
                         "workspace": state.active_workspace().id.to_string(),
593611
                     }))
594612
                 } else {
@@ -658,12 +676,10 @@ fn process_ipc_command(
658676
                     .monitor_showing_workspace(ws_idx)
659677
                     .map(|mi| state.monitor_rect(mi))
660678
                     .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);
667683
                 let windows: Vec<serde_json::Value> = geoms
668684
                     .iter()
669685
                     .map(|(wid, rect)| {
@@ -672,11 +688,22 @@ fn process_ipc_command(
672688
                             .get(*wid)
673689
                             .map(|w| w.app_name.clone())
674690
                             .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);
675699
                         serde_json::json!({
676700
                             "id": wid,
677701
                             "app_name": app_name,
678702
                             "x": rect.x, "y": rect.y,
679703
                             "width": rect.width, "height": rect.height,
704
+                            "stacked": stack.is_some(),
705
+                            "stack_index": stack_index,
706
+                            "stack_active": stack_active,
680707
                         })
681708
                     })
682709
                     .collect();
tarmacctl/src/main.rsmodified
@@ -38,6 +38,7 @@ fn main() {
3838
         eprintln!("  resize <left|right|up|down>   Resize split in direction");
3939
         eprintln!("  close                         Close focused window");
4040
         eprintln!("  equalize                      Equalize all splits");
41
+        eprintln!("  unstack                       Restore the focused stacked subtree");
4142
         eprintln!("  workspace <1-10>              Switch workspace");
4243
         eprintln!("  move-to-workspace <1-10>      Move window to workspace");
4344
         eprintln!("  toggle-floating               Toggle floating state");