gardesk/tarmac / ae35f6c

Browse files

Stabilize workspace visibility and floating order

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ae35f6c9130a460a3a63b4d6c94b9cb4c20a3f65
Parents
b736bef
Tree
fecca57

3 changed files

StatusFile+-
M tarmac/src/core/state.rs 197 209
M tarmac/src/core/workspace.rs 10 0
M tarmac/src/platform/observer.rs 1 0
tarmac/src/core/state.rsmodified
@@ -209,6 +209,24 @@ impl WmState {
209209
             .position(|m| m.active_workspace == ws_idx)
210210
     }
211211
 
212
+    fn special_showing_workspace(&self, ws_idx: usize) -> bool {
213
+        self.active_specials
214
+            .iter()
215
+            .flatten()
216
+            .any(|&idx| idx == ws_idx)
217
+    }
218
+
219
+    fn workspace_is_effectively_visible(&self, ws_idx: usize) -> bool {
220
+        self.monitor_showing_workspace(ws_idx).is_some() || self.special_showing_workspace(ws_idx)
221
+    }
222
+
223
+    fn sync_workspace_visibility(&mut self) {
224
+        for ws_idx in 0..self.workspaces.count() {
225
+            let visible = self.workspace_is_effectively_visible(ws_idx);
226
+            self.workspaces.get_mut(ws_idx).visible = visible;
227
+        }
228
+    }
229
+
212230
     pub fn discover_and_observe(&mut self) {
213231
         // Discover all displays
214232
         let mut displays = crate::platform::display::discover_displays();
@@ -274,10 +292,10 @@ impl WmState {
274292
         for (mi, ws_idx) in monitor_assignments.into_iter().enumerate() {
275293
             let ws_idx = ws_idx.unwrap_or(0);
276294
             self.monitors[mi].active_workspace = ws_idx;
277
-            self.workspaces.get_mut(ws_idx).visible = true;
278295
             self.workspaces.get_mut(ws_idx).last_monitor = Some(mi);
279296
             self.workspaces.get_mut(ws_idx).last_display_id = Some(self.monitors[mi].id);
280297
         }
298
+        self.sync_workspace_visibility();
281299
 
282300
         tracing::info!(
283301
             monitors = self.monitors.len(),
@@ -375,9 +393,8 @@ impl WmState {
375393
                     "show floating"
376394
                 );
377395
                 self.show_window(fw.id, fw.geometry);
378
-                use crate::platform::skylight::{K_CG_FLOATING_WINDOW_LEVEL, set_window_level};
379
-                set_window_level(fw.id, K_CG_FLOATING_WINDOW_LEVEL);
380396
             }
397
+            self.restack_floating_windows(ws_idx);
381398
         }
382399
     }
383400
 
@@ -467,9 +484,6 @@ impl WmState {
467484
             .collect();
468485
         // Track workspaces we've already fully processed to avoid infinite loops.
469486
         let mut processed: Vec<usize> = Vec::new();
470
-        // Track the first overflow workspace we created (for wrap-around).
471
-        let mut first_overflow_ws: Option<usize> = None;
472
-
473487
         while let Some(ws_idx) = pending.pop() {
474488
             if processed.contains(&ws_idx) {
475489
                 continue;
@@ -480,6 +494,7 @@ impl WmState {
480494
                 .monitor_showing_workspace(ws_idx)
481495
                 .map(|mi| self.monitor_rect(mi))
482496
                 .unwrap_or_else(|| self.focused_rect());
497
+            let (gap_inner, gap_outer) = self.workspace_gaps(ws_idx);
483498
 
484499
             // Phase 1: Swap oversized windows into larger tiles.
485500
             // Batch all swaps using BSP geometry (no AX calls), then apply layout
@@ -499,12 +514,7 @@ impl WmState {
499514
                         .workspaces
500515
                         .get(ws_idx)
501516
                         .tree
502
-                        .calculate_geometries_with_gaps(
503
-                            screen_rect,
504
-                            self.gap_inner,
505
-                            self.gap_outer,
506
-                            true,
507
-                        );
517
+                        .calculate_geometries_with_gaps(screen_rect, gap_inner, gap_outer, true);
508518
                     if geometries.is_empty() {
509519
                         break;
510520
                     }
@@ -568,21 +578,15 @@ impl WmState {
568578
                 // settling. The settled set prevents ping-pong.
569579
             }
570580
 
571
-            // Phase 2: Evict remaining oversized windows that couldn't be fixed by swaps.
572
-            // Try all workspaces in order (including ones with existing windows).
573
-            // The recursive pass will handle any overflow on the target workspace.
574
-            let mut evict_search_from = ws_idx + 1;
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.
575584
             loop {
576585
                 let geometries = self
577586
                     .workspaces
578587
                     .get(ws_idx)
579588
                     .tree
580
-                    .calculate_geometries_with_gaps(
581
-                        screen_rect,
582
-                        self.gap_inner,
583
-                        self.gap_outer,
584
-                        true,
585
-                    );
589
+                    .calculate_geometries_with_gaps(screen_rect, gap_inner, gap_outer, true);
586590
                 if geometries.is_empty() {
587591
                     break;
588592
                 }
@@ -610,88 +614,24 @@ impl WmState {
610614
                     None => break,
611615
                 };
612616
 
613
-                // Find next non-visible workspace to evict to.
614
-                // Only evict to non-visible workspaces — visible ones would
615
-                // need immediate overflow checking with potentially stale sizes.
616
-                let next_ws = (evict_search_from..self.workspaces.count())
617
-                    .find(|&i| i != ws_idx && !self.workspaces.get(i).visible);
618
-                let next_ws = match next_ws {
619
-                    Some(ws) => ws,
620
-                    None => {
621
-                        // All workspaces visible or exhausted — create one
622
-                        let idx = self.workspaces.count();
623
-                        self.workspaces.get_or_create(idx);
624
-                        idx
625
-                    }
626
-                };
627
-                evict_search_from = next_ws + 1;
628
-
629
-                // Check if we've wrapped all the way around.
630
-                let hit_limit = first_overflow_ws
631
-                    .is_some_and(|first| processed.contains(&next_ws) || next_ws == first);
632
-
633
-                if hit_limit {
634
-                    self.float_oversized_on_workspace(ws_idx, screen_rect);
635
-                    break;
636
-                }
637
-
638
-                if first_overflow_ws.is_none() {
639
-                    first_overflow_ws = Some(next_ws);
640
-                }
641
-
642617
                 tracing::info!(
643618
                     id = oversized_wid,
644619
                     min_w,
645620
                     min_h,
646
-                    from_ws = ws_idx + 1,
647
-                    to_ws = next_ws + 1,
648
-                    "evicting oversized window to next workspace"
621
+                    ws = ws_idx + 1,
622
+                    "floating oversized window locally"
649623
                 );
650
-
651
-                // Remove from current workspace
652
-                let ws = self.workspaces.get_mut(ws_idx);
653
-                ws.tree.remove(oversized_wid);
654
-                if ws.focused == Some(oversized_wid) {
655
-                    ws.pop_focus();
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);
656632
                 }
657
-                ws.focus_history.retain(|id| *id != oversized_wid);
658
-
659
-                // Hide and re-layout the SOURCE workspace (without the evicted window).
660
-                // Do this BEFORE inserting into the target so apply_layout
661
-                // can't accidentally restore alpha=1.0 on the evicted window.
662
-                let source_monitor = self.monitor_showing_workspace(ws_idx).unwrap_or(
663
-                    self.focused_monitor
664
-                        .min(self.monitors.len().saturating_sub(1)),
665
-                );
666
-                let source_frame = self.monitors[source_monitor].frame;
667
-                self.hide_window_on_frame(oversized_wid, source_frame, Some(source_monitor));
668
-                self.log_hidden_window_diagnostics(oversized_wid, "post-evict-hide");
669633
                 self.apply_layout();
670
-
671
-                // Now insert into target workspace (already hidden)
672
-                let target_rect = self
673
-                    .monitor_showing_workspace(next_ws)
674
-                    .map(|tmi| self.monitor_rect(tmi))
675
-                    .unwrap_or(screen_rect);
676
-                let target_ws = self.workspaces.get_or_create(next_ws);
677
-                target_ws.last_monitor = Some(source_monitor);
678
-                target_ws.last_display_id = Some(self.monitors[source_monitor].id);
679
-                target_ws
680
-                    .tree
681
-                    .insert_with_rect(oversized_wid, target_ws.focused, target_rect);
682
-                target_ws.record_focus(oversized_wid);
683
-
684
-                // Only queue VISIBLE target workspaces for overflow checking.
685
-                // Non-visible workspaces have stale ax_get_size values and will
686
-                // be checked when the user switches to them (switch_workspace
687
-                // calls fix_oversized_windows). This prevents runaway eviction
688
-                // chains caused by stale sizes.
689
-                if self.monitor_showing_workspace(next_ws).is_some()
690
-                    && !processed.contains(&next_ws)
691
-                    && !pending.contains(&next_ws)
692
-                {
693
-                    pending.push(next_ws);
694
-                }
634
+                self.restack_floating_windows(ws_idx);
695635
             }
696636
 
697637
             processed.push(ws_idx);
@@ -720,56 +660,6 @@ impl WmState {
720660
         }
721661
     }
722662
 
723
-    /// Float all oversized windows on a workspace as an absolute last resort.
724
-    /// Only called when every workspace has been tried and overflow persists.
725
-    fn float_oversized_on_workspace(&mut self, ws_idx: usize, screen_rect: Rect) {
726
-        loop {
727
-            let geometries = self
728
-                .workspaces
729
-                .get(ws_idx)
730
-                .tree
731
-                .calculate_geometries_with_gaps(screen_rect, self.gap_inner, self.gap_outer, true);
732
-            if geometries.is_empty() {
733
-                break;
734
-            }
735
-
736
-            let oversized = geometries.iter().find_map(|(wid, rect)| {
737
-                let ax_ref = self.ax_refs.get(wid)?;
738
-                let (aw, ah) = ax_get_size(ax_ref).ok()?;
739
-                if aw > rect.width + 1.0 || ah > rect.height + 1.0 {
740
-                    Some((*wid, aw, ah))
741
-                } else {
742
-                    None
743
-                }
744
-            });
745
-
746
-            let (oversized_wid, min_w, min_h) = match oversized {
747
-                Some(v) => v,
748
-                None => break,
749
-            };
750
-
751
-            tracing::warn!(
752
-                id = oversized_wid,
753
-                min_w,
754
-                min_h,
755
-                ws = ws_idx + 1,
756
-                "last-resort float: all workspaces exhausted"
757
-            );
758
-            self.workspaces
759
-                .get_mut(ws_idx)
760
-                .toggle_float(oversized_wid, screen_rect);
761
-            if let Some(ax_ref) = self.ax_refs.get(&oversized_wid) {
762
-                let fx = screen_rect.x + (screen_rect.width - min_w) / 2.0;
763
-                let fy = screen_rect.y + (screen_rect.height - min_h) / 2.0;
764
-                let _ = ax_set_position(ax_ref, fx, fy);
765
-                let _ = ax_set_size(ax_ref, min_w, min_h);
766
-            }
767
-            use crate::platform::skylight::{K_CG_FLOATING_WINDOW_LEVEL, set_window_level};
768
-            set_window_level(oversized_wid, K_CG_FLOATING_WINDOW_LEVEL);
769
-            self.apply_layout();
770
-        }
771
-    }
772
-
773663
     // --- Window operations ---
774664
 
775665
     pub fn focus_direction(&mut self, direction: super::tree::Direction) {
@@ -778,12 +668,13 @@ impl WmState {
778668
         let ws = self.active_workspace();
779669
         let focused = ws.focused;
780670
         let sr = self.focused_rect();
671
+        let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx());
781672
 
782673
         // Try intra-workspace navigation first (only if we have a focused window)
783674
         if let Some(from) = focused {
784
-            let geoms =
785
-                ws.tree
786
-                    .calculate_geometries_with_gaps(sr, self.gap_inner, self.gap_outer, true);
675
+            let geoms = ws
676
+                .tree
677
+                .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true);
787678
 
788679
             // Check if focused window is at the monitor edge in the requested
789680
             // direction. If so, cross monitors instead of spiraling into the BSP tree.
@@ -829,12 +720,10 @@ impl WmState {
829720
         if let Some(new_mi) = new_mi {
830721
             self.focused_monitor = new_mi;
831722
             let target_sr = self.focused_rect();
832
-            let target_geoms = self.active_workspace().tree.calculate_geometries_with_gaps(
833
-                target_sr,
834
-                self.gap_inner,
835
-                self.gap_outer,
836
-                true,
837
-            );
723
+            let target_geoms = self
724
+                .active_workspace()
725
+                .tree
726
+                .calculate_geometries_with_gaps(target_sr, gap_inner, gap_outer, true);
838727
 
839728
             if let Some(wid) =
840729
                 Node::nearest_to_edge(&target_geoms, direction).or(self.active_workspace().focused)
@@ -873,9 +762,10 @@ impl WmState {
873762
             None => return,
874763
         };
875764
         let sr = self.focused_rect();
876
-        let geoms =
877
-            ws.tree
878
-                .calculate_geometries_with_gaps(sr, self.gap_inner, self.gap_outer, true);
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);
879769
 
880770
         // Check if the focused window touches the monitor edge in the
881771
         // requested direction. If so, skip intra-workspace swap and move
@@ -986,14 +876,16 @@ impl WmState {
986876
         // workspace might be different from the window's workspace.
987877
         let old_focused = if let Some(ws_idx) = self.workspaces.find_window(id) {
988878
             let old = self.workspaces.get(ws_idx).focused;
879
+            self.workspaces.get_mut(ws_idx).raise_floating(id);
989880
             self.workspaces.get_mut(ws_idx).record_focus(id);
990881
             old
991882
         } else {
992883
             let old = self.active_workspace().focused;
884
+            self.active_workspace_mut().raise_floating(id);
993885
             self.active_workspace_mut().record_focus(id);
994886
             old
995887
         };
996
-        self.enforce_floating_levels();
888
+        self.enforce_floating_levels(id);
997889
 
998890
         // Update border colors on focus change
999891
         if self.borders.is_enabled() && old_focused != Some(id) {
@@ -1126,10 +1018,23 @@ impl WmState {
11261018
 
11271019
     /// Re-apply SkyLight window levels for all floating windows.
11281020
     /// Called after every focus change since app activation can reset ordering.
1129
-    fn enforce_floating_levels(&self) {
1021
+    fn enforce_floating_levels(&self, focused_id: WindowId) {
1022
+        if let Some(ws_idx) = self.workspaces.find_window(focused_id) {
1023
+            self.restack_floating_windows(ws_idx);
1024
+        } else {
1025
+            self.restack_floating_windows(self.active_ws_idx());
1026
+        }
1027
+    }
1028
+
1029
+    fn restack_floating_windows(&self, ws_idx: usize) {
11301030
         use crate::platform::skylight::{K_CG_FLOATING_WINDOW_LEVEL, set_window_level};
1131
-        for fw in &self.active_workspace().floating {
1031
+
1032
+        let ws = self.workspaces.get(ws_idx);
1033
+        for fw in &ws.floating {
11321034
             set_window_level(fw.id, K_CG_FLOATING_WINDOW_LEVEL);
1035
+            if let Some(ax_ref) = self.ax_refs.get(&fw.id) {
1036
+                let _ = ax_perform_action(ax_ref, "AXRaise");
1037
+            }
11331038
         }
11341039
     }
11351040
 
@@ -1221,6 +1126,7 @@ impl WmState {
12211126
         let window_under = if let Some(special_idx) = special_ws_idx {
12221127
             let sr = self.monitor_rect(mi);
12231128
             let ws = self.workspaces.get(special_idx);
1129
+            let (gap_inner, gap_outer) = self.workspace_gaps(special_idx);
12241130
 
12251131
             // Look up config for this special workspace's overlay rect
12261132
             let special_name = match &ws.id {
@@ -1265,8 +1171,8 @@ impl WmState {
12651171
                 // Check tiled windows within the overlay rect
12661172
                 let geoms = ws.tree.calculate_geometries_with_gaps(
12671173
                     overlay_rect,
1268
-                    self.gap_inner,
1269
-                    self.gap_outer,
1174
+                    gap_inner,
1175
+                    gap_outer,
12701176
                     true,
12711177
                 );
12721178
                 geoms
@@ -1276,6 +1182,7 @@ impl WmState {
12761182
             }
12771183
         } else {
12781184
             let ws = self.active_workspace();
1185
+            let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx());
12791186
 
12801187
             // Check floating windows first -- they're visually on top
12811188
             let floating_under = ws
@@ -1291,8 +1198,8 @@ impl WmState {
12911198
                 // Check tiled windows using gap-aware geometry matching actual layout
12921199
                 let geoms = ws.tree.calculate_geometries_with_gaps(
12931200
                     self.focused_rect(),
1294
-                    self.gap_inner,
1295
-                    self.gap_outer,
1201
+                    gap_inner,
1202
+                    gap_outer,
12961203
                     true,
12971204
                 );
12981205
                 geoms
@@ -1334,7 +1241,7 @@ impl WmState {
13341241
                 // Set window level to floating so it stays above all normal windows
13351242
                 use crate::platform::skylight::{K_CG_FLOATING_WINDOW_LEVEL, set_window_level};
13361243
                 set_window_level(focused, K_CG_FLOATING_WINDOW_LEVEL);
1337
-                self.enforce_floating_levels();
1244
+                self.enforce_floating_levels(focused);
13381245
                 tracing::info!(id = focused, "window floated (level=floating)");
13391246
             } else {
13401247
                 // Restore to normal window level
@@ -1347,6 +1254,7 @@ impl WmState {
13471254
 
13481255
     pub fn click_to_focus(&mut self, x: f64, y: f64) {
13491256
         let ws = self.active_workspace();
1257
+        let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx());
13501258
 
13511259
         // Check floating first
13521260
         let floating_hit = ws
@@ -1361,8 +1269,8 @@ impl WmState {
13611269
         } else {
13621270
             let geoms = ws.tree.calculate_geometries_with_gaps(
13631271
                 self.focused_rect(),
1364
-                self.gap_inner,
1365
-                self.gap_outer,
1272
+                gap_inner,
1273
+                gap_outer,
13661274
                 true,
13671275
             );
13681276
             geoms
@@ -1401,11 +1309,12 @@ impl WmState {
14011309
                 // Warp to the focused WINDOW center (not monitor center)
14021310
                 if self.mouse_follows_focus {
14031311
                     let sr = self.focused_rect();
1312
+                    let (gap_inner, gap_outer) = self.workspace_gaps(target_idx);
14041313
                     let geoms = self
14051314
                         .workspaces
14061315
                         .get(target_idx)
14071316
                         .tree
1408
-                        .calculate_geometries_with_gaps(sr, self.gap_inner, self.gap_outer, true);
1317
+                        .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true);
14091318
                     if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) {
14101319
                         warp_mouse_to_center(rect);
14111320
                     } else {
@@ -1444,7 +1353,6 @@ impl WmState {
14441353
         // Hide the workspace currently on the target monitor
14451354
         let displaced_idx = self.monitors[show_on_monitor].active_workspace;
14461355
         self.hide_workspace_windows(displaced_idx, show_on_monitor);
1447
-        self.workspaces.get_mut(displaced_idx).visible = false;
14481356
         // If the displaced workspace is empty, unassign it from the monitor
14491357
         if self.workspaces.get(displaced_idx).is_empty() {
14501358
             self.workspaces.get_mut(displaced_idx).last_monitor = None;
@@ -1453,11 +1361,11 @@ impl WmState {
14531361
 
14541362
         // Show target workspace on the chosen monitor
14551363
         self.monitors[show_on_monitor].active_workspace = target_idx;
1456
-        self.workspaces.get_mut(target_idx).visible = true;
14571364
         self.workspaces.get_mut(target_idx).last_monitor = Some(show_on_monitor);
14581365
         self.workspaces.get_mut(target_idx).last_display_id =
14591366
             Some(self.monitors[show_on_monitor].id);
14601367
         self.focused_monitor = show_on_monitor;
1368
+        self.sync_workspace_visibility();
14611369
 
14621370
         // Apply layout with double-apply for cross-monitor moves
14631371
         self.apply_layout();
@@ -1469,11 +1377,12 @@ impl WmState {
14691377
             self.focus_window(wid);
14701378
             if self.mouse_follows_focus {
14711379
                 let sr = self.focused_rect();
1380
+                let (gap_inner, gap_outer) = self.workspace_gaps(target_idx);
14721381
                 let geoms = self
14731382
                     .workspaces
14741383
                     .get(target_idx)
14751384
                     .tree
1476
-                    .calculate_geometries_with_gaps(sr, self.gap_inner, self.gap_outer, true);
1385
+                    .calculate_geometries_with_gaps(sr, gap_inner, gap_outer, true);
14771386
                 if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) {
14781387
                     warp_mouse_to_center(rect);
14791388
                 } else {
@@ -1597,7 +1506,7 @@ impl WmState {
15971506
 
15981507
     fn enforce_hidden_workspaces(&self) {
15991508
         for ws_idx in 0..self.workspaces.count() {
1600
-            if self.workspaces.get(ws_idx).visible {
1509
+            if self.workspace_is_effectively_visible(ws_idx) {
16011510
                 continue;
16021511
             }
16031512
             for wid in self.workspaces.get(ws_idx).all_window_ids() {
@@ -1627,7 +1536,7 @@ impl WmState {
16271536
             pid = window.app_pid,
16281537
             app = %window.app_name,
16291538
             workspace = ws_idx.map(|idx| idx + 1),
1630
-            workspace_visible = ws_idx.map(|idx| self.workspaces.get(idx).visible),
1539
+            workspace_visible = ws_idx.map(|idx| self.workspace_is_effectively_visible(idx)),
16311540
             group_ids = ?group_ids,
16321541
             on_screen_count = on_screen.len(),
16331542
             "hidden window diagnostics"
@@ -1713,7 +1622,7 @@ impl WmState {
17131622
             // Currently shown → hide
17141623
             self.active_specials[mi] = None;
17151624
             let wids = self.workspaces.get(special_idx).all_window_ids();
1716
-            self.workspaces.get_mut(special_idx).visible = false;
1625
+            self.sync_workspace_visibility();
17171626
             for wid in wids {
17181627
                 self.hide_window(wid);
17191628
             }
@@ -1726,7 +1635,8 @@ impl WmState {
17261635
             // Dismiss any other active special on this monitor first
17271636
             if let Some(old_idx) = self.active_specials[mi] {
17281637
                 let old_wids = self.workspaces.get(old_idx).all_window_ids();
1729
-                self.workspaces.get_mut(old_idx).visible = false;
1638
+                self.active_specials[mi] = None;
1639
+                self.sync_workspace_visibility();
17301640
                 for wid in old_wids {
17311641
                     self.hide_window(wid);
17321642
                 }
@@ -1736,9 +1646,9 @@ impl WmState {
17361646
             self.active_specials[mi] = Some(special_idx);
17371647
             {
17381648
                 let ws = self.workspaces.get_mut(special_idx);
1739
-                ws.visible = true;
17401649
                 ws.last_monitor = Some(mi);
17411650
             }
1651
+            self.sync_workspace_visibility();
17421652
 
17431653
             let sr = self.monitor_rect(mi);
17441654
             let ws = self.workspaces.get(special_idx);
@@ -1773,12 +1683,10 @@ impl WmState {
17731683
             let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_w, overlay_h);
17741684
 
17751685
             // Compute geometries and collect data before calling self methods
1776
-            let geoms = ws.tree.calculate_geometries_with_gaps(
1777
-                overlay_rect,
1778
-                self.gap_inner,
1779
-                self.gap_outer,
1780
-                true,
1781
-            );
1686
+            let (gap_inner, gap_outer) = self.workspace_gaps(special_idx);
1687
+            let geoms =
1688
+                ws.tree
1689
+                    .calculate_geometries_with_gaps(overlay_rect, gap_inner, gap_outer, true);
17821690
             let floating_data: Vec<(WindowId, Rect)> =
17831691
                 ws.floating.iter().map(|fw| (fw.id, fw.geometry)).collect();
17841692
             let focus_target = ws.focused.or_else(|| geoms.first().map(|(id, _)| *id));
@@ -1864,6 +1772,8 @@ impl WmState {
18641772
         let is_visible = self.active_specials[self.focused_monitor] == Some(special_idx);
18651773
         if !is_visible {
18661774
             self.hide_window(focused);
1775
+        } else {
1776
+            self.restack_floating_windows(special_idx);
18671777
         }
18681778
 
18691779
         // Retile the source workspace
@@ -1892,12 +1802,11 @@ impl WmState {
18921802
             self.focus_window(wid);
18931803
             if self.mouse_follows_focus {
18941804
                 let sr = self.focused_rect();
1895
-                let geoms = self.active_workspace().tree.calculate_geometries_with_gaps(
1896
-                    sr,
1897
-                    self.gap_inner,
1898
-                    self.gap_outer,
1899
-                    true,
1900
-                );
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);
19011810
                 if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) {
19021811
                     warp_mouse_to_center(rect);
19031812
                 } else {
@@ -1927,12 +1836,11 @@ impl WmState {
19271836
             self.focus_window(wid);
19281837
             if self.mouse_follows_focus {
19291838
                 let sr = self.focused_rect();
1930
-                let geoms = self.active_workspace().tree.calculate_geometries_with_gaps(
1931
-                    sr,
1932
-                    self.gap_inner,
1933
-                    self.gap_outer,
1934
-                    true,
1935
-                );
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);
19361844
                 if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) {
19371845
                     warp_mouse_to_center(rect);
19381846
                 } else {
@@ -1983,6 +1891,7 @@ impl WmState {
19831891
 
19841892
         // Get target monitor's screen rect for layout
19851893
         let target_rect = self.monitor_rect(target_mi);
1894
+        let (target_gap_inner, target_gap_outer) = self.workspace_gaps(target_ws_idx);
19861895
 
19871896
         // Find which workspace the window is actually on
19881897
         let current_idx = match self.workspaces.find_window(focused) {
@@ -2032,8 +1941,8 @@ impl WmState {
20321941
         // Apply layout on target monitor using its screen rect
20331942
         let geoms = target.tree.calculate_geometries_with_gaps(
20341943
             target_rect,
2035
-            self.gap_inner,
2036
-            self.gap_outer,
1944
+            target_gap_inner,
1945
+            target_gap_outer,
20371946
             true,
20381947
         );
20391948
         for (wid, rect) in &geoms {
@@ -2063,6 +1972,7 @@ impl WmState {
20631972
         });
20641973
 
20651974
         let old_count = self.monitors.len();
1975
+        let old_active_specials = self.active_specials.clone();
20661976
         let old_focused_display_id = self.monitors.get(self.focused_monitor).map(|m| m.id);
20671977
 
20681978
         // Collect old display IDs for orphan detection
@@ -2103,7 +2013,6 @@ impl WmState {
21032013
                 );
21042014
                 self.hide_workspace_windows_immediate(ws_idx);
21052015
                 let ws = self.workspaces.get_mut(ws_idx);
2106
-                ws.visible = false;
21072016
                 ws.last_monitor = None;
21082017
                 // Keep last_display_id intact for reconnection
21092018
             }
@@ -2126,7 +2035,7 @@ impl WmState {
21262035
             let ws_idx = (0..self.workspaces.count())
21272036
                 .find(|&i| {
21282037
                     !used_ws.get(i).copied().unwrap_or(false)
2129
-                        && !self.workspaces.get(i).visible
2038
+                        && !self.workspace_is_effectively_visible(i)
21302039
                         && self.workspace_prefs(i).and_then(|prefs| {
21312040
                             prefs.monitor.as_ref().map(|monitor| monitor.display_id)
21322041
                         }) == Some(nd.id)
@@ -2134,7 +2043,7 @@ impl WmState {
21342043
                 .or_else(|| {
21352044
                     (0..self.workspaces.count()).find(|&i| {
21362045
                         !used_ws.get(i).copied().unwrap_or(false)
2137
-                            && !self.workspaces.get(i).visible
2046
+                            && !self.workspace_is_effectively_visible(i)
21382047
                             && self.workspaces.get(i).last_display_id == Some(nd.id)
21392048
                     })
21402049
                 })
@@ -2142,7 +2051,7 @@ impl WmState {
21422051
                     // Next preference: first hidden workspace assigned to no specific monitor.
21432052
                     (0..self.workspaces.count()).find(|&i| {
21442053
                         !used_ws.get(i).copied().unwrap_or(false)
2145
-                            && !self.workspaces.get(i).visible
2054
+                            && !self.workspace_is_effectively_visible(i)
21462055
                             && self
21472056
                                 .workspace_prefs(i)
21482057
                                 .and_then(|prefs| {
@@ -2153,7 +2062,8 @@ impl WmState {
21532062
                 })
21542063
                 .or_else(|| {
21552064
                     (0..self.workspaces.count()).find(|&i| {
2156
-                        !used_ws.get(i).copied().unwrap_or(false) && !self.workspaces.get(i).visible
2065
+                        !used_ws.get(i).copied().unwrap_or(false)
2066
+                            && !self.workspace_is_effectively_visible(i)
21572067
                     })
21582068
                 })
21592069
                 .unwrap_or_else(|| {
@@ -2171,7 +2081,6 @@ impl WmState {
21712081
             m.active_workspace = ws_idx;
21722082
 
21732083
             let ws = self.workspaces.get_mut(ws_idx);
2174
-            ws.visible = true;
21752084
             ws.last_monitor = Some(new_idx);
21762085
             ws.last_display_id = Some(nd.id);
21772086
 
@@ -2184,7 +2093,19 @@ impl WmState {
21842093
         }
21852094
 
21862095
         // --- Phase 4: Replace monitors, recover focus ---
2096
+        let remapped_specials: Vec<Option<usize>> = new_monitors
2097
+            .iter()
2098
+            .map(|monitor| {
2099
+                self.monitors
2100
+                    .iter()
2101
+                    .position(|old_monitor| old_monitor.id == monitor.id)
2102
+                    .and_then(|old_idx| old_active_specials.get(old_idx).copied().flatten())
2103
+            })
2104
+            .collect();
2105
+
21872106
         self.monitors = new_monitors;
2107
+        self.active_specials = remapped_specials;
2108
+        self.sync_workspace_visibility();
21882109
 
21892110
         // Map old focused_monitor through display ID → new index
21902111
         if let Some(old_did) = old_focused_display_id {
@@ -2248,7 +2169,7 @@ impl WmState {
22482169
         {
22492170
             // Dismiss the special workspace since we're taking its window
22502171
             self.active_specials[mi] = None;
2251
-            self.workspaces.get_mut(special_idx).visible = false;
2172
+            self.sync_workspace_visibility();
22522173
             // Hide any remaining windows on the special workspace
22532174
             let remaining = self.workspaces.get(special_idx).all_window_ids();
22542175
             for wid in remaining {
@@ -2301,7 +2222,7 @@ impl WmState {
23012222
         target_ws.record_focus(focused);
23022223
 
23032224
         // If target is not visible, hide the window
2304
-        if !self.workspaces.get(target_idx).visible {
2225
+        if !self.workspace_is_effectively_visible(target_idx) {
23052226
             self.hide_window(focused);
23062227
         }
23072228
 
@@ -2340,7 +2261,12 @@ impl WmState {
23402261
             .workspaces
23412262
             .get(ws_idx)
23422263
             .tree
2343
-            .calculate_geometries_with_gaps(screen_rect, self.gap_inner, self.gap_outer, true);
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
+            );
23442270
 
23452271
         let (_min_w, _min_h) = match geometries.iter().find(|(wid, _)| *wid == moved_wid) {
23462272
             Some((_, rect)) => {
@@ -2364,7 +2290,12 @@ impl WmState {
23642290
                 .workspaces
23652291
                 .get(ws_idx)
23662292
                 .tree
2367
-                .calculate_geometries_with_gaps(screen_rect, self.gap_inner, self.gap_outer, true);
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
+                );
23682299
             if geoms.is_empty() {
23692300
                 break;
23702301
             }
@@ -2575,7 +2506,7 @@ impl WmState {
25752506
     pub fn is_window_hidden(&self, wid: u32) -> bool {
25762507
         let id = wid as WindowId;
25772508
         if let Some(ws_idx) = self.workspaces.find_window(id) {
2578
-            !self.workspaces.get(ws_idx).visible
2509
+            !self.workspace_is_effectively_visible(ws_idx)
25792510
         } else {
25802511
             false
25812512
         }
@@ -3163,4 +3094,61 @@ mod tests {
31633094
         assert_eq!(hide_x, -979.0);
31643095
         assert_eq!(hide_y, 6107.0);
31653096
     }
3097
+
3098
+    #[test]
3099
+    fn special_workspace_counts_as_effectively_visible() {
3100
+        let mut state = WmState::new();
3101
+        state.monitors = vec![Monitor {
3102
+            id: 42,
3103
+            frame: Rect::new(0.0, 0.0, 1710.0, 1107.0),
3104
+            usable_frame: Rect::new(0.0, 33.0, 1710.0, 1074.0),
3105
+            is_primary: true,
3106
+            active_workspace: 0,
3107
+        }];
3108
+        let special_idx = state.workspaces.special_index("scratch");
3109
+        state.active_specials = vec![Some(special_idx)];
3110
+        state.sync_workspace_visibility();
3111
+
3112
+        assert!(state.workspace_is_effectively_visible(special_idx));
3113
+        assert!(state.workspaces.get(special_idx).visible);
3114
+    }
3115
+
3116
+    #[test]
3117
+    fn is_window_hidden_uses_effective_visibility_not_cached_flag() {
3118
+        let mut state = WmState::new();
3119
+        state.monitors = vec![Monitor {
3120
+            id: 42,
3121
+            frame: Rect::new(0.0, 0.0, 1710.0, 1107.0),
3122
+            usable_frame: Rect::new(0.0, 33.0, 1710.0, 1074.0),
3123
+            is_primary: true,
3124
+            active_workspace: 0,
3125
+        }];
3126
+        state.registry.add(WindowState {
3127
+            id: 123,
3128
+            app_pid: 1,
3129
+            app_name: "Test".to_string(),
3130
+            app_bundle_id: "test.bundle".to_string(),
3131
+            title: String::new(),
3132
+            role: "AXWindow".to_string(),
3133
+            subrole: "AXStandardWindow".to_string(),
3134
+            x: 0.0,
3135
+            y: 0.0,
3136
+            width: 100.0,
3137
+            height: 100.0,
3138
+            floating: false,
3139
+            minimized: false,
3140
+        });
3141
+        state.workspaces.get_mut(1).tree.insert_with_rect(
3142
+            123,
3143
+            None,
3144
+            state.monitors[0].usable_frame,
3145
+        );
3146
+        state.workspaces.get_mut(1).visible = true; // Stale cached state.
3147
+
3148
+        assert!(state.is_window_hidden(123));
3149
+
3150
+        state.monitors[0].active_workspace = 1;
3151
+        state.sync_workspace_visibility();
3152
+        assert!(!state.is_window_hidden(123));
3153
+    }
31663154
 }
tarmac/src/core/workspace.rsmodified
@@ -237,6 +237,16 @@ impl Workspace {
237237
         self.focused = Some(window_id);
238238
     }
239239
 
240
+    pub fn raise_floating(&mut self, id: WindowId) -> bool {
241
+        if let Some(idx) = self.floating.iter().position(|f| f.id == id) {
242
+            let floating = self.floating.remove(idx);
243
+            self.floating.push(floating);
244
+            true
245
+        } else {
246
+            false
247
+        }
248
+    }
249
+
240250
     /// Pop the most recent focus and return the new focused window.
241251
     pub fn pop_focus(&mut self) -> Option<WindowId> {
242252
         self.focus_history.pop();
tarmac/src/platform/observer.rsmodified
@@ -93,6 +93,7 @@ impl AppObserver {
9393
         // Subscribe to all notification types on the app element
9494
         let notifications = [
9595
             AX_WINDOW_CREATED,
96
+            AX_UI_ELEMENT_DESTROYED,
9697
             AX_FOCUSED_WINDOW_CHANGED,
9798
             AX_WINDOW_MOVED,
9899
             AX_WINDOW_RESIZED,