@@ -209,6 +209,24 @@ impl WmState { |
| 209 | 209 | .position(|m| m.active_workspace == ws_idx) |
| 210 | 210 | } |
| 211 | 211 | |
| 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 | + |
| 212 | 230 | pub fn discover_and_observe(&mut self) { |
| 213 | 231 | // Discover all displays |
| 214 | 232 | let mut displays = crate::platform::display::discover_displays(); |
@@ -274,10 +292,10 @@ impl WmState { |
| 274 | 292 | for (mi, ws_idx) in monitor_assignments.into_iter().enumerate() { |
| 275 | 293 | let ws_idx = ws_idx.unwrap_or(0); |
| 276 | 294 | self.monitors[mi].active_workspace = ws_idx; |
| 277 | | - self.workspaces.get_mut(ws_idx).visible = true; |
| 278 | 295 | self.workspaces.get_mut(ws_idx).last_monitor = Some(mi); |
| 279 | 296 | self.workspaces.get_mut(ws_idx).last_display_id = Some(self.monitors[mi].id); |
| 280 | 297 | } |
| 298 | + self.sync_workspace_visibility(); |
| 281 | 299 | |
| 282 | 300 | tracing::info!( |
| 283 | 301 | monitors = self.monitors.len(), |
@@ -375,9 +393,8 @@ impl WmState { |
| 375 | 393 | "show floating" |
| 376 | 394 | ); |
| 377 | 395 | 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); |
| 380 | 396 | } |
| 397 | + self.restack_floating_windows(ws_idx); |
| 381 | 398 | } |
| 382 | 399 | } |
| 383 | 400 | |
@@ -467,9 +484,6 @@ impl WmState { |
| 467 | 484 | .collect(); |
| 468 | 485 | // Track workspaces we've already fully processed to avoid infinite loops. |
| 469 | 486 | 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 | | - |
| 473 | 487 | while let Some(ws_idx) = pending.pop() { |
| 474 | 488 | if processed.contains(&ws_idx) { |
| 475 | 489 | continue; |
@@ -480,6 +494,7 @@ impl WmState { |
| 480 | 494 | .monitor_showing_workspace(ws_idx) |
| 481 | 495 | .map(|mi| self.monitor_rect(mi)) |
| 482 | 496 | .unwrap_or_else(|| self.focused_rect()); |
| 497 | + let (gap_inner, gap_outer) = self.workspace_gaps(ws_idx); |
| 483 | 498 | |
| 484 | 499 | // Phase 1: Swap oversized windows into larger tiles. |
| 485 | 500 | // Batch all swaps using BSP geometry (no AX calls), then apply layout |
@@ -499,12 +514,7 @@ impl WmState { |
| 499 | 514 | .workspaces |
| 500 | 515 | .get(ws_idx) |
| 501 | 516 | .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); |
| 508 | 518 | if geometries.is_empty() { |
| 509 | 519 | break; |
| 510 | 520 | } |
@@ -568,21 +578,15 @@ impl WmState { |
| 568 | 578 | // settling. The settled set prevents ping-pong. |
| 569 | 579 | } |
| 570 | 580 | |
| 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. |
| 575 | 584 | loop { |
| 576 | 585 | let geometries = self |
| 577 | 586 | .workspaces |
| 578 | 587 | .get(ws_idx) |
| 579 | 588 | .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); |
| 586 | 590 | if geometries.is_empty() { |
| 587 | 591 | break; |
| 588 | 592 | } |
@@ -610,88 +614,24 @@ impl WmState { |
| 610 | 614 | None => break, |
| 611 | 615 | }; |
| 612 | 616 | |
| 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 | | - |
| 642 | 617 | tracing::info!( |
| 643 | 618 | id = oversized_wid, |
| 644 | 619 | min_w, |
| 645 | 620 | 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" |
| 649 | 623 | ); |
| 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); |
| 656 | 632 | } |
| 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"); |
| 669 | 633 | 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); |
| 695 | 635 | } |
| 696 | 636 | |
| 697 | 637 | processed.push(ws_idx); |
@@ -720,56 +660,6 @@ impl WmState { |
| 720 | 660 | } |
| 721 | 661 | } |
| 722 | 662 | |
| 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 | | - |
| 773 | 663 | // --- Window operations --- |
| 774 | 664 | |
| 775 | 665 | pub fn focus_direction(&mut self, direction: super::tree::Direction) { |
@@ -778,12 +668,13 @@ impl WmState { |
| 778 | 668 | let ws = self.active_workspace(); |
| 779 | 669 | let focused = ws.focused; |
| 780 | 670 | let sr = self.focused_rect(); |
| 671 | + let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); |
| 781 | 672 | |
| 782 | 673 | // Try intra-workspace navigation first (only if we have a focused window) |
| 783 | 674 | 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); |
| 787 | 678 | |
| 788 | 679 | // Check if focused window is at the monitor edge in the requested |
| 789 | 680 | // direction. If so, cross monitors instead of spiraling into the BSP tree. |
@@ -829,12 +720,10 @@ impl WmState { |
| 829 | 720 | if let Some(new_mi) = new_mi { |
| 830 | 721 | self.focused_monitor = new_mi; |
| 831 | 722 | 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); |
| 838 | 727 | |
| 839 | 728 | if let Some(wid) = |
| 840 | 729 | Node::nearest_to_edge(&target_geoms, direction).or(self.active_workspace().focused) |
@@ -873,9 +762,10 @@ impl WmState { |
| 873 | 762 | None => return, |
| 874 | 763 | }; |
| 875 | 764 | 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); |
| 879 | 769 | |
| 880 | 770 | // Check if the focused window touches the monitor edge in the |
| 881 | 771 | // requested direction. If so, skip intra-workspace swap and move |
@@ -986,14 +876,16 @@ impl WmState { |
| 986 | 876 | // workspace might be different from the window's workspace. |
| 987 | 877 | let old_focused = if let Some(ws_idx) = self.workspaces.find_window(id) { |
| 988 | 878 | let old = self.workspaces.get(ws_idx).focused; |
| 879 | + self.workspaces.get_mut(ws_idx).raise_floating(id); |
| 989 | 880 | self.workspaces.get_mut(ws_idx).record_focus(id); |
| 990 | 881 | old |
| 991 | 882 | } else { |
| 992 | 883 | let old = self.active_workspace().focused; |
| 884 | + self.active_workspace_mut().raise_floating(id); |
| 993 | 885 | self.active_workspace_mut().record_focus(id); |
| 994 | 886 | old |
| 995 | 887 | }; |
| 996 | | - self.enforce_floating_levels(); |
| 888 | + self.enforce_floating_levels(id); |
| 997 | 889 | |
| 998 | 890 | // Update border colors on focus change |
| 999 | 891 | if self.borders.is_enabled() && old_focused != Some(id) { |
@@ -1126,10 +1018,23 @@ impl WmState { |
| 1126 | 1018 | |
| 1127 | 1019 | /// Re-apply SkyLight window levels for all floating windows. |
| 1128 | 1020 | /// 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) { |
| 1130 | 1030 | 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 { |
| 1132 | 1034 | 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 | + } |
| 1133 | 1038 | } |
| 1134 | 1039 | } |
| 1135 | 1040 | |
@@ -1221,6 +1126,7 @@ impl WmState { |
| 1221 | 1126 | let window_under = if let Some(special_idx) = special_ws_idx { |
| 1222 | 1127 | let sr = self.monitor_rect(mi); |
| 1223 | 1128 | let ws = self.workspaces.get(special_idx); |
| 1129 | + let (gap_inner, gap_outer) = self.workspace_gaps(special_idx); |
| 1224 | 1130 | |
| 1225 | 1131 | // Look up config for this special workspace's overlay rect |
| 1226 | 1132 | let special_name = match &ws.id { |
@@ -1265,8 +1171,8 @@ impl WmState { |
| 1265 | 1171 | // Check tiled windows within the overlay rect |
| 1266 | 1172 | let geoms = ws.tree.calculate_geometries_with_gaps( |
| 1267 | 1173 | overlay_rect, |
| 1268 | | - self.gap_inner, |
| 1269 | | - self.gap_outer, |
| 1174 | + gap_inner, |
| 1175 | + gap_outer, |
| 1270 | 1176 | true, |
| 1271 | 1177 | ); |
| 1272 | 1178 | geoms |
@@ -1276,6 +1182,7 @@ impl WmState { |
| 1276 | 1182 | } |
| 1277 | 1183 | } else { |
| 1278 | 1184 | let ws = self.active_workspace(); |
| 1185 | + let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); |
| 1279 | 1186 | |
| 1280 | 1187 | // Check floating windows first -- they're visually on top |
| 1281 | 1188 | let floating_under = ws |
@@ -1291,8 +1198,8 @@ impl WmState { |
| 1291 | 1198 | // Check tiled windows using gap-aware geometry matching actual layout |
| 1292 | 1199 | let geoms = ws.tree.calculate_geometries_with_gaps( |
| 1293 | 1200 | self.focused_rect(), |
| 1294 | | - self.gap_inner, |
| 1295 | | - self.gap_outer, |
| 1201 | + gap_inner, |
| 1202 | + gap_outer, |
| 1296 | 1203 | true, |
| 1297 | 1204 | ); |
| 1298 | 1205 | geoms |
@@ -1334,7 +1241,7 @@ impl WmState { |
| 1334 | 1241 | // Set window level to floating so it stays above all normal windows |
| 1335 | 1242 | use crate::platform::skylight::{K_CG_FLOATING_WINDOW_LEVEL, set_window_level}; |
| 1336 | 1243 | set_window_level(focused, K_CG_FLOATING_WINDOW_LEVEL); |
| 1337 | | - self.enforce_floating_levels(); |
| 1244 | + self.enforce_floating_levels(focused); |
| 1338 | 1245 | tracing::info!(id = focused, "window floated (level=floating)"); |
| 1339 | 1246 | } else { |
| 1340 | 1247 | // Restore to normal window level |
@@ -1347,6 +1254,7 @@ impl WmState { |
| 1347 | 1254 | |
| 1348 | 1255 | pub fn click_to_focus(&mut self, x: f64, y: f64) { |
| 1349 | 1256 | let ws = self.active_workspace(); |
| 1257 | + let (gap_inner, gap_outer) = self.workspace_gaps(self.active_ws_idx()); |
| 1350 | 1258 | |
| 1351 | 1259 | // Check floating first |
| 1352 | 1260 | let floating_hit = ws |
@@ -1361,8 +1269,8 @@ impl WmState { |
| 1361 | 1269 | } else { |
| 1362 | 1270 | let geoms = ws.tree.calculate_geometries_with_gaps( |
| 1363 | 1271 | self.focused_rect(), |
| 1364 | | - self.gap_inner, |
| 1365 | | - self.gap_outer, |
| 1272 | + gap_inner, |
| 1273 | + gap_outer, |
| 1366 | 1274 | true, |
| 1367 | 1275 | ); |
| 1368 | 1276 | geoms |
@@ -1401,11 +1309,12 @@ impl WmState { |
| 1401 | 1309 | // Warp to the focused WINDOW center (not monitor center) |
| 1402 | 1310 | if self.mouse_follows_focus { |
| 1403 | 1311 | let sr = self.focused_rect(); |
| 1312 | + let (gap_inner, gap_outer) = self.workspace_gaps(target_idx); |
| 1404 | 1313 | let geoms = self |
| 1405 | 1314 | .workspaces |
| 1406 | 1315 | .get(target_idx) |
| 1407 | 1316 | .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); |
| 1409 | 1318 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1410 | 1319 | warp_mouse_to_center(rect); |
| 1411 | 1320 | } else { |
@@ -1444,7 +1353,6 @@ impl WmState { |
| 1444 | 1353 | // Hide the workspace currently on the target monitor |
| 1445 | 1354 | let displaced_idx = self.monitors[show_on_monitor].active_workspace; |
| 1446 | 1355 | self.hide_workspace_windows(displaced_idx, show_on_monitor); |
| 1447 | | - self.workspaces.get_mut(displaced_idx).visible = false; |
| 1448 | 1356 | // If the displaced workspace is empty, unassign it from the monitor |
| 1449 | 1357 | if self.workspaces.get(displaced_idx).is_empty() { |
| 1450 | 1358 | self.workspaces.get_mut(displaced_idx).last_monitor = None; |
@@ -1453,11 +1361,11 @@ impl WmState { |
| 1453 | 1361 | |
| 1454 | 1362 | // Show target workspace on the chosen monitor |
| 1455 | 1363 | self.monitors[show_on_monitor].active_workspace = target_idx; |
| 1456 | | - self.workspaces.get_mut(target_idx).visible = true; |
| 1457 | 1364 | self.workspaces.get_mut(target_idx).last_monitor = Some(show_on_monitor); |
| 1458 | 1365 | self.workspaces.get_mut(target_idx).last_display_id = |
| 1459 | 1366 | Some(self.monitors[show_on_monitor].id); |
| 1460 | 1367 | self.focused_monitor = show_on_monitor; |
| 1368 | + self.sync_workspace_visibility(); |
| 1461 | 1369 | |
| 1462 | 1370 | // Apply layout with double-apply for cross-monitor moves |
| 1463 | 1371 | self.apply_layout(); |
@@ -1469,11 +1377,12 @@ impl WmState { |
| 1469 | 1377 | self.focus_window(wid); |
| 1470 | 1378 | if self.mouse_follows_focus { |
| 1471 | 1379 | let sr = self.focused_rect(); |
| 1380 | + let (gap_inner, gap_outer) = self.workspace_gaps(target_idx); |
| 1472 | 1381 | let geoms = self |
| 1473 | 1382 | .workspaces |
| 1474 | 1383 | .get(target_idx) |
| 1475 | 1384 | .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); |
| 1477 | 1386 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1478 | 1387 | warp_mouse_to_center(rect); |
| 1479 | 1388 | } else { |
@@ -1597,7 +1506,7 @@ impl WmState { |
| 1597 | 1506 | |
| 1598 | 1507 | fn enforce_hidden_workspaces(&self) { |
| 1599 | 1508 | 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) { |
| 1601 | 1510 | continue; |
| 1602 | 1511 | } |
| 1603 | 1512 | for wid in self.workspaces.get(ws_idx).all_window_ids() { |
@@ -1627,7 +1536,7 @@ impl WmState { |
| 1627 | 1536 | pid = window.app_pid, |
| 1628 | 1537 | app = %window.app_name, |
| 1629 | 1538 | 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)), |
| 1631 | 1540 | group_ids = ?group_ids, |
| 1632 | 1541 | on_screen_count = on_screen.len(), |
| 1633 | 1542 | "hidden window diagnostics" |
@@ -1713,7 +1622,7 @@ impl WmState { |
| 1713 | 1622 | // Currently shown → hide |
| 1714 | 1623 | self.active_specials[mi] = None; |
| 1715 | 1624 | let wids = self.workspaces.get(special_idx).all_window_ids(); |
| 1716 | | - self.workspaces.get_mut(special_idx).visible = false; |
| 1625 | + self.sync_workspace_visibility(); |
| 1717 | 1626 | for wid in wids { |
| 1718 | 1627 | self.hide_window(wid); |
| 1719 | 1628 | } |
@@ -1726,7 +1635,8 @@ impl WmState { |
| 1726 | 1635 | // Dismiss any other active special on this monitor first |
| 1727 | 1636 | if let Some(old_idx) = self.active_specials[mi] { |
| 1728 | 1637 | 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(); |
| 1730 | 1640 | for wid in old_wids { |
| 1731 | 1641 | self.hide_window(wid); |
| 1732 | 1642 | } |
@@ -1736,9 +1646,9 @@ impl WmState { |
| 1736 | 1646 | self.active_specials[mi] = Some(special_idx); |
| 1737 | 1647 | { |
| 1738 | 1648 | let ws = self.workspaces.get_mut(special_idx); |
| 1739 | | - ws.visible = true; |
| 1740 | 1649 | ws.last_monitor = Some(mi); |
| 1741 | 1650 | } |
| 1651 | + self.sync_workspace_visibility(); |
| 1742 | 1652 | |
| 1743 | 1653 | let sr = self.monitor_rect(mi); |
| 1744 | 1654 | let ws = self.workspaces.get(special_idx); |
@@ -1773,12 +1683,10 @@ impl WmState { |
| 1773 | 1683 | let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_w, overlay_h); |
| 1774 | 1684 | |
| 1775 | 1685 | // 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); |
| 1782 | 1690 | let floating_data: Vec<(WindowId, Rect)> = |
| 1783 | 1691 | ws.floating.iter().map(|fw| (fw.id, fw.geometry)).collect(); |
| 1784 | 1692 | let focus_target = ws.focused.or_else(|| geoms.first().map(|(id, _)| *id)); |
@@ -1864,6 +1772,8 @@ impl WmState { |
| 1864 | 1772 | let is_visible = self.active_specials[self.focused_monitor] == Some(special_idx); |
| 1865 | 1773 | if !is_visible { |
| 1866 | 1774 | self.hide_window(focused); |
| 1775 | + } else { |
| 1776 | + self.restack_floating_windows(special_idx); |
| 1867 | 1777 | } |
| 1868 | 1778 | |
| 1869 | 1779 | // Retile the source workspace |
@@ -1892,12 +1802,11 @@ impl WmState { |
| 1892 | 1802 | self.focus_window(wid); |
| 1893 | 1803 | if self.mouse_follows_focus { |
| 1894 | 1804 | 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); |
| 1901 | 1810 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1902 | 1811 | warp_mouse_to_center(rect); |
| 1903 | 1812 | } else { |
@@ -1927,12 +1836,11 @@ impl WmState { |
| 1927 | 1836 | self.focus_window(wid); |
| 1928 | 1837 | if self.mouse_follows_focus { |
| 1929 | 1838 | 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); |
| 1936 | 1844 | if let Some((_, rect)) = geoms.iter().find(|(id, _)| *id == wid) { |
| 1937 | 1845 | warp_mouse_to_center(rect); |
| 1938 | 1846 | } else { |
@@ -1983,6 +1891,7 @@ impl WmState { |
| 1983 | 1891 | |
| 1984 | 1892 | // Get target monitor's screen rect for layout |
| 1985 | 1893 | let target_rect = self.monitor_rect(target_mi); |
| 1894 | + let (target_gap_inner, target_gap_outer) = self.workspace_gaps(target_ws_idx); |
| 1986 | 1895 | |
| 1987 | 1896 | // Find which workspace the window is actually on |
| 1988 | 1897 | let current_idx = match self.workspaces.find_window(focused) { |
@@ -2032,8 +1941,8 @@ impl WmState { |
| 2032 | 1941 | // Apply layout on target monitor using its screen rect |
| 2033 | 1942 | let geoms = target.tree.calculate_geometries_with_gaps( |
| 2034 | 1943 | target_rect, |
| 2035 | | - self.gap_inner, |
| 2036 | | - self.gap_outer, |
| 1944 | + target_gap_inner, |
| 1945 | + target_gap_outer, |
| 2037 | 1946 | true, |
| 2038 | 1947 | ); |
| 2039 | 1948 | for (wid, rect) in &geoms { |
@@ -2063,6 +1972,7 @@ impl WmState { |
| 2063 | 1972 | }); |
| 2064 | 1973 | |
| 2065 | 1974 | let old_count = self.monitors.len(); |
| 1975 | + let old_active_specials = self.active_specials.clone(); |
| 2066 | 1976 | let old_focused_display_id = self.monitors.get(self.focused_monitor).map(|m| m.id); |
| 2067 | 1977 | |
| 2068 | 1978 | // Collect old display IDs for orphan detection |
@@ -2103,7 +2013,6 @@ impl WmState { |
| 2103 | 2013 | ); |
| 2104 | 2014 | self.hide_workspace_windows_immediate(ws_idx); |
| 2105 | 2015 | let ws = self.workspaces.get_mut(ws_idx); |
| 2106 | | - ws.visible = false; |
| 2107 | 2016 | ws.last_monitor = None; |
| 2108 | 2017 | // Keep last_display_id intact for reconnection |
| 2109 | 2018 | } |
@@ -2126,7 +2035,7 @@ impl WmState { |
| 2126 | 2035 | let ws_idx = (0..self.workspaces.count()) |
| 2127 | 2036 | .find(|&i| { |
| 2128 | 2037 | !used_ws.get(i).copied().unwrap_or(false) |
| 2129 | | - && !self.workspaces.get(i).visible |
| 2038 | + && !self.workspace_is_effectively_visible(i) |
| 2130 | 2039 | && self.workspace_prefs(i).and_then(|prefs| { |
| 2131 | 2040 | prefs.monitor.as_ref().map(|monitor| monitor.display_id) |
| 2132 | 2041 | }) == Some(nd.id) |
@@ -2134,7 +2043,7 @@ impl WmState { |
| 2134 | 2043 | .or_else(|| { |
| 2135 | 2044 | (0..self.workspaces.count()).find(|&i| { |
| 2136 | 2045 | !used_ws.get(i).copied().unwrap_or(false) |
| 2137 | | - && !self.workspaces.get(i).visible |
| 2046 | + && !self.workspace_is_effectively_visible(i) |
| 2138 | 2047 | && self.workspaces.get(i).last_display_id == Some(nd.id) |
| 2139 | 2048 | }) |
| 2140 | 2049 | }) |
@@ -2142,7 +2051,7 @@ impl WmState { |
| 2142 | 2051 | // Next preference: first hidden workspace assigned to no specific monitor. |
| 2143 | 2052 | (0..self.workspaces.count()).find(|&i| { |
| 2144 | 2053 | !used_ws.get(i).copied().unwrap_or(false) |
| 2145 | | - && !self.workspaces.get(i).visible |
| 2054 | + && !self.workspace_is_effectively_visible(i) |
| 2146 | 2055 | && self |
| 2147 | 2056 | .workspace_prefs(i) |
| 2148 | 2057 | .and_then(|prefs| { |
@@ -2153,7 +2062,8 @@ impl WmState { |
| 2153 | 2062 | }) |
| 2154 | 2063 | .or_else(|| { |
| 2155 | 2064 | (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) |
| 2157 | 2067 | }) |
| 2158 | 2068 | }) |
| 2159 | 2069 | .unwrap_or_else(|| { |
@@ -2171,7 +2081,6 @@ impl WmState { |
| 2171 | 2081 | m.active_workspace = ws_idx; |
| 2172 | 2082 | |
| 2173 | 2083 | let ws = self.workspaces.get_mut(ws_idx); |
| 2174 | | - ws.visible = true; |
| 2175 | 2084 | ws.last_monitor = Some(new_idx); |
| 2176 | 2085 | ws.last_display_id = Some(nd.id); |
| 2177 | 2086 | |
@@ -2184,7 +2093,19 @@ impl WmState { |
| 2184 | 2093 | } |
| 2185 | 2094 | |
| 2186 | 2095 | // --- 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 | + |
| 2187 | 2106 | self.monitors = new_monitors; |
| 2107 | + self.active_specials = remapped_specials; |
| 2108 | + self.sync_workspace_visibility(); |
| 2188 | 2109 | |
| 2189 | 2110 | // Map old focused_monitor through display ID → new index |
| 2190 | 2111 | if let Some(old_did) = old_focused_display_id { |
@@ -2248,7 +2169,7 @@ impl WmState { |
| 2248 | 2169 | { |
| 2249 | 2170 | // Dismiss the special workspace since we're taking its window |
| 2250 | 2171 | self.active_specials[mi] = None; |
| 2251 | | - self.workspaces.get_mut(special_idx).visible = false; |
| 2172 | + self.sync_workspace_visibility(); |
| 2252 | 2173 | // Hide any remaining windows on the special workspace |
| 2253 | 2174 | let remaining = self.workspaces.get(special_idx).all_window_ids(); |
| 2254 | 2175 | for wid in remaining { |
@@ -2301,7 +2222,7 @@ impl WmState { |
| 2301 | 2222 | target_ws.record_focus(focused); |
| 2302 | 2223 | |
| 2303 | 2224 | // 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) { |
| 2305 | 2226 | self.hide_window(focused); |
| 2306 | 2227 | } |
| 2307 | 2228 | |
@@ -2340,7 +2261,12 @@ impl WmState { |
| 2340 | 2261 | .workspaces |
| 2341 | 2262 | .get(ws_idx) |
| 2342 | 2263 | .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 | + ); |
| 2344 | 2270 | |
| 2345 | 2271 | let (_min_w, _min_h) = match geometries.iter().find(|(wid, _)| *wid == moved_wid) { |
| 2346 | 2272 | Some((_, rect)) => { |
@@ -2364,7 +2290,12 @@ impl WmState { |
| 2364 | 2290 | .workspaces |
| 2365 | 2291 | .get(ws_idx) |
| 2366 | 2292 | .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 | + ); |
| 2368 | 2299 | if geoms.is_empty() { |
| 2369 | 2300 | break; |
| 2370 | 2301 | } |
@@ -2575,7 +2506,7 @@ impl WmState { |
| 2575 | 2506 | pub fn is_window_hidden(&self, wid: u32) -> bool { |
| 2576 | 2507 | let id = wid as WindowId; |
| 2577 | 2508 | 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) |
| 2579 | 2510 | } else { |
| 2580 | 2511 | false |
| 2581 | 2512 | } |
@@ -3163,4 +3094,61 @@ mod tests { |
| 3163 | 3094 | assert_eq!(hide_x, -979.0); |
| 3164 | 3095 | assert_eq!(hide_y, 6107.0); |
| 3165 | 3096 | } |
| 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 | + } |
| 3166 | 3154 | } |