diff --git a/src/actor/app.rs b/src/actor/app.rs index 94ce2955..aebadc1d 100644 --- a/src/actor/app.rs +++ b/src/actor/app.rs @@ -457,11 +457,6 @@ impl State { return Ok(false); } - // If we don't know this window, nothing to verify. - if !self.windows.contains_key(&wid) { - return Ok(false); - } - // Trigger a visible windows refresh. If the window is gone, the reactor // will detect it via missing membership and tear down state. *request = Request::GetVisibleWindows; diff --git a/src/actor/reactor.rs b/src/actor/reactor.rs index 61af9a6c..7210d398 100644 --- a/src/actor/reactor.rs +++ b/src/actor/reactor.rs @@ -9,6 +9,7 @@ mod display_topology; mod events; mod main_window; mod managers; +mod native_tabs; mod query; mod replay; pub mod transaction_manager; @@ -65,8 +66,8 @@ type Receiver = actor::Receiver; pub use query::ReactorQueryHandle; pub(crate) use crate::model::reactor::{ - AppState, FullscreenSpaceTrack, FullscreenWindowTrack, PendingSpaceChange, WindowFilter, - WindowState, + AppState, FullscreenSpaceTrack, FullscreenWindowTrack, NativeTabMembership, NativeTabRole, + PendingSpaceChange, WindowFilter, WindowState, }; pub use crate::model::reactor::{ Command, DisplaySelector, DragSession, DragState, MenuState, MissionControlState, @@ -238,6 +239,7 @@ pub struct Reactor { app_manager: managers::AppManager, layout_manager: managers::LayoutManager, window_manager: managers::WindowManager, + native_tab_manager: managers::NativeTabManager, window_server_info_manager: managers::WindowServerInfoManager, space_manager: managers::SpaceManager, space_activation_policy: SpaceActivationPolicy, @@ -318,6 +320,7 @@ impl Reactor { visible_windows: HashSet::default(), observed_window_server_ids: HashSet::default(), }, + native_tab_manager: managers::NativeTabManager::new(), window_server_info_manager: managers::WindowServerInfoManager { window_server_info: HashMap::default(), }, @@ -972,6 +975,9 @@ impl Reactor { self.set_login_window_active(false); } } + Event::ApplicationMainWindowChanged(pid, wid, quiet) => { + self.handle_native_tab_main_window_changed(pid, wid, quiet); + } Event::ResyncAppForWindow(wsid) => { AppEventHandler::handle_resync_app_for_window(self, wsid); } @@ -1682,13 +1688,6 @@ impl Reactor { self.screen_for_point(window_center).map(|_| window_center) } - fn has_visible_window_server_ids_for_pid(&self, pid: pid_t) -> bool { - self.window_manager - .visible_windows - .iter() - .any(|wsid| self.window_manager.window_ids.get(wsid).is_some_and(|wid| wid.pid == pid)) - } - fn warp_mouse_to_space_center(&self, space: SpaceId) -> bool { let Some(screen) = self.space_manager.screen_by_space(space) else { return false; @@ -1935,8 +1934,8 @@ impl Reactor { window.ignore_app_rule = false; } - let effective_floating = assignment.floating - || (!assignment.prev_rule_decision && was_floating); + let effective_floating = + assignment.floating || (!assignment.prev_rule_decision && was_floating); let needs_layout_refresh = !was_assigned || was_floating != effective_floating || was_ignored; if needs_layout_refresh { @@ -2728,6 +2727,10 @@ impl Reactor { self.maybe_send_menu_update(); } + fn refresh_all_windows_without_pending_refresh(&mut self) { + self.request_visible_windows_for_apps(false); + } + fn force_refresh_all_windows(&mut self) { self.request_visible_windows_for_apps(true); } fn request_close_window(&mut self, wid: WindowId) { diff --git a/src/actor/reactor/animation.rs b/src/actor/reactor/animation.rs index d6686e8a..bcfaff13 100644 --- a/src/actor/reactor/animation.rs +++ b/src/actor/reactor/animation.rs @@ -163,6 +163,7 @@ impl AnimationManager { let mut animated_count = 0; let mut animated_wids_wsids: Vec = Vec::new(); let mut any_frame_changed = false; + let mut synced_native_tab_wids = Vec::new(); for &(wid, target_frame) in layout { // Skip applying layout frames and animations for the window currently being dragged. @@ -182,8 +183,16 @@ impl AnimationManager { if target_frame.same_as(current_frame) { continue; } + let Some(wsid) = window.info.sys_id else { + trace!( + ?wid, + ?current_frame, + ?target_frame, + "Skipping animation for window without window server id" + ); + continue; + }; any_frame_changed = true; - let wsid = window.info.sys_id.unwrap(); let txid = reactor.transaction_manager.generate_next_txid(wsid); (current_frame, Some(wsid), txid) } @@ -193,7 +202,7 @@ impl AnimationManager { } }; - let Some(app_state) = &reactor.app_manager.apps.get(&wid.pid) else { + let Some(app_state) = reactor.app_manager.apps.get(&wid.pid) else { debug!(?wid, "Skipping for window - app no longer exists"); continue; }; @@ -234,6 +243,7 @@ impl AnimationManager { if let Some(window) = reactor.window_manager.windows.get_mut(&wid) { window.frame_monotonic = target_frame; } + synced_native_tab_wids.push(wid); } if animated_count > 0 { @@ -250,6 +260,9 @@ impl AnimationManager { anim.run(); } } + for wid in synced_native_tab_wids { + reactor.handle_native_tab_frame_changed(wid, true); + } any_frame_changed } @@ -278,6 +291,15 @@ impl AnimationManager { if target_frame.same_as(current_frame) { continue; } + if window.info.sys_id.is_none() { + trace!( + ?wid, + ?current_frame, + ?target_frame, + "Skipping instant layout for window without window server id" + ); + continue; + } any_frame_changed = true; trace!( ?wid, @@ -339,9 +361,59 @@ impl AnimationManager { if let Some(window) = reactor.window_manager.windows.get_mut(wid) { window.frame_monotonic = *target_frame; } + reactor.handle_native_tab_frame_changed(*wid, true); } } any_frame_changed } } + +#[cfg(test)] +mod tests { + use objc2_core_foundation::{CGPoint, CGRect, CGSize}; + + use super::AnimationManager; + use crate::actor::reactor::testing::{Apps, make_window, screen_params_event}; + use crate::actor::reactor::{Reactor, WindowId}; + use crate::layout_engine::LayoutEngine; + use crate::sys::screen::SpaceId; + + #[test] + fn layout_application_skips_windows_without_window_server_ids() { + let mut apps = Apps::new(); + let mut reactor = Reactor::new_for_test(LayoutEngine::new( + &crate::common::config::VirtualWorkspaceSettings::default(), + &crate::common::config::LayoutSettings::default(), + None, + )); + let space = SpaceId::new(90); + reactor.handle_event(screen_params_event( + vec![CGRect::new(CGPoint::new(0., 0.), CGSize::new(1000., 1000.))], + vec![Some(space)], + vec![], + )); + + reactor.handle_events(apps.make_app(1, vec![make_window(1)])); + apps.simulate_until_quiet(&mut reactor); + let _ = apps.requests(); + + let wid = WindowId::new(1, 1); + let target = CGRect::new(CGPoint::new(300., 50.), CGSize::new(400., 700.)); + reactor.window_manager.windows.get_mut(&wid).unwrap().info.sys_id = None; + + assert!(!AnimationManager::animate_layout( + &mut reactor, + space, + &[(wid, target)], + false, + None, + )); + assert!(!AnimationManager::instant_layout( + &mut reactor, + &[(wid, target)], + None, + )); + assert!(apps.requests().is_empty()); + } +} diff --git a/src/actor/reactor/events/app.rs b/src/actor/reactor/events/app.rs index fa74e08b..ac02ab90 100644 --- a/src/actor/reactor/events/app.rs +++ b/src/actor/reactor/events/app.rs @@ -33,6 +33,7 @@ impl AppEventHandler { } pub fn handle_application_thread_terminated(reactor: &mut Reactor, pid: i32) { + reactor.handle_native_tab_app_terminated(pid); reactor.app_manager.apps.remove(&pid); reactor.send_layout_event(LayoutEvent::AppClosed(pid)); } diff --git a/src/actor/reactor/events/command.rs b/src/actor/reactor/events/command.rs index 57e3c953..3e5fbe0c 100644 --- a/src/actor/reactor/events/command.rs +++ b/src/actor/reactor/events/command.rs @@ -493,6 +493,7 @@ impl CommandEventHandler { if let Some(state) = reactor.window_manager.windows.get_mut(&window_id) { state.frame_monotonic = target_frame; } + reactor.handle_native_tab_frame_changed(window_id, true); let response = reactor.layout_manager.layout_engine.move_window_to_space( source_space, diff --git a/src/actor/reactor/events/drag.rs b/src/actor/reactor/events/drag.rs index fda54ea2..93a395e4 100644 --- a/src/actor/reactor/events/drag.rs +++ b/src/actor/reactor/events/drag.rs @@ -8,6 +8,7 @@ pub struct DragEventHandler; impl DragEventHandler { pub fn handle_mouse_up(reactor: &mut Reactor) { let mut need_layout_refresh = false; + let dragged_wid = reactor.drag_manager.dragged(); let pending_swap = reactor.get_pending_drag_swap(); @@ -71,5 +72,8 @@ impl DragEventHandler { } reactor.drag_manager.skip_layout_for_window = None; + if let Some(wid) = dragged_wid { + reactor.handle_native_tab_frame_changed(wid, true); + } } } diff --git a/src/actor/reactor/events/space.rs b/src/actor/reactor/events/space.rs index cdceb71f..67eb5132 100644 --- a/src/actor/reactor/events/space.rs +++ b/src/actor/reactor/events/space.rs @@ -63,6 +63,16 @@ impl SpaceEventHandler { return; } + if reactor.stage_native_tab_destroy(wsid, sid) { + if let Some(&wid) = reactor.window_manager.window_ids.get(&wsid) + && let Some(app_state) = reactor.app_manager.apps.get(&wid.pid) + && let Err(e) = app_state.handle.send(Request::WindowMaybeDestroyed(wid)) + { + warn!("Failed to send WindowMaybeDestroyed: {}", e); + } + return; + } + if let Some(&wid) = reactor.window_manager.window_ids.get(&wsid) { reactor.window_manager.window_ids.remove(&wsid); reactor.window_server_info_manager.window_server_info.remove(&wsid); @@ -90,9 +100,17 @@ impl SpaceEventHandler { wsid: WindowServerId, sid: SpaceId, ) { - if reactor.window_server_info_manager.window_server_info.contains_key(&wsid) - || reactor.window_manager.observed_window_server_ids.contains(&wsid) + let known_before = + reactor.window_server_info_manager.window_server_info.contains_key(&wsid) + || reactor.window_manager.observed_window_server_ids.contains(&wsid); + let appearance_info = crate::sys::window_server::get_window(wsid); + if let Some(window_server_info) = appearance_info + && reactor.note_native_tab_appearance(wsid, sid, window_server_info) { + return; + } + + if known_before { debug!( ?wsid, "Received WindowServerAppeared for known window - ignoring" @@ -103,7 +121,7 @@ impl SpaceEventHandler { reactor.window_manager.observed_window_server_ids.insert(wsid); // TODO: figure out why this is happening, we should really know about this app, // why dont we get notifications that its being launched? - if let Some(window_server_info) = crate::sys::window_server::get_window(wsid) { + if let Some(window_server_info) = appearance_info { if window_server_info.layer != 0 { trace!( ?wsid, diff --git a/src/actor/reactor/events/system.rs b/src/actor/reactor/events/system.rs index 5191d903..165b2078 100644 --- a/src/actor/reactor/events/system.rs +++ b/src/actor/reactor/events/system.rs @@ -1,9 +1,10 @@ -use tracing::debug; +use tracing::{debug, warn}; -use crate::actor::app::WindowId; +use crate::actor::app::{Request, WindowId}; use crate::actor::raise_manager; use crate::actor::reactor::{MenuState, Reactor}; use crate::actor::wm_controller::Sender as WmSender; +use crate::common::collections::HashMap; pub struct SystemEventHandler; @@ -60,6 +61,36 @@ impl SystemEventHandler { reactor.window_manager.window_ids.keys().map(|wsid| wsid.as_u32()).collect(); crate::sys::window_notify::update_window_notifications(&ids); reactor.notification_manager.last_sls_notification_ids = ids; + + // Sleep/wake can interrupt both deferred native-tab destroys and ordinary + // close/removal cleanup, leaving stale tracked slots in layout for still-running + // apps like Finder. Re-probe one tracked window per pid so the app actor emits a + // current visible-window snapshot even when the app itself remains alive. + let mut probe_windows_by_pid: HashMap = reactor + .window_manager + .windows + .keys() + .copied() + .map(|wid| (wid.pid, wid)) + .collect(); + for pending in reactor.pending_native_tab_destroys() { + probe_windows_by_pid.entry(pending.window_id.pid).or_insert(pending.window_id); + } + + for window_id in probe_windows_by_pid.into_values() { + let Some(app) = reactor.app_manager.apps.get(&window_id.pid) else { + continue; + }; + if let Err(err) = app.handle.send(Request::WindowMaybeDestroyed(window_id)) { + warn!( + pid = window_id.pid, + wid = ?window_id, + ?err, + "Failed to verify tracked windows after wake" + ); + } + } + reactor.refresh_all_windows_without_pending_refresh(); } pub fn handle_raise_completed(reactor: &mut Reactor, window_id: WindowId, sequence_id: u64) { diff --git a/src/actor/reactor/events/window.rs b/src/actor/reactor/events/window.rs index 15ca8ad7..9398abcc 100644 --- a/src/actor/reactor/events/window.rs +++ b/src/actor/reactor/events/window.rs @@ -33,8 +33,31 @@ impl WindowEventHandler { reactor.window_server_info_manager.window_server_info.insert(info.id, info); } - let frame = window.frame; + let mut frame = window.frame; + let existing_native_tab = reactor + .window_manager + .windows + .get(&wid) + .and_then(|existing| existing.native_tab); + let existing_ignore_app_rule = reactor + .window_manager + .windows + .get(&wid) + .is_some_and(|existing| existing.ignore_app_rule); + let existing_frame = reactor + .window_manager + .windows + .get(&wid) + .map(|existing| existing.frame_monotonic); let mut window_state: WindowState = window.into(); + window_state.native_tab = existing_native_tab; + window_state.ignore_app_rule = existing_ignore_app_rule; + if existing_native_tab.is_some() { + if let Some(existing_frame) = existing_frame { + window_state.frame_monotonic = existing_frame; + frame = existing_frame; + } + } let is_manageable = utils::compute_window_manageability( window_state.info.sys_id, window_state.info.is_minimized, @@ -54,6 +77,10 @@ impl WindowEventHandler { let server_id = window_state.info.sys_id; reactor.window_manager.windows.insert(wid, window_state); + if reactor.maybe_hold_native_tab_window_created(wid) { + return; + } + if is_manageable { let active_space = active_space_for_window(reactor, &frame, server_id); if let Some(space) = active_space { @@ -75,6 +102,10 @@ impl WindowEventHandler { } pub fn handle_window_destroyed(reactor: &mut Reactor, wid: WindowId) -> bool { + if reactor.defer_native_tab_window_destroy(wid) { + return false; + } + reactor.finalize_native_tab_window_destroy(wid); let window_server_id = match reactor.window_manager.windows.get(&wid) { Some(window) => window.info.sys_id, None => return false, @@ -208,6 +239,7 @@ impl WindowEventHandler { let pending_target = server_id.and_then(|wsid| { reactor.transaction_manager.get_target_frame(wsid).map(|target| (wsid, target)) }); + let pending_native_tab_target = reactor.native_tab_manager.pending_frame_target(wid); let last_sent_txid = server_id .map(|wsid| reactor.transaction_manager.get_last_sent_txid(wsid)) @@ -221,6 +253,7 @@ impl WindowEventHandler { if let Some((wsid, _)) = pending_target { reactor.transaction_manager.clear_target_for_window(wsid); } + reactor.native_tab_manager.clear_pending_frame_target(wid); triggered_by_rift = false; has_pending_request = false; } @@ -230,7 +263,36 @@ impl WindowEventHandler { return false; } + if let Some(target) = pending_native_tab_target + && last_seen.is_none() + && !requested.0 + && effective_mouse_state != Some(MouseState::Down) + { + if new_frame.same_as(target) { + if let Some(window) = reactor.window_manager.windows.get_mut(&wid) + && !window.frame_monotonic.same_as(new_frame) + { + debug!(?wid, ?new_frame, "Final native-tab frame matched pending target"); + window.frame_monotonic = new_frame; + } + if let Some((wsid, _)) = pending_target { + reactor.transaction_manager.clear_target_for_window(wsid); + } + reactor.native_tab_manager.clear_pending_frame_target(wid); + } else { + trace!( + ?wid, + ?new_frame, + ?target, + "Ignoring stale native-tab frame change while pending requested frame" + ); + reactor.retry_pending_native_tab_frame_target(wid); + } + return false; + } + if triggered_by_rift { + let mut should_sync_native_tabs = false; let Some(window) = reactor.window_manager.windows.get_mut(&wid) else { return false; }; @@ -242,6 +304,8 @@ impl WindowEventHandler { window.frame_monotonic = new_frame; } reactor.transaction_manager.clear_target_for_window(wsid); + reactor.native_tab_manager.clear_pending_frame_target(wid); + should_sync_native_tabs = true; } else { trace!( ?wid, @@ -260,12 +324,18 @@ impl WindowEventHandler { if let Some(wsid) = window.info.sys_id { reactor.transaction_manager.clear_target_for_window(wsid); } + reactor.native_tab_manager.clear_pending_frame_target(wid); + should_sync_native_tabs = true; } + if should_sync_native_tabs { + reactor.handle_native_tab_frame_changed(wid, true); + } return false; } if requested.0 { + let mut should_sync_native_tabs = false; if let Some(window) = reactor.window_manager.windows.get_mut(&wid) { if !window.frame_monotonic.same_as(new_frame) { debug!( @@ -275,10 +345,15 @@ impl WindowEventHandler { ); window.frame_monotonic = new_frame; } + should_sync_native_tabs = true; } if let Some(wsid) = server_id { reactor.transaction_manager.clear_target_for_window(wsid); } + reactor.native_tab_manager.clear_pending_frame_target(wid); + if should_sync_native_tabs { + reactor.handle_native_tab_frame_changed(wid, true); + } return false; } @@ -300,8 +375,8 @@ impl WindowEventHandler { } window.frame_monotonic = new_frame; } - let dragging = effective_mouse_state == Some(MouseState::Down) || reactor.is_in_drag(); + reactor.handle_native_tab_frame_changed(wid, !dragging); if !dragging { reactor.drag_manager.skip_layout_for_window = Some(wid); diff --git a/src/actor/reactor/events/window_discovery.rs b/src/actor/reactor/events/window_discovery.rs index f7350ed5..345851c3 100644 --- a/src/actor/reactor/events/window_discovery.rs +++ b/src/actor/reactor/events/window_discovery.rs @@ -1,6 +1,6 @@ use tracing::{trace, warn}; -use crate::actor::app::{AppInfo, WindowId, WindowInfo, pid_t}; +use crate::actor::app::{AppInfo, Request, WindowId, WindowInfo, pid_t}; use crate::actor::reactor::{Event, LayoutEvent, Reactor, WindowFilter, WindowState, utils}; use crate::common::collections::{BTreeMap, HashSet}; use crate::model::virtual_workspace::AppRuleResult; @@ -26,11 +26,46 @@ impl WindowDiscoveryHandler { let app_info = app_info.or_else(|| reactor.app_manager.apps.get(&pid).map(|app| app.info.clone())); - let (stale_windows, pending_refresh) = - Self::identify_stale_windows(reactor, pid, &known_visible); + let transient_empty_known_visible = known_visible.is_empty() + && reactor.native_tab_manager.consume_transient_empty_visibility(pid); + let (stale_windows, pending_refresh) = Self::identify_stale_windows( + reactor, + pid, + &known_visible, + transient_empty_known_visible, + ); Self::cleanup_stale_windows(reactor, pid, stale_windows, pending_refresh); let new_windows = Self::process_window_list(reactor, new, &app_info); Self::update_window_states(reactor, new_windows, &app_info); + if transient_empty_known_visible { + if let Some(app_state) = reactor.app_manager.apps.get(&pid) { + let pending_destroys = reactor.native_tab_manager.pending_destroys_for_pid(pid); + if pending_destroys.is_empty() { + if let Err(err) = app_state.handle.send(Request::GetVisibleWindows) { + warn!( + pid, + ?err, + "Failed to request follow-up visible-window refresh after transient empty native-tab visibility" + ); + } + } else { + for pending in pending_destroys { + if let Err(err) = + app_state.handle.send(Request::WindowMaybeDestroyed(pending.window_id)) + { + warn!( + pid, + wid = ?pending.window_id, + ?err, + "Failed to verify pending native-tab destroy after transient empty visibility" + ); + } + } + } + } + return; + } + reactor.reconcile_native_tabs_for_pid(pid, &known_visible); Self::emit_layout_events(reactor, pid, &known_visible, &app_info); } @@ -58,6 +93,7 @@ impl WindowDiscoveryHandler { reactor: &Reactor, pid: pid_t, known_visible: &[WindowId], + transient_empty_known_visible: bool, ) -> (Vec, bool) { const MIN_REAL_WINDOW_DIMENSION: f64 = 2.0; @@ -82,12 +118,14 @@ impl WindowDiscoveryHandler { ) || pending_refresh || reactor.is_mission_control_active() || reactor.is_in_drag() - || (known_visible_set.is_empty() - && !reactor.has_visible_window_server_ids_for_pid(pid)) + || transient_empty_known_visible || has_window_server_visibles_without_ax; if skip_stale_cleanup { - return (Vec::new(), false); + // `pending_refresh` is a one-shot stale-cleanup grace. If we keep it set after + // an empty wake / Mission Control discovery, later empty refreshes will continue + // to skip cleanup forever and leave ghost windows stranded in layout. + return (Vec::new(), pending_refresh); } let active_space_windows: Option> = { @@ -116,6 +154,10 @@ impl WindowDiscoveryHandler { return None; } + if state.is_native_tab_suppressed() { + return None; + } + if state.info.is_minimized { return None; } @@ -355,6 +397,11 @@ impl WindowDiscoveryHandler { .filter(|wid| wid.pid == pid) .filter(|wid| reactor.window_is_standard(*wid)) { + if !reactor.layout_manager.layout_engine.has_window_membership(wid) + && reactor.maybe_hold_native_tab_window_created(wid) + { + continue; + } let Some(space) = reactor.best_space_for_window_id(wid) else { continue; }; @@ -368,6 +415,11 @@ impl WindowDiscoveryHandler { if included.contains(&wid) || !reactor.window_is_standard(wid) { continue; } + if !reactor.layout_manager.layout_engine.has_window_membership(wid) + && reactor.maybe_hold_native_tab_window_created(wid) + { + continue; + } let Some(state) = reactor.window_manager.windows.get(&wid) else { continue; }; diff --git a/src/actor/reactor/main_window.rs b/src/actor/reactor/main_window.rs index 97c70391..6e39ccdc 100644 --- a/src/actor/reactor/main_window.rs +++ b/src/actor/reactor/main_window.rs @@ -82,6 +82,14 @@ impl MainWindowTracker { _ => None, } } + + pub fn rekey_window(&mut self, old: WindowId, new: WindowId) { + if let Some(app) = self.apps.get_mut(&old.pid) + && app.main_window == Some(old) + { + app.main_window = Some(new); + } + } } #[cfg(test)] diff --git a/src/actor/reactor/managers.rs b/src/actor/reactor/managers.rs index a1888e10..ca0b10d5 100644 --- a/src/actor/reactor/managers.rs +++ b/src/actor/reactor/managers.rs @@ -5,8 +5,8 @@ use tracing::trace; use super::replay::Record; use super::{ - AppState, Event, FullscreenSpaceTrack, PendingSpaceChange, ScreenInfo, WindowState, - WorkspaceSwitchOrigin, WorkspaceSwitchState, + AppState, Event, FullscreenSpaceTrack, NativeTabMembership, NativeTabRole, PendingSpaceChange, + ScreenInfo, WindowState, WorkspaceSwitchOrigin, WorkspaceSwitchState, }; use crate::actor; use crate::actor::app::{WindowId, pid_t}; @@ -29,6 +29,295 @@ pub struct WindowManager { pub observed_window_server_ids: HashSet, } +#[derive(Debug, Clone)] +pub struct NativeTabGroup { + pub pid: pid_t, + pub members: HashSet, + pub active: Option, + pub canonical_frame: CGRect, +} + +#[derive(Debug, Clone, Copy)] +pub struct PendingNativeTabDestroy { + pub window_id: WindowId, + pub window_server_id: WindowServerId, + pub space_id: SpaceId, + pub frame: CGRect, +} + +#[derive(Debug, Clone, Copy)] +pub struct PendingNativeTabAppearance { + pub wsid: WindowServerId, + pub space: SpaceId, + pub frame: CGRect, +} + +pub struct NativeTabManager { + pub groups: HashMap, + pub by_window: HashMap, + pub pending_destroys: HashMap>, + pub pending_appearances: HashMap>, + pub pending_frame_targets: HashMap, + pub transient_empty_visibility_grace: HashMap, + pub next_group_id: u32, +} + +impl Default for NativeTabManager { + fn default() -> Self { + Self { + groups: HashMap::default(), + by_window: HashMap::default(), + pending_destroys: HashMap::default(), + pending_appearances: HashMap::default(), + pending_frame_targets: HashMap::default(), + transient_empty_visibility_grace: HashMap::default(), + next_group_id: 1, + } + } +} + +impl NativeTabManager { + pub fn new() -> Self { Self::default() } + + pub fn group_for_window(&self, wid: WindowId) -> Option { + self.by_window.get(&wid).copied() + } + + pub fn is_suppressed(&self, wid: WindowId) -> bool { + self.group_for_window(wid) + .and_then(|group_id| self.groups.get(&group_id)) + .is_some_and(|group| group.active != Some(wid)) + } + + pub fn stage_destroy( + &mut self, + window_id: WindowId, + window_server_id: WindowServerId, + space_id: SpaceId, + frame: CGRect, + ) { + let entry = self.pending_destroys.entry(window_id.pid).or_default(); + if !entry.iter().any(|pending| pending.window_id == window_id) { + entry.push(PendingNativeTabDestroy { + window_id, + window_server_id, + space_id, + frame, + }); + } + } + + pub fn stage_appearance( + &mut self, + wsid: WindowServerId, + pid: pid_t, + space: SpaceId, + frame: CGRect, + ) { + let entry = self.pending_appearances.entry(pid).or_default(); + if !entry.iter().any(|pending| pending.wsid == wsid) { + entry.push(PendingNativeTabAppearance { wsid, space, frame }); + } + } + + pub fn pending_destroys_for_pid(&self, pid: pid_t) -> Vec { + self.pending_destroys.get(&pid).cloned().unwrap_or_default() + } + + pub fn pending_appearances_for_pid(&self, pid: pid_t) -> Vec { + self.pending_appearances.get(&pid).cloned().unwrap_or_default() + } + + pub fn clear_pending_destroy(&mut self, window_id: WindowId) { + if let Some(pending) = self.pending_destroys.get_mut(&window_id.pid) { + pending.retain(|candidate| candidate.window_id != window_id); + if pending.is_empty() { + self.pending_destroys.remove(&window_id.pid); + } + } + } + + pub fn clear_pending_appearance(&mut self, pid: pid_t, wsid: WindowServerId) { + if let Some(pending) = self.pending_appearances.get_mut(&pid) { + pending.retain(|candidate| candidate.wsid != wsid); + if pending.is_empty() { + self.pending_appearances.remove(&pid); + } + } + } + + fn next_group_id(&mut self) -> u32 { + let group_id = self.next_group_id; + self.next_group_id += 1; + group_id + } + + fn ensure_group(&mut self, active_window: WindowId, frame: CGRect) -> u32 { + if let Some(group_id) = self.by_window.get(&active_window).copied() { + return group_id; + } + + let group_id = self.next_group_id(); + let mut members = HashSet::default(); + members.insert(active_window); + self.groups.insert(group_id, NativeTabGroup { + pid: active_window.pid, + active: Some(active_window), + members, + canonical_frame: frame, + }); + self.by_window.insert(active_window, group_id); + group_id + } + + fn clear_window_membership(windows: &mut HashMap, wid: WindowId) { + if let Some(window) = windows.get_mut(&wid) { + window.native_tab = None; + } + } + + fn dissolve_group(&mut self, group_id: u32, windows: &mut HashMap) { + if let Some(group) = self.groups.remove(&group_id) { + for member in group.members { + self.by_window.remove(&member); + self.pending_frame_targets.remove(&member); + Self::clear_window_membership(windows, member); + } + } + } + + pub fn replace_active_member(&mut self, old: WindowId, new: WindowId, frame: CGRect) -> u32 { + let group_id = self.ensure_group(old, frame); + if let Some(group) = self.groups.get_mut(&group_id) { + group.members.insert(new); + group.active = Some(new); + group.canonical_frame = frame; + } + self.by_window.insert(new, group_id); + group_id + } + + pub fn add_background_member( + &mut self, + active_window: WindowId, + candidate: WindowId, + frame: CGRect, + ) -> u32 { + let group_id = self.ensure_group(active_window, frame); + if let Some(group) = self.groups.get_mut(&group_id) { + group.members.insert(candidate); + group.canonical_frame = frame; + } + self.by_window.insert(candidate, group_id); + group_id + } + + pub fn set_active_member(&mut self, wid: WindowId, frame: CGRect) -> Option { + let Some(group_id) = self.by_window.get(&wid).copied() else { + return None; + }; + if let Some(group) = self.groups.get_mut(&group_id) { + group.active = Some(wid); + group.canonical_frame = frame; + } + Some(group_id) + } + + pub fn remove_window(&mut self, wid: WindowId, windows: &mut HashMap) { + let Some(group_id) = self.by_window.remove(&wid) else { + return; + }; + self.pending_frame_targets.remove(&wid); + Self::clear_window_membership(windows, wid); + if let Some(group) = self.groups.get_mut(&group_id) { + group.members.remove(&wid); + if group.active == Some(wid) { + group.active = group.members.iter().copied().next(); + } + let active = group.active; + let members: Vec = group.members.iter().copied().collect(); + for member in members { + if let Some(window) = windows.get_mut(&member) + && let Some(membership) = window.native_tab.as_mut() + && membership.group_id == group_id + { + membership.role = if Some(member) == active { + NativeTabRole::Active + } else { + NativeTabRole::Suppressed + }; + } + } + if group.members.len() >= 2 { + return; + } + } + self.dissolve_group(group_id, windows); + } + + pub fn update_frame( + &mut self, + _wid: WindowId, + frame: CGRect, + membership: Option, + ) { + let Some(membership) = membership else { + return; + }; + if membership.role == NativeTabRole::Active + && let Some(group) = self.groups.get_mut(&membership.group_id) + { + group.canonical_frame = frame; + } + } + + pub fn remove_app(&mut self, pid: pid_t, windows: &mut HashMap) { + self.pending_destroys.remove(&pid); + self.pending_appearances.remove(&pid); + self.pending_frame_targets.retain(|wid, _| wid.pid != pid); + let group_ids: Vec = self + .groups + .iter() + .filter_map(|(&group_id, group)| (group.pid == pid).then_some(group_id)) + .collect(); + for group_id in group_ids { + self.dissolve_group(group_id, windows); + } + } + + pub fn set_pending_frame_target(&mut self, wid: WindowId, frame: CGRect) { + self.pending_frame_targets.insert(wid, frame); + } + + pub fn pending_frame_target(&self, wid: WindowId) -> Option { + self.pending_frame_targets.get(&wid).copied() + } + + pub fn clear_pending_frame_target(&mut self, wid: WindowId) { + self.pending_frame_targets.remove(&wid); + } + + pub fn note_transient_empty_visibility(&mut self, pid: pid_t) { + self.transient_empty_visibility_grace.insert(pid, 1); + } + + pub fn clear_transient_empty_visibility(&mut self, pid: pid_t) { + self.transient_empty_visibility_grace.remove(&pid); + } + + pub fn consume_transient_empty_visibility(&mut self, pid: pid_t) -> bool { + let Some(remaining) = self.transient_empty_visibility_grace.get_mut(&pid) else { + return false; + }; + if *remaining > 1 { + *remaining -= 1; + } else { + self.transient_empty_visibility_grace.remove(&pid); + } + true + } +} + /// Manages application state and rules pub struct AppManager { pub apps: HashMap, diff --git a/src/actor/reactor/native_tabs.rs b/src/actor/reactor/native_tabs.rs new file mode 100644 index 00000000..ee4e9de7 --- /dev/null +++ b/src/actor/reactor/native_tabs.rs @@ -0,0 +1,676 @@ +use objc2_core_foundation::CGRect; +use tracing::debug; + +use crate::actor::app::{Quiet, Request, WindowId, pid_t}; +use crate::actor::reactor::{NativeTabMembership, NativeTabRole, Reactor}; +use crate::model::reactor::WindowFilter; +use crate::sys::screen::SpaceId; +use crate::sys::window_server::{WindowServerId, WindowServerInfo}; + +const NATIVE_TAB_FRAME_TOLERANCE: f64 = 2.0; + +pub(crate) fn frames_match(a: CGRect, b: CGRect) -> bool { + (a.origin.x - b.origin.x).abs() <= NATIVE_TAB_FRAME_TOLERANCE + && (a.origin.y - b.origin.y).abs() <= NATIVE_TAB_FRAME_TOLERANCE + && (a.size.width - b.size.width).abs() <= NATIVE_TAB_FRAME_TOLERANCE + && (a.size.height - b.size.height).abs() <= NATIVE_TAB_FRAME_TOLERANCE +} + +impl Reactor { + fn log_native_tab_state(&self, label: &str, wid: WindowId) { let _ = (label, wid); } + + fn native_tab_group_frame_for(&self, wid: WindowId) -> Option { + let group_id = self.native_tab_manager.group_for_window(wid)?; + self.native_tab_manager.groups.get(&group_id).map(|group| group.canonical_frame) + } + + fn pid_has_native_tab_state(&self, pid: pid_t) -> bool { + self.native_tab_manager.groups.values().any(|group| group.pid == pid) + || !self.native_tab_manager.pending_destroys_for_pid(pid).is_empty() + || !self.native_tab_manager.pending_appearances_for_pid(pid).is_empty() + } + + fn set_native_tab_role(&mut self, wid: WindowId, group_id: u32, role: NativeTabRole) { + if let Some(window) = self.window_manager.windows.get_mut(&wid) { + window.native_tab = Some(NativeTabMembership { group_id, role }); + } + } + + fn try_activate_native_tab_replacement(&mut self, old: WindowId, new: WindowId) -> bool { + if self.activate_native_tab_replacement(old, new) { + self.native_tab_manager.clear_pending_destroy(old); + return true; + } + false + } + + fn sync_native_tab_group_frame( + &mut self, + active_wid: WindowId, + frame: CGRect, + membership: Option, + sync_window_positions: bool, + ) { + let Some(membership) = membership else { + return; + }; + if membership.role != NativeTabRole::Active { + return; + } + let Some(group) = self.native_tab_manager.groups.get(&membership.group_id) else { + return; + }; + let members: Vec = group.members.iter().copied().collect(); + for member in members { + if let Some(window) = self.window_manager.windows.get_mut(&member) { + window.frame_monotonic = frame; + window.info.frame = frame; + } + if !sync_window_positions || member == active_wid { + continue; + } + self.request_native_tab_member_frame_sync(member, frame); + } + } + + fn request_native_tab_member_frame_sync(&mut self, wid: WindowId, frame: CGRect) { + let Some(app) = self.app_manager.apps.get(&wid.pid) else { + return; + }; + self.native_tab_manager.set_pending_frame_target(wid, frame); + let txid = if let Some(wsid) = + self.window_manager.windows.get(&wid).and_then(|window| window.info.sys_id) + { + let txid = self.transaction_manager.generate_next_txid(wsid); + self.transaction_manager.store_txid(wsid, txid, frame); + txid + } else { + Default::default() + }; + if let Err(err) = app.handle.send(Request::SetWindowFrame(wid, frame, txid, true)) { + debug!(?wid, ?err, "Failed to sync native tab member frame"); + } + } + + fn ensure_active_native_tab_frame(&mut self, wid: WindowId, frame: CGRect) { + self.request_native_tab_member_frame_sync(wid, frame); + } + + pub(super) fn retry_pending_native_tab_frame_target(&mut self, wid: WindowId) { + let Some(frame) = self.native_tab_manager.pending_frame_target(wid) else { + return; + }; + self.request_native_tab_member_frame_sync(wid, frame); + } + + pub(super) fn is_native_tab_suppressed(&self, wid: WindowId) -> bool { + self.native_tab_manager.is_suppressed(wid) + } + + pub(super) fn window_is_native_tab_candidate(&self, wid: WindowId) -> bool { + self.window_manager.windows.get(&wid).is_some_and(|window| { + !window.info.is_minimized && window.info.is_standard && window.info.is_root + }) + } + + fn has_other_visible_same_pid_frame( + &self, + pid: pid_t, + frame: CGRect, + exclude: &[WindowId], + ) -> bool { + self.window_manager + .visible_windows + .iter() + .filter_map(|wsid| self.window_manager.window_ids.get(wsid)) + .copied() + .any(|wid| { + if wid.pid != pid || exclude.contains(&wid) { + return false; + } + self.window_manager.windows.get(&wid).is_some_and(|window| { + window.matches_filter(WindowFilter::Manageable) + && frames_match(window.frame_monotonic, frame) + }) + }) + } + + fn visible_native_tab_peer_for( + &self, + pid: pid_t, + frame: CGRect, + exclude: &[WindowId], + ) -> Option { + self.window_manager + .visible_windows + .iter() + .filter_map(|wsid| self.window_manager.window_ids.get(wsid)) + .copied() + .find(|wid| { + if wid.pid != pid || exclude.contains(wid) { + return false; + } + self.window_manager.windows.get(wid).is_some_and(|window| { + self.window_is_native_tab_candidate(*wid) + && !window.is_native_tab_suppressed() + && frames_match(window.frame_monotonic, frame) + && self.layout_manager.layout_engine.has_window_membership(*wid) + }) + }) + } + + fn has_matching_pending_native_tab_appearance(&self, wid: WindowId, frame: CGRect) -> bool { + let Some(window) = self.window_manager.windows.get(&wid) else { + return false; + }; + let Some(wsid) = window.info.sys_id else { + return false; + }; + let Some(space) = self + .best_space_for_window(&frame, Some(wsid)) + .or_else(|| self.best_space_for_window_state(window)) + else { + return false; + }; + + self.native_tab_manager + .pending_appearances_for_pid(wid.pid) + .into_iter() + .any(|pending| { + pending.wsid == wsid && pending.space == space && frames_match(pending.frame, frame) + }) + } + + fn visible_native_tab_replacement_peer_for( + &self, + new: WindowId, + frame: CGRect, + ) -> Option { + // A same-frame visible sibling is only a replacement candidate if we also + // observed the matching WindowServer appearance for `new`. + if !self.has_matching_pending_native_tab_appearance(new, frame) { + return None; + } + self.visible_native_tab_peer_for(new.pid, frame, &[new]) + .filter(|old| !self.has_other_visible_same_pid_frame(new.pid, frame, &[*old, new])) + } + + pub(super) fn activate_native_tab_replacement(&mut self, old: WindowId, new: WindowId) -> bool { + let Some(old_window) = self.window_manager.windows.get(&old) else { + return false; + }; + let frame = self.native_tab_group_frame_for(old).unwrap_or(old_window.frame_monotonic); + let old_wsid = old_window.info.sys_id; + let new_wsid = self.window_manager.windows.get(&new).and_then(|window| window.info.sys_id); + self.log_native_tab_state("activate_before_old", old); + self.log_native_tab_state("activate_before_new", new); + + if !self.window_is_native_tab_candidate(old) || !self.window_is_native_tab_candidate(new) { + return false; + } + let _ = self.layout_manager.layout_engine.rekey_window(old, new); + let group_id = self.native_tab_manager.replace_active_member(old, new, frame); + self.set_native_tab_role(old, group_id, NativeTabRole::Suppressed); + self.set_native_tab_role(new, group_id, NativeTabRole::Active); + if let Some(window) = self.window_manager.windows.get_mut(&new) { + window.frame_monotonic = frame; + window.info.frame = frame; + } + self.main_window_tracker.rekey_window(old, new); + if let (Some(old_wsid), Some(new_wsid)) = (old_wsid, new_wsid) { + self.transaction_manager.rekey_window(old_wsid, new_wsid); + } + self.ensure_active_native_tab_frame(new, frame); + + if self.drag_manager.skip_layout_for_window == Some(old) { + self.drag_manager.skip_layout_for_window = Some(new); + } + match &mut self.drag_manager.drag_state { + crate::actor::reactor::DragState::Active { session } + | crate::actor::reactor::DragState::PendingSwap { session, .. } => { + if session.window == old { + session.window = new; + } + } + crate::actor::reactor::DragState::Inactive => {} + } + if let crate::actor::reactor::DragState::PendingSwap { target, .. } = + &mut self.drag_manager.drag_state + && *target == old + { + *target = new; + } + if self.workspace_switch_manager.pending_workspace_mouse_warp == Some(old) { + self.workspace_switch_manager.pending_workspace_mouse_warp = Some(new); + } + self.log_native_tab_state("activate_after_old", old); + self.log_native_tab_state("activate_after_new", new); + true + } + + fn pending_native_tab_replacement_for( + &self, + pid: pid_t, + new: WindowId, + space: SpaceId, + frame: CGRect, + ) -> Option { + self.native_tab_manager + .pending_destroys_for_pid(pid) + .into_iter() + .find(|pending| { + pending.window_id != new + && pending.space_id == space + && frames_match(pending.frame, frame) + && !self.has_other_visible_same_pid_frame(pid, pending.frame, &[ + pending.window_id, + new, + ]) + }) + .map(|pending| pending.window_id) + } + + fn native_tab_replacement_candidate_for( + &self, + new: WindowId, + frame: CGRect, + ) -> Option { + let space = self.best_space_for_window_id(new)?; + self.pending_native_tab_replacement_for(new.pid, new, space, frame) + .or_else(|| self.visible_native_tab_replacement_peer_for(new, frame)) + } + + fn grouped_native_tab_replacement_for( + &self, + old: WindowId, + known_visible: &std::collections::HashSet, + ) -> Option { + let group_id = self.native_tab_manager.group_for_window(old)?; + let group = self.native_tab_manager.groups.get(&group_id)?; + group.members.iter().copied().find(|candidate| { + *candidate != old + && known_visible.contains(candidate) + && self.window_is_native_tab_candidate(*candidate) + && self.window_manager.windows.contains_key(candidate) + }) + } + + fn candidate_has_pending_native_tab_appearance(&self, wid: WindowId) -> bool { + let Some(wsid) = + self.window_manager.windows.get(&wid).and_then(|window| window.info.sys_id) + else { + return false; + }; + self.native_tab_manager + .pending_appearances_for_pid(wid.pid) + .into_iter() + .any(|pending| pending.wsid == wsid) + } + + fn clear_stale_pending_native_tab_appearances_for_pid(&mut self, pid: pid_t) { + for pending in self.native_tab_manager.pending_appearances_for_pid(pid) { + let has_window_mapping = self.window_manager.window_ids.contains_key(&pending.wsid); + if !has_window_mapping { + self.window_manager.visible_windows.remove(&pending.wsid); + self.window_server_info_manager.window_server_info.remove(&pending.wsid); + self.window_manager.observed_window_server_ids.remove(&pending.wsid); + } + self.native_tab_manager.clear_pending_appearance(pid, pending.wsid); + } + } + + pub(super) fn stage_native_tab_destroy(&mut self, wsid: WindowServerId, sid: SpaceId) -> bool { + let Some(&wid) = self.window_manager.window_ids.get(&wsid) else { + return false; + }; + if !self.window_is_native_tab_candidate(wid) { + return false; + } + let Some(window) = self.window_manager.windows.get(&wid) else { + return false; + }; + self.native_tab_manager.stage_destroy(wid, wsid, sid, window.frame_monotonic); + self.window_manager.visible_windows.remove(&wsid); + self.window_server_info_manager.window_server_info.remove(&wsid); + true + } + + pub(super) fn note_native_tab_appearance( + &mut self, + wsid: WindowServerId, + sid: SpaceId, + info: WindowServerInfo, + ) -> bool { + self.window_manager.visible_windows.insert(wsid); + self.window_server_info_manager.window_server_info.insert(wsid, info); + + let Some(&wid) = self.window_manager.window_ids.get(&wsid) else { + self.native_tab_manager.stage_appearance(wsid, info.pid, sid, info.frame); + return false; + }; + self.native_tab_manager.clear_pending_appearance(info.pid, wsid); + + if let Some(pending_old) = + self.pending_native_tab_replacement_for(info.pid, wid, sid, info.frame) + && self.try_activate_native_tab_replacement(pending_old, wid) + { + return true; + } + + let Some(group_id) = self.native_tab_manager.group_for_window(wid) else { + // don't treat ordinary already-managed same-size windows as tab-switch signals. + if !self.layout_manager.layout_engine.has_window_membership(wid) { + self.native_tab_manager.stage_appearance(wsid, info.pid, sid, info.frame); + } + return false; + }; + if !self.is_native_tab_suppressed(wid) { + return false; + } + let Some(group) = self.native_tab_manager.groups.get(&group_id) else { + return false; + }; + if !frames_match(group.canonical_frame, info.frame) { + return false; + } + + let old_active = group.active; + if let Some(old_active) = old_active { + if self.try_activate_native_tab_replacement(old_active, wid) { + return true; + } + return false; + } + + if let Some(group_id) = self.native_tab_manager.set_active_member(wid, info.frame) { + self.set_native_tab_role(wid, group_id, NativeTabRole::Active); + } + true + } + + pub(super) fn maybe_hold_native_tab_window_created(&self, wid: WindowId) -> bool { + self.window_is_native_tab_candidate(wid) + && self.window_manager.windows.get(&wid).is_some_and(|window| { + self.native_tab_manager + .pending_destroys_for_pid(wid.pid) + .into_iter() + .any(|pending| frames_match(pending.frame, window.frame_monotonic)) + || self.native_tab_manager.groups.values().any(|group| { + group.pid == wid.pid + && frames_match(group.canonical_frame, window.frame_monotonic) + }) + || self + .visible_native_tab_replacement_peer_for(wid, window.frame_monotonic) + .is_some() + }) + } + + pub(super) fn defer_native_tab_window_destroy(&mut self, wid: WindowId) -> bool { + let Some(window) = self.window_manager.windows.get(&wid) else { + return false; + }; + if !self.window_is_native_tab_candidate(wid) { + return false; + } + + let has_pending_destroy = self + .native_tab_manager + .pending_destroys_for_pid(wid.pid) + .into_iter() + .any(|pending| pending.window_id == wid); + if !has_pending_destroy { + return false; + } + + if let Some(wsid) = window.info.sys_id { + self.transaction_manager.remove_for_window(wsid); + self.window_manager.window_ids.remove(&wsid); + self.window_server_info_manager.window_server_info.remove(&wsid); + self.window_manager.visible_windows.remove(&wsid); + } + if let Some(window) = self.window_manager.windows.get_mut(&wid) { + window.info.sys_id = None; + } + true + } + + pub(super) fn finalize_native_tab_window_destroy(&mut self, wid: WindowId) { + self.native_tab_manager.clear_pending_destroy(wid); + self.native_tab_manager.remove_window(wid, &mut self.window_manager.windows); + } + + pub(super) fn handle_native_tab_frame_changed(&mut self, wid: WindowId, sync_group: bool) { + let Some(window) = self.window_manager.windows.get(&wid) else { + return; + }; + let frame = window.frame_monotonic; + let membership = window.native_tab; + self.native_tab_manager.update_frame(wid, frame, membership); + if sync_group { + self.sync_native_tab_group_frame(wid, frame, membership, true); + } + } + + pub(super) fn handle_native_tab_app_terminated(&mut self, pid: pid_t) { + self.native_tab_manager.remove_app(pid, &mut self.window_manager.windows); + } + + pub(super) fn pending_native_tab_destroys( + &self, + ) -> Vec { + self.native_tab_manager + .pending_destroys + .values() + .flat_map(|pending| pending.iter().copied()) + .collect() + } + + pub(super) fn handle_native_tab_main_window_changed( + &mut self, + pid: pid_t, + wid: Option, + quiet: Quiet, + ) { + if quiet == Quiet::Yes { + return; + } + if wid.is_none() { + if self.pid_has_native_tab_state(pid) { + self.native_tab_manager.note_transient_empty_visibility(pid); + } + return; + } + self.native_tab_manager.clear_transient_empty_visibility(pid); + let Some(wid) = wid else { + return; + }; + self.log_native_tab_state("main_window_changed_entry", wid); + if wid.pid != pid || !self.window_is_native_tab_candidate(wid) { + return; + } + if let Some(frame) = + self.window_manager.windows.get(&wid).map(|window| window.frame_monotonic) + && !self.is_native_tab_suppressed(wid) + && let Some(old_active) = self.native_tab_replacement_candidate_for(wid, frame) + && self.try_activate_native_tab_replacement(old_active, wid) + { + return; + } + + if !self.is_native_tab_suppressed(wid) { + return; + } + + let Some(group_id) = self.native_tab_manager.group_for_window(wid) else { + return; + }; + let Some(group) = self.native_tab_manager.groups.get(&group_id) else { + return; + }; + if let Some(old_active) = + group.active.filter(|old_active| *old_active != wid).or_else(|| { + group.members.iter().copied().find(|member| { + *member != wid + && self.layout_manager.layout_engine.has_window_membership(*member) + }) + }) + && self.try_activate_native_tab_replacement(old_active, wid) + { + return; + } + } + + pub(super) fn reconcile_native_tabs_for_pid(&mut self, pid: pid_t, known_visible: &[WindowId]) { + let known_visible: std::collections::HashSet = + known_visible.iter().copied().collect(); + + for pending in self.native_tab_manager.pending_appearances_for_pid(pid) { + let Some(&wid) = self.window_manager.window_ids.get(&pending.wsid) else { + continue; + }; + if !self.window_is_native_tab_candidate(wid) { + continue; + } + let Some(old_active) = + self.pending_native_tab_replacement_for(pid, wid, pending.space, pending.frame) + else { + continue; + }; + if self.try_activate_native_tab_replacement(old_active, wid) { + self.native_tab_manager.clear_pending_appearance(pid, pending.wsid); + } + } + + for pending in self.native_tab_manager.pending_destroys_for_pid(pid) { + let replacement = known_visible.iter().copied().find(|&candidate| { + if candidate == pending.window_id || candidate.pid != pending.window_id.pid { + return false; + } + if !self.window_is_native_tab_candidate(candidate) { + return false; + } + let Some(window) = self.window_manager.windows.get(&candidate) else { + return false; + }; + let Some(sys_id) = window.info.sys_id else { + return false; + }; + if !self + .has_matching_pending_native_tab_appearance(candidate, window.frame_monotonic) + { + return false; + } + self.window_manager.visible_windows.contains(&sys_id) + && self.best_space_for_window_id(candidate) == Some(pending.space_id) + && frames_match(window.frame_monotonic, pending.frame) + && !self.has_other_visible_same_pid_frame( + pending.window_id.pid, + pending.frame, + &[pending.window_id, candidate], + ) + }); + + if let Some(new_active) = replacement { + if self.try_activate_native_tab_replacement(pending.window_id, new_active) { + if let Some(wsid) = self + .window_manager + .windows + .get(&new_active) + .and_then(|window| window.info.sys_id) + { + self.native_tab_manager.clear_pending_appearance(pid, wsid); + } + continue; + } + } + + if !known_visible.contains(&pending.window_id) { + if let Some(new_active) = + self.grouped_native_tab_replacement_for(pending.window_id, &known_visible) + { + let replacement_has_pending_appearance = + self.candidate_has_pending_native_tab_appearance(new_active); + if self.try_activate_native_tab_replacement(pending.window_id, new_active) { + if replacement_has_pending_appearance { + if let Some(wsid) = self + .window_manager + .windows + .get(&new_active) + .and_then(|window| window.info.sys_id) + { + self.native_tab_manager.clear_pending_appearance(pid, wsid); + } + } else { + let _ = crate::actor::reactor::events::window::WindowEventHandler::handle_window_destroyed( + self, + pending.window_id, + ); + } + continue; + } + } + self.native_tab_manager.clear_pending_destroy(pending.window_id); + let _ = crate::actor::reactor::events::window::WindowEventHandler::handle_window_destroyed( + self, + pending.window_id, + ); + continue; + } + + if self.window_manager.visible_windows.contains(&pending.window_server_id) { + self.native_tab_manager.clear_pending_destroy(pending.window_id); + } + } + + if known_visible.is_empty() && self.native_tab_manager.pending_destroys_for_pid(pid).is_empty() + { + self.clear_stale_pending_native_tab_appearances_for_pid(pid); + } + + let active_windows: Vec = self + .window_manager + .visible_windows + .iter() + .filter_map(|wsid| self.window_manager.window_ids.get(wsid)) + .copied() + .filter(|wid| wid.pid == pid) + .filter(|wid| self.window_is_native_tab_candidate(*wid)) + .filter(|wid| !self.is_native_tab_suppressed(*wid)) + .collect(); + + for active_window in active_windows { + let Some(active_state) = self.window_manager.windows.get(&active_window) else { + continue; + }; + let active_frame = active_state.frame_monotonic; + for candidate in known_visible.iter().copied() { + if candidate == active_window || candidate.pid != active_window.pid { + continue; + } + if !self.window_is_native_tab_candidate(candidate) { + continue; + } + let Some(candidate_state) = self.window_manager.windows.get(&candidate) else { + continue; + }; + if candidate_state + .info + .sys_id + .is_some_and(|sys_id| self.window_manager.visible_windows.contains(&sys_id)) + { + continue; + } + if !frames_match(candidate_state.frame_monotonic, active_frame) { + continue; + } + let group_id = self.native_tab_manager.add_background_member( + active_window, + candidate, + active_frame, + ); + self.set_native_tab_role(candidate, group_id, NativeTabRole::Suppressed); + self.set_native_tab_role(active_window, group_id, NativeTabRole::Active); + } + } + } +} diff --git a/src/actor/reactor/testing.rs b/src/actor/reactor/testing.rs index 74a84ef5..dba81fe4 100644 --- a/src/actor/reactor/testing.rs +++ b/src/actor/reactor/testing.rs @@ -201,7 +201,19 @@ impl Apps { debug!(?request); match request { Request::Terminate => break, - Request::WindowMaybeDestroyed(_) => {} + Request::WindowMaybeDestroyed(wid) => { + if got_visible_windows { + continue; + } + got_visible_windows = true; + let windows = + self.windows.keys().copied().filter(|known| known.pid == wid.pid).collect(); + events.push(Event::WindowsDiscovered { + pid: wid.pid, + new: vec![], + known_visible: windows, + }); + } Request::GetVisibleWindows => { if got_visible_windows { continue; diff --git a/src/actor/reactor/tests.rs b/src/actor/reactor/tests.rs index e17952f5..2bdfb066 100644 --- a/src/actor/reactor/tests.rs +++ b/src/actor/reactor/tests.rs @@ -2,12 +2,164 @@ use objc2_core_foundation::{CGPoint, CGSize}; use test_log::test; use super::display_topology::TopologyState; +use super::events::window::WindowEventHandler; use super::testing::*; use super::*; use crate::actor::app::Request; use crate::layout_engine::{Direction, LayoutCommand, LayoutEngine}; use crate::sys::app::WindowInfo; -use crate::sys::window_server::WindowServerId; +use crate::sys::window_server::{WindowServerId, WindowServerInfo}; + +fn test_reactor() -> Reactor { + Reactor::new_for_test(LayoutEngine::new( + &crate::common::config::VirtualWorkspaceSettings::default(), + &crate::common::config::LayoutSettings::default(), + None, + )) +} + +fn native_tab_test_setup(space: u64) -> (Apps, Reactor, SpaceId) { + let mut apps = Apps::new(); + let mut reactor = test_reactor(); + let space = SpaceId::new(space); + let screen = CGRect::new(CGPoint::new(0., 0.), CGSize::new(1000., 1000.)); + reactor.handle_event(screen_params_event(vec![screen], vec![Some(space)], vec![])); + + reactor.handle_events(apps.make_app(1, vec![make_window(1)])); + apps.simulate_until_quiet(&mut reactor); + let _ = apps.requests(); + + (apps, reactor, space) +} + +fn replacement_tab( + reactor: &Reactor, + old_wid: WindowId, + new_window_number: u32, +) -> (WindowId, WindowInfo, WindowServerInfo) { + let mut replacement = make_window(new_window_number as usize); + replacement.frame = reactor.window_manager.windows[&old_wid].frame_monotonic; + let ws_info = WindowServerInfo { + id: WindowServerId::new(new_window_number), + pid: old_wid.pid, + layer: 0, + frame: replacement.frame, + min_frame: CGSize::ZERO, + max_frame: CGSize::ZERO, + }; + ( + WindowId::new(old_wid.pid, new_window_number), + replacement, + ws_info, + ) +} + +fn create_native_tab_replacement( + reactor: &mut Reactor, + space: SpaceId, + old_ws_id: WindowServerId, + new_wid: WindowId, + replacement: WindowInfo, + replacement_ws_info: WindowServerInfo, +) -> bool { + assert!(reactor.stage_native_tab_destroy(old_ws_id, space)); + WindowEventHandler::handle_window_created( + reactor, + new_wid, + replacement, + Some(replacement_ws_info), + Some(MouseState::Up), + ); + reactor.note_native_tab_appearance(replacement_ws_info.id, space, replacement_ws_info) +} + +fn create_three_tab_group(reactor: &mut Reactor, space: SpaceId) -> (WindowId, WindowId, WindowId) { + let first = WindowId::new(1, 1); + let (second, second_info, second_ws_info) = replacement_tab(reactor, first, 2); + assert!(create_native_tab_replacement( + reactor, + space, + WindowServerId::new(1), + second, + second_info, + second_ws_info, + )); + + let (third, third_info, third_ws_info) = replacement_tab(reactor, second, 3); + assert!(create_native_tab_replacement( + reactor, + space, + WindowServerId::new(2), + third, + third_info, + third_ws_info, + )); + + (first, second, third) +} + +fn assert_native_tab_switch_state( + reactor: &mut Reactor, + space: SpaceId, + old_wid: WindowId, + new_wid: WindowId, +) { + let old = reactor + .window_manager + .windows + .get(&old_wid) + .expect("old tab should remain tracked as suppressed"); + let new = reactor + .window_manager + .windows + .get(&new_wid) + .expect("new tab should become the active managed slot"); + assert!(old.is_native_tab_suppressed()); + assert_eq!( + new.native_tab.expect("new tab should be part of a native tab group").role, + NativeTabRole::Active + ); + assert_eq!( + reactor.layout_manager.layout_engine.selected_window(space), + Some(new_wid) + ); + assert_eq!( + reactor.layout_manager.layout_engine.windows_in_active_workspace(space), + vec![new_wid] + ); +} + +fn native_tab_window_and_space_setup(space: u64, windows: usize) -> (Apps, Reactor, SpaceId) { + let mut apps = Apps::new(); + let mut reactor = test_reactor(); + let space = SpaceId::new(space); + let screen = CGRect::new(CGPoint::new(0., 0.), CGSize::new(1000., 1000.)); + reactor.handle_event(screen_params_event(vec![screen], vec![Some(space)], vec![])); + + reactor.handle_events(apps.make_app(1, make_windows(windows))); + apps.simulate_until_quiet(&mut reactor); + let _ = apps.requests(); + + (apps, reactor, space) +} + +fn assert_window_removed_from_layout(reactor: &Reactor, space: SpaceId, wid: WindowId) { + assert!(!reactor.window_manager.windows.contains_key(&wid)); + assert!( + reactor + .layout_manager + .layout_engine + .windows_in_active_workspace(space) + .is_empty() + ); +} + +fn assert_has_set_window_frame_request(requests: &[Request], wid: WindowId, frame: CGRect) { + assert!(requests.iter().any(|request| matches!( + request, + Request::SetWindowFrame(req_wid, req_frame, _, _) if *req_wid == wid && *req_frame == frame + ))); +} #[test] fn it_ignores_stale_resize_events() { @@ -257,6 +409,921 @@ fn it_ignores_windows_on_nonzero_layers() { reactor.handle_event(Event::WindowDestroyed(WindowId::new(1, 2))); } +#[test] +fn native_tab_switch_rekeys_the_active_layout_slot() { + let (apps, mut reactor, space) = native_tab_test_setup(30); + let old_wid = WindowId::new(1, 1); + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 2); + let txid = reactor.transaction_manager.generate_next_txid(WindowServerId::new(1)); + reactor + .transaction_manager + .store_txid(WindowServerId::new(1), txid, replacement.frame); + + assert!(create_native_tab_replacement( + &mut reactor, + space, + WindowServerId::new(1), + new_wid, + replacement.clone(), + replacement_ws_info, + )); + + assert_native_tab_switch_state(&mut reactor, space, old_wid, new_wid); + assert_eq!( + reactor.transaction_manager.get_last_sent_txid(WindowServerId::new(2)), + txid.next() + ); + assert_eq!( + reactor.transaction_manager.get_target_frame(WindowServerId::new(2)), + Some(replacement.frame) + ); + assert_eq!( + reactor.transaction_manager.get_target_frame(WindowServerId::new(1)), + None + ); + let _ = apps; +} + +#[test] +fn native_tab_switch_reconciles_when_windowserver_appears_first() { + let (_apps, mut reactor, space) = native_tab_test_setup(31); + let old_wid = WindowId::new(1, 1); + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 2); + + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(1), space)); + assert!(!reactor.note_native_tab_appearance( + WindowServerId::new(2), + space, + replacement_ws_info, + )); + WindowEventHandler::handle_window_created( + &mut reactor, + new_wid, + replacement, + Some(replacement_ws_info), + Some(MouseState::Up), + ); + reactor.reconcile_native_tabs_for_pid(1, &[old_wid, new_wid]); + + assert_native_tab_switch_state(&mut reactor, space, old_wid, new_wid); +} + +#[test] +fn native_tab_window_created_before_destroy_is_held_out_of_layout() { + let (_apps, mut reactor, space) = native_tab_test_setup(32); + let old_wid = WindowId::new(1, 1); + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 2); + + assert!(!reactor.note_native_tab_appearance( + WindowServerId::new(2), + space, + replacement_ws_info, + )); + WindowEventHandler::handle_window_created( + &mut reactor, + new_wid, + replacement, + Some(replacement_ws_info), + Some(MouseState::Up), + ); + + assert!(!reactor.layout_manager.layout_engine.has_window_membership(new_wid)); + assert_eq!( + reactor.layout_manager.layout_engine.windows_in_active_workspace(space), + vec![old_wid] + ); +} + +#[test] +fn native_tab_main_window_changed_rekeys_before_windowserver_destroy() { + let (_apps, mut reactor, space) = native_tab_test_setup(33); + let old_wid = WindowId::new(1, 1); + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 2); + + assert!(!reactor.note_native_tab_appearance( + WindowServerId::new(2), + space, + replacement_ws_info, + )); + WindowEventHandler::handle_window_created( + &mut reactor, + new_wid, + replacement, + Some(replacement_ws_info), + Some(MouseState::Up), + ); + reactor.handle_event(Event::ApplicationMainWindowChanged(1, Some(new_wid), Quiet::No)); + + assert_native_tab_switch_state(&mut reactor, space, old_wid, new_wid); +} + +#[test] +fn moving_active_native_tab_updates_suppressed_siblings() { + let (mut apps, mut reactor, space) = native_tab_test_setup(331); + let old_wid = WindowId::new(1, 1); + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 2); + + assert!(create_native_tab_replacement( + &mut reactor, + space, + WindowServerId::new(1), + new_wid, + replacement, + replacement_ws_info, + )); + + let mut moved_frame = reactor.window_manager.windows[&new_wid].frame_monotonic; + moved_frame.origin.x += 137.0; + moved_frame.origin.y += 41.0; + reactor.handle_event(Event::WindowFrameChanged( + new_wid, + moved_frame, + None, + Requested(false), + Some(MouseState::Down), + )); + reactor.handle_event(Event::MouseUp); + + let final_frame = reactor.window_manager.windows[&new_wid].frame_monotonic; + assert_eq!( + reactor.window_manager.windows[&old_wid].frame_monotonic, + final_frame + ); + assert_eq!(reactor.window_manager.windows[&old_wid].info.frame, final_frame); + + let requests = apps.requests(); + assert!(requests.iter().any(|request| matches!( + request, + Request::SetWindowFrame(wid, frame, _, _) if *wid == old_wid && *frame == final_frame + ))); + for event in apps.simulate_events_for_requests(requests) { + reactor.handle_event(event); + } + assert_eq!(apps.windows[&old_wid].frame, final_frame); + + let _ = apps.requests(); + reactor.handle_event(Event::ApplicationMainWindowChanged(1, Some(old_wid), Quiet::No)); + let requests = apps.requests(); + assert!(requests.iter().any(|request| matches!( + request, + Request::SetWindowFrame(wid, frame, _, _) if *wid == old_wid && *frame == final_frame + ))); +} + +#[test] +fn requested_move_of_active_native_tab_updates_suppressed_siblings() { + let (mut apps, mut reactor, space) = native_tab_test_setup(333); + let old_wid = WindowId::new(1, 1); + let original_frame = reactor.window_manager.windows[&old_wid].frame_monotonic; + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 2); + + assert!(create_native_tab_replacement( + &mut reactor, + space, + WindowServerId::new(1), + new_wid, + replacement, + replacement_ws_info, + )); + + let mut requested_frame = original_frame; + requested_frame.origin.x += 137.0; + requested_frame.origin.y += 41.0; + assert!(!WindowEventHandler::handle_window_frame_changed( + &mut reactor, + new_wid, + requested_frame, + None, + Requested(true), + Some(MouseState::Up), + )); + + assert_eq!( + reactor.window_manager.windows[&new_wid].frame_monotonic, + requested_frame + ); + assert_eq!( + reactor.window_manager.windows[&old_wid].frame_monotonic, + requested_frame + ); + assert_eq!( + reactor.window_manager.windows[&old_wid].info.frame, + requested_frame + ); + + let requests = apps.requests(); + assert_has_set_window_frame_request(&requests, old_wid, requested_frame); +} + +#[test] +fn reactivating_native_tab_retries_pending_frame_after_stale_event() { + let (mut apps, mut reactor, space) = native_tab_test_setup(334); + let old_wid = WindowId::new(1, 1); + let original_frame = reactor.window_manager.windows[&old_wid].frame_monotonic; + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 2); + + assert!(create_native_tab_replacement( + &mut reactor, + space, + WindowServerId::new(1), + new_wid, + replacement, + replacement_ws_info, + )); + + let mut final_frame = original_frame; + final_frame.origin.x += 137.0; + final_frame.origin.y += 41.0; + if let Some(window) = reactor.window_manager.windows.get_mut(&new_wid) { + window.frame_monotonic = final_frame; + window.info.frame = final_frame; + } + let group_id = reactor.window_manager.windows[&new_wid] + .native_tab + .expect("new tab should belong to a native-tab group") + .group_id; + reactor.native_tab_manager.groups.get_mut(&group_id).unwrap().canonical_frame = final_frame; + + assert!(reactor.activate_native_tab_replacement(new_wid, old_wid)); + let requests = apps.requests(); + assert_has_set_window_frame_request(&requests, old_wid, final_frame); + + assert!(!WindowEventHandler::handle_window_frame_changed( + &mut reactor, + old_wid, + original_frame, + None, + Requested(false), + Some(MouseState::Up), + )); + + let retry_requests = apps.requests(); + assert_has_set_window_frame_request(&retry_requests, old_wid, final_frame); +} + +#[test] +fn reactivating_native_tab_uses_group_canonical_frame_not_stale_active_window_frame() { + let (mut apps, mut reactor, space) = native_tab_test_setup(335); + let old_wid = WindowId::new(1, 1); + let original_frame = reactor.window_manager.windows[&old_wid].frame_monotonic; + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 2); + + assert!(create_native_tab_replacement( + &mut reactor, + space, + WindowServerId::new(1), + new_wid, + replacement, + replacement_ws_info, + )); + + let mut final_frame = original_frame; + final_frame.origin.x += 137.0; + final_frame.origin.y += 41.0; + let group_id = reactor.window_manager.windows[&new_wid] + .native_tab + .expect("new tab should belong to a native-tab group") + .group_id; + reactor.native_tab_manager.groups.get_mut(&group_id).unwrap().canonical_frame = final_frame; + if let Some(window) = reactor.window_manager.windows.get_mut(&new_wid) { + window.frame_monotonic = original_frame; + window.info.frame = original_frame; + } + + assert!(reactor.activate_native_tab_replacement(new_wid, old_wid)); + assert_eq!( + reactor.window_manager.windows[&old_wid].frame_monotonic, + final_frame + ); + + let requests = apps.requests(); + assert_has_set_window_frame_request(&requests, old_wid, final_frame); +} + +#[test] +fn window_created_for_suppressed_native_tab_preserves_membership() { + let (_apps, mut reactor, space) = native_tab_test_setup(337); + let old_wid = WindowId::new(1, 1); + let original_frame = reactor.window_manager.windows[&old_wid].frame_monotonic; + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 2); + + assert!(create_native_tab_replacement( + &mut reactor, + space, + WindowServerId::new(1), + new_wid, + replacement, + replacement_ws_info, + )); + + let mut stale_frame = original_frame; + stale_frame.origin.x += 504.0; + let recreated_old = WindowInfo { + title: "tab-a".to_string(), + frame: stale_frame, + sys_id: Some(WindowServerId::new(1)), + ..make_window(1) + }; + let recreated_old_ws_info = WindowServerInfo { + id: WindowServerId::new(1), + pid: 1, + layer: 0, + frame: stale_frame, + min_frame: CGSize::ZERO, + max_frame: CGSize::ZERO, + }; + + WindowEventHandler::handle_window_created( + &mut reactor, + old_wid, + recreated_old, + Some(recreated_old_ws_info), + Some(MouseState::Up), + ); + + let old_state = reactor.window_manager.windows.get(&old_wid).unwrap(); + assert_eq!( + old_state + .native_tab + .expect("recreated suppressed tab should stay in its native-tab group") + .role, + NativeTabRole::Suppressed + ); + assert_eq!(old_state.frame_monotonic, original_frame); + assert_eq!( + reactor.window_manager.windows[&new_wid] + .native_tab + .expect("active tab should stay grouped") + .role, + NativeTabRole::Active + ); +} + +#[test] +fn transient_empty_visibility_during_native_tab_switch_preserves_group_state() { + let (_apps, mut reactor, space) = native_tab_test_setup(336); + let old_wid = WindowId::new(1, 1); + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 2); + + assert!(create_native_tab_replacement( + &mut reactor, + space, + WindowServerId::new(1), + new_wid, + replacement, + replacement_ws_info, + )); + + reactor.handle_event(Event::ApplicationMainWindowChanged(1, None, Quiet::No)); + reactor.handle_event(Event::WindowsDiscovered { + pid: 1, + new: vec![], + known_visible: vec![], + }); + + assert!(reactor.window_manager.windows[&old_wid].is_native_tab_suppressed()); + assert_eq!( + reactor.window_manager.windows[&new_wid] + .native_tab + .expect("new tab should remain active") + .role, + NativeTabRole::Active + ); + assert_eq!( + reactor.layout_manager.layout_engine.windows_in_active_workspace(space), + vec![new_wid] + ); + + reactor.handle_event(Event::ApplicationMainWindowChanged(1, Some(old_wid), Quiet::No)); + + assert_eq!( + reactor.window_manager.windows[&old_wid] + .native_tab + .expect("old tab should still belong to the group") + .role, + NativeTabRole::Active + ); + assert_eq!( + reactor.window_manager.windows[&new_wid] + .native_tab + .expect("new tab should stay grouped") + .role, + NativeTabRole::Suppressed + ); + assert_eq!( + reactor.layout_manager.layout_engine.windows_in_active_workspace(space), + vec![old_wid] + ); +} + +#[test] +fn reconcile_native_tabs_reactivates_visible_group_member_instead_of_dissolving_group() { + let (_apps, mut reactor, space) = native_tab_test_setup(338); + let old_wid = WindowId::new(1, 1); + let original_frame = reactor.window_manager.windows[&old_wid].frame_monotonic; + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 2); + + assert!(create_native_tab_replacement( + &mut reactor, + space, + WindowServerId::new(1), + new_wid, + replacement, + replacement_ws_info, + )); + + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(2), space)); + if let Some(window) = reactor.window_manager.windows.get_mut(&old_wid) { + window.info.sys_id = Some(WindowServerId::new(1)); + window.info.frame = original_frame; + } + reactor.window_manager.window_ids.insert(WindowServerId::new(1), old_wid); + reactor + .native_tab_manager + .stage_appearance(WindowServerId::new(1), 1, space, original_frame); + + reactor.handle_event(Event::WindowsDiscovered { + pid: 1, + new: vec![(old_wid, WindowInfo { + sys_id: Some(WindowServerId::new(1)), + frame: original_frame, + ..make_window(1) + })], + known_visible: vec![old_wid], + }); + + assert!(reactor.window_manager.windows.contains_key(&new_wid)); + assert_eq!( + reactor.window_manager.windows[&old_wid] + .native_tab + .expect("old tab should remain grouped") + .role, + NativeTabRole::Active + ); + assert_eq!( + reactor.window_manager.windows[&new_wid] + .native_tab + .expect("new tab should remain grouped") + .role, + NativeTabRole::Suppressed + ); + assert_eq!( + reactor.layout_manager.layout_engine.windows_in_active_workspace(space), + vec![old_wid] + ); +} + +#[test] +fn removing_active_native_tab_member_promotes_a_survivor_role() { + let (_apps, mut reactor, space) = native_tab_test_setup(339); + let (_first, _second, third) = create_three_tab_group(&mut reactor, space); + + reactor.finalize_native_tab_window_destroy(third); + + let active_count = [WindowId::new(1, 1), WindowId::new(1, 2)] + .into_iter() + .filter_map(|wid| { + reactor.window_manager.windows.get(&wid).and_then(|window| window.native_tab) + }) + .filter(|membership| membership.role == NativeTabRole::Active) + .count(); + assert_eq!(active_count, 1); +} + +#[test] +fn closing_active_native_tab_rekeys_to_existing_group_member_without_pending_appearance() { + let (_apps, mut reactor, space) = native_tab_test_setup(340); + let (_first, second, third) = create_three_tab_group(&mut reactor, space); + + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(3), space)); + assert!(!WindowEventHandler::handle_window_destroyed(&mut reactor, third)); + assert!(reactor.window_manager.windows.contains_key(&third)); + + reactor.reconcile_native_tabs_for_pid(1, &[second]); + + assert!(!reactor.window_manager.windows.contains_key(&third)); + assert_eq!( + reactor.window_manager.windows[&second] + .native_tab + .expect("existing grouped member should become active") + .role, + NativeTabRole::Active + ); + assert_eq!( + reactor.layout_manager.layout_engine.windows_in_active_workspace(space), + vec![second] + ); +} + +#[test] +fn dragging_tabbed_window_still_detects_swap_targets() { + let mut apps = Apps::new(); + let mut reactor = test_reactor(); + let space = SpaceId::new(332); + let screen = CGRect::new(CGPoint::new(0., 0.), CGSize::new(1000., 1000.)); + reactor.handle_event(screen_params_event(vec![screen], vec![Some(space)], vec![])); + + reactor.handle_events(apps.make_app(1, vec![make_window(1), make_window(2)])); + apps.simulate_until_quiet(&mut reactor); + let _ = apps.requests(); + + let old_wid = WindowId::new(1, 1); + let sibling_wid = WindowId::new(1, 2); + let (new_wid, replacement, replacement_ws_info) = replacement_tab(&reactor, old_wid, 3); + + assert!(create_native_tab_replacement( + &mut reactor, + space, + WindowServerId::new(1), + new_wid, + replacement, + replacement_ws_info, + )); + + let sibling_frame = reactor.window_manager.windows[&sibling_wid].frame_monotonic; + reactor.handle_event(Event::WindowFrameChanged( + new_wid, + sibling_frame, + None, + Requested(false), + Some(MouseState::Down), + )); + + assert_eq!(reactor.get_pending_drag_swap(), Some((new_wid, sibling_wid))); + + reactor.handle_event(Event::MouseUp); + assert!(reactor.get_pending_drag_swap().is_none()); +} + +#[test] +fn native_tab_window_destroy_is_deferred_until_replacement_is_discovered() { + let (_apps, mut reactor, space) = native_tab_test_setup(34); + let old_wid = WindowId::new(1, 1); + let old_frame = reactor.window_manager.windows[&old_wid].frame_monotonic; + let replacement_ws_info = WindowServerInfo { + id: WindowServerId::new(2), + pid: 1, + layer: 0, + frame: old_frame, + min_frame: CGSize::ZERO, + max_frame: CGSize::ZERO, + }; + + assert!(!reactor.note_native_tab_appearance( + WindowServerId::new(2), + space, + replacement_ws_info, + )); + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(1), space)); + assert!(!WindowEventHandler::handle_window_destroyed( + &mut reactor, + old_wid + )); + assert!(reactor.window_manager.windows.contains_key(&old_wid)); + assert_eq!( + reactor.layout_manager.layout_engine.windows_in_active_workspace(space), + vec![old_wid] + ); + + let (new_wid, replacement, _) = replacement_tab(&reactor, old_wid, 2); + WindowEventHandler::handle_window_created( + &mut reactor, + new_wid, + replacement, + Some(replacement_ws_info), + Some(MouseState::Up), + ); + reactor.reconcile_native_tabs_for_pid(1, &[]); + + assert_native_tab_switch_state(&mut reactor, space, old_wid, new_wid); +} + +#[test] +fn deferred_native_tab_destroy_finalizes_when_refresh_reports_no_visible_windows() { + let (mut apps, mut reactor, space) = native_tab_test_setup(35); + + let wid = WindowId::new(1, 1); + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(1), space)); + apps.windows.remove(&wid); + assert!(!WindowEventHandler::handle_window_destroyed(&mut reactor, wid)); + reactor + .app_manager + .apps + .get(&1) + .unwrap() + .handle + .send(Request::WindowMaybeDestroyed(wid)) + .unwrap(); + + for event in apps.simulate_events() { + reactor.handle_event(event); + } + + assert_window_removed_from_layout(&reactor, space, wid); +} + +#[test] +fn transient_empty_visibility_grace_is_one_shot_for_real_native_tab_close() { + let (mut apps, mut reactor, space) = native_tab_test_setup(350); + + let wid = WindowId::new(1, 1); + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(1), space)); + apps.windows.remove(&wid); + assert!(!WindowEventHandler::handle_window_destroyed(&mut reactor, wid)); + + reactor.handle_event(Event::ApplicationMainWindowChanged(1, None, Quiet::No)); + reactor.handle_event(Event::WindowsDiscovered { + pid: 1, + new: vec![], + known_visible: vec![], + }); + assert!( + reactor.window_manager.windows.contains_key(&wid), + "first empty refresh should be treated as a transient native-tab handoff" + ); + + reactor.handle_event(Event::WindowsDiscovered { + pid: 1, + new: vec![], + known_visible: vec![], + }); + assert_window_removed_from_layout(&reactor, space, wid); +} + +#[test] +fn transient_empty_visibility_requests_follow_up_refresh_to_finalize_last_tab_close() { + let (mut apps, mut reactor, space) = native_tab_test_setup(351); + + let wid = WindowId::new(1, 1); + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(1), space)); + apps.windows.remove(&wid); + assert!(!WindowEventHandler::handle_window_destroyed(&mut reactor, wid)); + + reactor.handle_event(Event::ApplicationMainWindowChanged(1, None, Quiet::No)); + reactor.handle_event(Event::WindowsDiscovered { + pid: 1, + new: vec![], + known_visible: vec![], + }); + assert!(reactor.window_manager.windows.contains_key(&wid)); + + apps.simulate_until_quiet(&mut reactor); + assert_window_removed_from_layout(&reactor, space, wid); +} + +#[test] +fn closing_last_tab_clears_stale_pending_native_tab_appearance_state() { + let (mut apps, mut reactor, space) = native_tab_test_setup(352); + + let wid = WindowId::new(1, 1); + let frame = reactor.window_manager.windows[&wid].frame_monotonic; + let phantom_wsid = WindowServerId::new(2); + assert!(!reactor.note_native_tab_appearance( + phantom_wsid, + space, + WindowServerInfo { + id: phantom_wsid, + pid: 1, + layer: 0, + frame, + min_frame: CGSize::ZERO, + max_frame: CGSize::ZERO, + }, + )); + + assert_eq!(reactor.native_tab_manager.pending_appearances_for_pid(1).len(), 1); + assert!(reactor.window_manager.visible_windows.contains(&phantom_wsid)); + assert!( + reactor + .window_server_info_manager + .window_server_info + .contains_key(&phantom_wsid) + ); + + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(1), space)); + apps.windows.remove(&wid); + assert!(!WindowEventHandler::handle_window_destroyed(&mut reactor, wid)); + + reactor.handle_event(Event::ApplicationMainWindowChanged(1, None, Quiet::No)); + reactor.handle_event(Event::WindowsDiscovered { + pid: 1, + new: vec![], + known_visible: vec![], + }); + apps.simulate_until_quiet(&mut reactor); + + assert_window_removed_from_layout(&reactor, space, wid); + assert!( + reactor.native_tab_manager.pending_appearances_for_pid(1).is_empty(), + "stale pending native-tab appearances should be cleared after true last-tab close" + ); + assert!( + !reactor.window_manager.visible_windows.contains(&phantom_wsid), + "phantom appeared wsid should be removed from visible window cache" + ); + assert!( + !reactor + .window_server_info_manager + .window_server_info + .contains_key(&phantom_wsid), + "phantom appeared wsid should be removed from window-server info cache" + ); +} + +#[test] +fn pending_native_tab_appearance_does_not_defer_regular_window_close() { + let (_apps, mut reactor, space) = native_tab_test_setup(36); + + let wid = WindowId::new(1, 1); + let frame = reactor.window_manager.windows[&wid].frame_monotonic; + reactor + .native_tab_manager + .stage_appearance(WindowServerId::new(2), 1, space, frame); + + assert!(WindowEventHandler::handle_window_destroyed(&mut reactor, wid)); + assert_window_removed_from_layout(&reactor, space, wid); +} + +#[test] +fn main_window_changed_same_frame_visible_sibling_without_pending_appearance_stays_standalone() { + let (_apps, mut reactor, space) = native_tab_window_and_space_setup(37, 2); + + let old_wid = WindowId::new(1, 1); + let new_wid = WindowId::new(1, 2); + let old_frame = reactor.window_manager.windows[&old_wid].frame_monotonic; + if let Some(window) = reactor.window_manager.windows.get_mut(&new_wid) { + window.frame_monotonic = old_frame; + } + + reactor.handle_event(Event::ApplicationMainWindowChanged(1, Some(new_wid), Quiet::No)); + + assert!(reactor.window_manager.windows[&old_wid].native_tab.is_none()); + assert!(reactor.window_manager.windows[&new_wid].native_tab.is_none()); + let mut windows = reactor.layout_manager.layout_engine.windows_in_active_workspace(space); + windows.sort_unstable(); + assert_eq!(windows, vec![old_wid, new_wid]); +} + +#[test] +fn pending_destroy_does_not_rekey_to_existing_same_frame_sibling_without_appearance() { + let (_apps, mut reactor, space) = native_tab_window_and_space_setup(38, 2); + + let old_wid = WindowId::new(1, 1); + let sibling_wid = WindowId::new(1, 2); + let old_frame = reactor.window_manager.windows[&old_wid].frame_monotonic; + if let Some(window) = reactor.window_manager.windows.get_mut(&sibling_wid) { + window.frame_monotonic = old_frame; + } + + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(1), space)); + assert!(!WindowEventHandler::handle_window_destroyed( + &mut reactor, + old_wid + )); + reactor.reconcile_native_tabs_for_pid(1, &[sibling_wid]); + + assert!(!reactor.window_manager.windows.contains_key(&old_wid)); + assert!(reactor.window_manager.windows[&sibling_wid].native_tab.is_none()); + assert_eq!( + reactor.layout_manager.layout_engine.windows_in_active_workspace(space), + vec![sibling_wid] + ); +} + +#[test] +fn known_same_size_window_appearance_does_not_stage_tab_signal_or_rekey() { + let (_apps, mut reactor, space) = native_tab_window_and_space_setup(42, 2); + + let old_wid = WindowId::new(1, 1); + let sibling_wid = WindowId::new(1, 2); + let old_frame = reactor.window_manager.windows[&old_wid].frame_monotonic; + if let Some(window) = reactor.window_manager.windows.get_mut(&sibling_wid) { + window.frame_monotonic = old_frame; + } + + assert!( + !reactor.note_native_tab_appearance(WindowServerId::new(2), space, WindowServerInfo { + id: WindowServerId::new(2), + pid: 1, + layer: 0, + frame: old_frame, + min_frame: CGSize::ZERO, + max_frame: CGSize::ZERO, + },) + ); + assert!( + reactor.native_tab_manager.pending_appearances_for_pid(1).is_empty(), + "known managed window appearance should not be retained as a native-tab signal" + ); + + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(1), space)); + assert!(!WindowEventHandler::handle_window_destroyed( + &mut reactor, + old_wid + )); + reactor.reconcile_native_tabs_for_pid(1, &[sibling_wid]); + + assert!(!reactor.window_manager.windows.contains_key(&old_wid)); + assert!(reactor.window_manager.windows[&sibling_wid].native_tab.is_none()); + assert_eq!( + reactor.layout_manager.layout_engine.windows_in_active_workspace(space), + vec![sibling_wid] + ); +} + +#[test] +fn native_tab_discovery_before_destroy_does_not_append_new_tab_to_layout() { + let (_apps, mut reactor, space) = native_tab_test_setup(39); + + let old_wid = WindowId::new(1, 1); + let old_frame = reactor.window_manager.windows[&old_wid].frame_monotonic; + let new_wid = WindowId::new(1, 2); + let mut replacement = make_window(2); + replacement.frame = old_frame; + replacement.sys_id = Some(WindowServerId::new(2)); + + assert!( + !reactor.note_native_tab_appearance(WindowServerId::new(2), space, WindowServerInfo { + id: WindowServerId::new(2), + pid: 1, + layer: 0, + frame: old_frame, + min_frame: CGSize::ZERO, + max_frame: CGSize::ZERO, + }) + ); + + reactor.handle_event(Event::WindowsDiscovered { + pid: 1, + new: vec![(new_wid, replacement)], + known_visible: vec![new_wid], + }); + + assert_eq!( + reactor.layout_manager.layout_engine.windows_in_active_workspace(space), + vec![old_wid] + ); + + reactor.handle_event(Event::ApplicationMainWindowChanged(1, Some(new_wid), Quiet::No)); + assert_native_tab_switch_state(&mut reactor, space, old_wid, new_wid); +} + +#[test] +fn unmatched_window_server_destroy_still_removes_closed_window() { + let (_apps, mut reactor, space) = native_tab_test_setup(31); + + let wid = WindowId::new(1, 1); + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(1), space)); + reactor.reconcile_native_tabs_for_pid(1, &[]); + + assert_window_removed_from_layout(&reactor, space, wid); +} + +#[test] +fn system_wake_finalizes_deferred_native_tab_destroy_after_close() { + let (mut apps, mut reactor, space) = native_tab_test_setup(40); + + let wid = WindowId::new(1, 1); + assert!(reactor.stage_native_tab_destroy(WindowServerId::new(1), space)); + apps.windows.remove(&wid); + assert!(!WindowEventHandler::handle_window_destroyed(&mut reactor, wid)); + + reactor.handle_event(Event::SystemWoke); + apps.simulate_until_quiet(&mut reactor); + + assert_window_removed_from_layout(&reactor, space, wid); +} + +#[test] +fn pending_refresh_empty_discovery_is_one_shot_and_allows_later_stale_cleanup() { + let (_apps, mut reactor, space) = native_tab_test_setup(41); + + let wid = WindowId::new(1, 1); + assert!(reactor.window_manager.windows.contains_key(&wid)); + reactor.active_spaces.clear(); + reactor.window_manager.visible_windows.clear(); + reactor.window_manager.window_ids.remove(&WindowServerId::new(1)); + + reactor.mission_control_manager.pending_mission_control_refresh.insert(1); + + reactor.handle_event(Event::WindowsDiscovered { + pid: 1, + new: vec![], + known_visible: vec![], + }); + + assert!(!reactor.mission_control_manager.pending_mission_control_refresh.contains(&1)); + assert!(reactor.window_manager.windows.contains_key(&wid)); + + reactor.handle_event(Event::WindowsDiscovered { + pid: 1, + new: vec![], + known_visible: vec![], + }); + + assert_window_removed_from_layout(&reactor, space, wid); +} + #[test] fn handle_layout_response_groups_windows_by_app_and_screen() { let mut apps = Apps::new(); @@ -539,10 +1606,7 @@ fn title_change_reapply_does_not_rebalance_when_window_stays_floating() { LayoutCommand::ToggleWindowFloating, ))); apps.simulate_until_quiet(&mut reactor); - assert!(reactor - .layout_manager - .layout_engine - .is_window_floating(WindowId::new(1, 1))); + assert!(reactor.layout_manager.layout_engine.is_window_floating(WindowId::new(1, 1))); let modified = reactor.layout_manager.layout_engine.calculate_layout( space, @@ -558,10 +1622,7 @@ fn title_change_reapply_does_not_rebalance_when_window_stays_floating() { "Renamed floating window".to_string(), )); - assert!(reactor - .layout_manager - .layout_engine - .is_window_floating(WindowId::new(1, 1))); + assert!(reactor.layout_manager.layout_engine.is_window_floating(WindowId::new(1, 1))); assert_eq!( reactor.layout_manager.layout_engine.calculate_layout( space, diff --git a/src/actor/reactor/transaction_manager.rs b/src/actor/reactor/transaction_manager.rs index 5684b03b..20318f98 100644 --- a/src/actor/reactor/transaction_manager.rs +++ b/src/actor/reactor/transaction_manager.rs @@ -60,4 +60,17 @@ impl TransactionManager { pub fn get_target_frame(&self, wsid: WindowServerId) -> Option { self.store.get(&wsid)?.target } + + pub fn rekey_window(&self, old_wsid: WindowServerId, new_wsid: WindowServerId) { + if old_wsid == new_wsid { + return; + } + if let Some(record) = self.store.get(&old_wsid) { + self.store.insert(new_wsid, record.txid, record.target.unwrap_or(CGRect::ZERO)); + if record.target.is_none() { + self.store.clear_target(&new_wsid); + } + } + self.store.remove(&old_wsid); + } } diff --git a/src/layout_engine/engine.rs b/src/layout_engine/engine.rs index 952625c9..0b89d87e 100644 --- a/src/layout_engine/engine.rs +++ b/src/layout_engine/engine.rs @@ -2441,6 +2441,53 @@ impl LayoutEngine { self.floating.is_floating(window_id) } + pub fn has_window_membership(&self, window_id: WindowId) -> bool { + if self.floating.is_floating(window_id) { + return true; + } + if self.window_layout_constraints.contains_key(&window_id) { + return true; + } + if self.virtual_workspace_manager.workspace_for_window_any(window_id).is_some() { + return true; + } + + self.workspace_layouts.active_layouts_with_workspace().into_iter().any( + |(workspace_id, layout)| { + self.workspace_tree(workspace_id).contains_window(layout, window_id) + }, + ) + } + + pub fn rekey_window(&mut self, old: WindowId, new: WindowId) -> bool { + if old == new { + return self.has_window_membership(old); + } + + let had_membership = self.has_window_membership(old) || self.focused_window == Some(old); + if !had_membership { + return false; + } + + self.workspace_layouts.active_layouts_with_workspace().into_iter().for_each( + |(workspace_id, layout)| { + self.workspace_tree_mut(workspace_id).rekey_window(layout, old, new); + }, + ); + + self.floating.rekey_window(old, new); + self.virtual_workspace_manager.rekey_window(old, new); + + if self.focused_window == Some(old) { + self.focused_window = Some(new); + } + if let Some(constraints) = self.window_layout_constraints.remove(&old) { + self.window_layout_constraints.insert(new, constraints); + } + + true + } + fn update_active_floating_windows(&mut self, space: SpaceId) { let windows_in_workspace = self.virtual_workspace_manager.windows_in_active_workspace(space); @@ -3081,4 +3128,44 @@ mod tests { before ); } + + #[test] + fn rekey_window_preserves_single_slot_focus_and_constraints() { + let mut engine = test_engine(); + let space = SpaceId::new(94); + let screen = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(1000.0, 1000.0)); + let pid: pid_t = 5153; + let old = WindowId::new(pid, 1); + let new = WindowId::new(pid, 2); + + let _ = engine.handle_event(LayoutEvent::SpaceExposed(space, screen.size)); + let _ = engine.handle_event(LayoutEvent::WindowsOnScreenUpdated( + space, + pid, + vec![( + old, + None, + None, + None, + true, + CGSize::new(500.0, 500.0), + None, + None, + )], + None, + )); + let _ = engine.handle_event(LayoutEvent::WindowFocused(space, old)); + + assert!(engine.rekey_window(old, new)); + assert_eq!(engine.selected_window(space), Some(new)); + assert_eq!(engine.windows_in_active_workspace(space), vec![new]); + + let gaps = engine.layout_settings.gaps.clone(); + let laid_out: HashMap = engine + .calculate_layout(space, screen, &gaps, 0.0, Default::default(), Default::default()) + .into_iter() + .collect(); + assert!(laid_out.contains_key(&new)); + assert!(!laid_out.contains_key(&old)); + } } diff --git a/src/layout_engine/floating.rs b/src/layout_engine/floating.rs index 09768d2b..62b44c5e 100644 --- a/src/layout_engine/floating.rs +++ b/src/layout_engine/floating.rs @@ -74,6 +74,24 @@ impl FloatingManager { pub(crate) fn last_focus(&self) -> Option { self.last_floating_focus } + pub(crate) fn rekey_window(&mut self, old: WindowId, new: WindowId) { + if self.floating_windows.remove(&old) { + self.floating_windows.insert(new); + } + + for space_map in self.active_floating_windows.values_mut() { + if let Some(app_set) = space_map.get_mut(&old.pid) + && app_set.remove(&old) + { + app_set.insert(new); + } + } + + if self.last_floating_focus == Some(old) { + self.last_floating_focus = Some(new); + } + } + pub(crate) fn remove_all_for_pid(&mut self, pid: pid_t) { let _ = self.floating_windows.remove_all_for_pid(pid); diff --git a/src/layout_engine/systems.rs b/src/layout_engine/systems.rs index c2628553..71f3ea0c 100644 --- a/src/layout_engine/systems.rs +++ b/src/layout_engine/systems.rs @@ -126,6 +126,37 @@ pub trait LayoutSystem: Serialize + for<'de> Deserialize<'de> { fn has_windows_for_app(&self, layout: LayoutId, pid: pid_t) -> bool; fn contains_window(&self, layout: LayoutId, wid: WindowId) -> bool; fn select_window(&mut self, layout: LayoutId, wid: WindowId) -> bool; + fn rekey_window(&mut self, layout: LayoutId, old: WindowId, new: WindowId) -> bool { + if old == new { + return self.contains_window(layout, old); + } + if old.pid != new.pid { + return false; + } + if !self.contains_window(layout, old) { + return false; + } + + let mut desired = self.windows_for_app(layout, old.pid); + let mut changed = false; + for wid in &mut desired { + if *wid == old { + *wid = new; + changed = true; + } + } + desired.retain(|wid| *wid != old); + let mut seen = std::collections::HashSet::new(); + desired.retain(|wid| seen.insert(*wid)); + + if changed { + self.set_windows_for_app(layout, old.pid, desired); + } + if self.selected_window(layout) == Some(old) { + let _ = self.select_window(layout, new); + } + true + } fn on_window_resized( &mut self, layout: LayoutId, diff --git a/src/layout_engine/systems/bsp.rs b/src/layout_engine/systems/bsp.rs index 7144653c..a51e4d89 100644 --- a/src/layout_engine/systems/bsp.rs +++ b/src/layout_engine/systems/bsp.rs @@ -740,7 +740,7 @@ impl Components { } #[cfg(test)] -mod tests { +mod rekey_tests { use super::*; fn w(idx: u32) -> WindowId { WindowId::new(1, idx) } @@ -1191,6 +1191,34 @@ impl LayoutSystem for BspLayoutSystem { false } + fn rekey_window(&mut self, layout: LayoutId, old: WindowId, new: WindowId) -> bool { + if old == new { + return self.contains_window(layout, old); + } + + let Some(state) = self.layouts.get(layout).copied() else { + return false; + }; + let Some(node) = self.node_for_window_mut(old) else { + return false; + }; + if !self.belongs_to_layout(state, node) { + return false; + } + if let Some(existing) = self.node_for_window(new) { + return existing == node; + } + + let Some(NodeKind::Leaf { window, .. }) = self.kind.get_mut(node) else { + return false; + }; + debug_assert_eq!(*window, Some(old)); + *window = Some(new); + self.unindex_window(old); + self.index_window(new, node); + true + } + fn on_window_resized( &mut self, layout: LayoutId, @@ -1623,3 +1651,40 @@ impl LayoutSystem for BspLayoutSystem { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn w(id: u32) -> WindowId { WindowId::new(1, id) } + + #[test] + fn rekey_window_preserves_leaf_and_selection() { + let mut system = BspLayoutSystem::default(); + let layout = system.create_layout(); + let w3 = w(3); + let old = w(1); + let w2 = w(2); + let new = w(9); + + system.add_window_after_selection(layout, w3); + system.add_window_after_selection(layout, old); + system.add_window_after_selection(layout, w2); + assert!(system.select_window(layout, old)); + + let before = system.visible_windows_in_layout(layout); + let old_node = system.node_for_window(old).expect("old node missing"); + + assert!(system.rekey_window(layout, old, new)); + + let after = system.visible_windows_in_layout(layout); + let replaced = before + .iter() + .map(|wid| if *wid == old { new } else { *wid }) + .collect::>(); + assert_eq!(after, replaced); + assert_eq!(system.selected_window(layout), Some(new)); + assert_eq!(system.node_for_window(new), Some(old_node)); + assert_eq!(system.node_for_window(old), None); + } +} diff --git a/src/layout_engine/systems/master_stack.rs b/src/layout_engine/systems/master_stack.rs index 8b2c532b..d2c2043a 100644 --- a/src/layout_engine/systems/master_stack.rs +++ b/src/layout_engine/systems/master_stack.rs @@ -644,6 +644,10 @@ impl LayoutSystem for MasterStackLayoutSystem { self.inner.select_window(layout, wid) } + fn rekey_window(&mut self, layout: LayoutId, old: WindowId, new: WindowId) -> bool { + self.inner.rekey_window(layout, old, new) + } + fn on_window_resized( &mut self, layout: LayoutId, @@ -766,3 +770,36 @@ impl LayoutSystem for MasterStackLayoutSystem { fn toggle_tile_orientation(&mut self, layout: LayoutId) { self.normalize_layout(layout); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn w(id: u32) -> WindowId { WindowId::new(1, id) } + + #[test] + fn rekey_window_preserves_master_stack_order_and_selection() { + let mut system = MasterStackLayoutSystem::new(MasterStackSettings::default()); + let layout = system.create_layout(); + let w3 = w(3); + let old = w(1); + let w2 = w(2); + let new = w(9); + + system.add_window_after_selection(layout, w3); + system.add_window_after_selection(layout, old); + system.add_window_after_selection(layout, w2); + assert!(system.select_window(layout, old)); + + let before = system.visible_windows_in_layout(layout); + assert!(system.rekey_window(layout, old, new)); + + let after = system.visible_windows_in_layout(layout); + let replaced = before + .iter() + .map(|wid| if *wid == old { new } else { *wid }) + .collect::>(); + assert_eq!(after, replaced); + assert_eq!(system.selected_window(layout), Some(new)); + } +} diff --git a/src/layout_engine/systems/scrolling.rs b/src/layout_engine/systems/scrolling.rs index f38458da..c5b28b51 100644 --- a/src/layout_engine/systems/scrolling.rs +++ b/src/layout_engine/systems/scrolling.rs @@ -192,6 +192,33 @@ impl LayoutState { self.selected } + fn rekey_window(&mut self, old: WindowId, new: WindowId) -> bool { + if old == new { + return self.locate(old).is_some(); + } + let Some((col_idx, row_idx)) = self.locate(old) else { + return false; + }; + if self.locate(new).is_some() { + return false; + } + + self.columns[col_idx].windows[row_idx] = new; + if self.selected == Some(old) { + self.selected = Some(new); + } + if self.center_override_window == Some(old) { + self.center_override_window = Some(new); + } + if self.fullscreen.remove(&old) { + self.fullscreen.insert(new); + } + if self.fullscreen_within_gaps.remove(&old) { + self.fullscreen_within_gaps.insert(new); + } + true + } + fn insert_column_after(&mut self, index: usize, wid: WindowId) { let column = Column { windows: vec![wid], @@ -1058,6 +1085,12 @@ impl LayoutSystem for ScrollingLayoutSystem { } } + fn rekey_window(&mut self, layout: LayoutId, old: WindowId, new: WindowId) -> bool { + self.layout_state_mut(layout) + .map(|state| state.rekey_window(old, new)) + .unwrap_or(false) + } + fn on_window_resized( &mut self, layout: LayoutId, @@ -2013,4 +2046,37 @@ mod tests { after.origin.x ); } + + #[test] + fn rekey_window_preserves_column_slot_and_window_state() { + let mut settings = ScrollingLayoutSettings::default(); + settings.focus_navigation_style = + crate::common::config::ScrollingFocusNavigationStyle::Niri; + let (mut system, layout, w1, old) = setup_two_windows(settings); + let new = WindowId::new(old.pid, 9); + + system.center_selected_column(layout); + let _ = system.toggle_fullscreen_of_selection(layout); + + let before = system.visible_windows_in_layout(layout); + assert!(system.rekey_window(layout, old, new)); + + let state = system.layouts.get(layout).expect("layout state missing"); + let replaced = before + .iter() + .map(|wid| if *wid == old { new } else { *wid }) + .collect::>(); + let after = state + .columns + .iter() + .flat_map(|column| column.windows.iter().copied()) + .collect::>(); + + assert_eq!(after, replaced); + assert_eq!(state.selected, Some(new)); + assert_eq!(state.center_override_window, Some(new)); + assert!(state.fullscreen.contains(&new)); + assert!(!state.fullscreen.contains(&old)); + assert!(after.contains(&w1)); + } } diff --git a/src/layout_engine/systems/stack.rs b/src/layout_engine/systems/stack.rs index 3286bb56..cc960b62 100644 --- a/src/layout_engine/systems/stack.rs +++ b/src/layout_engine/systems/stack.rs @@ -262,6 +262,10 @@ impl LayoutSystem for StackLayoutSystem { self.inner.select_window(layout, wid) } + fn rekey_window(&mut self, layout: LayoutId, old: WindowId, new: WindowId) -> bool { + self.inner.rekey_window(layout, old, new) + } + fn on_window_resized( &mut self, layout: LayoutId, @@ -448,4 +452,30 @@ mod tests { system.unjoin_selection(layout); assert!(system.has_any_fullscreen_node(layout)); } + + #[test] + fn rekey_window_preserves_stack_order_and_selection() { + let mut system = StackLayoutSystem::new(StackDefaultOrientation::Perpendicular); + let layout = system.create_layout(); + let w3 = w(3); + let old = w(1); + let w2 = w(2); + let new = w(9); + + system.add_window_after_selection(layout, w3); + system.add_window_after_selection(layout, old); + system.add_window_after_selection(layout, w2); + assert!(system.select_window(layout, old)); + + let before = system.visible_windows_in_layout(layout); + assert!(system.rekey_window(layout, old, new)); + + let after = system.visible_windows_in_layout(layout); + let replaced = before + .iter() + .map(|wid| if *wid == old { new } else { *wid }) + .collect::>(); + assert_eq!(after, replaced); + assert_eq!(system.selected_window(layout), Some(new)); + } } diff --git a/src/layout_engine/systems/traditional.rs b/src/layout_engine/systems/traditional.rs index 4111e5d8..bc6a619f 100644 --- a/src/layout_engine/systems/traditional.rs +++ b/src/layout_engine/systems/traditional.rs @@ -665,6 +665,10 @@ impl LayoutSystem for TraditionalLayoutSystem { } } + fn rekey_window(&mut self, layout: LayoutId, old: WindowId, new: WindowId) -> bool { + self.tree.data.window.rekey_window(layout, old, new) + } + fn on_window_resized( &mut self, layout: LayoutId, @@ -2118,6 +2122,43 @@ impl WindowIndex { .push(WindowNodeInfo { layout, node }); } + pub(crate) fn rekey_window(&mut self, layout: LayoutId, old: WindowId, new: WindowId) -> bool { + if old == new { + return self.node_for(layout, old).is_some(); + } + + let Some(node) = self.node_for(layout, old) else { + return false; + }; + + if let Some(existing) = self.node_for(layout, new) { + return existing == node; + } + + let moved = { + let Some(window_nodes) = self.window_nodes.get_mut(&old) else { + return false; + }; + let Some(index) = window_nodes + .0 + .iter() + .position(|info| info.layout == layout && info.node == node) + else { + return false; + }; + window_nodes.0.remove(index) + }; + + if self.window_nodes.get(&old).is_some_and(|info| info.0.is_empty()) { + self.window_nodes.remove(&old); + } + + let previous = self.windows.insert(node, new); + debug_assert_eq!(previous, Some(old)); + self.window_nodes.entry(new).or_default().0.push(moved); + true + } + fn take_nodes_for(&mut self, wid: WindowId) -> impl Iterator { self.window_nodes .remove(&wid) @@ -4992,4 +5033,34 @@ mod tests { .expect("right node proportion missing"); assert_eq!(before, after); } + + #[test] + fn rekey_window_preserves_node_and_selection() { + let mut system = TraditionalLayoutSystem::default(); + let layout = system.create_layout(); + let w3 = w(3); + let old = w(1); + let w2 = w(2); + let new = w(9); + + system.add_window_after_selection(layout, w3); + system.add_window_after_selection(layout, old); + system.add_window_after_selection(layout, w2); + assert!(system.select_window(layout, old)); + + let old_node = system.tree.data.window.node_for(layout, old).expect("old node missing"); + let before = system.visible_windows_in_layout(layout); + + assert!(system.rekey_window(layout, old, new)); + + let after = system.visible_windows_in_layout(layout); + let replaced = before + .iter() + .map(|wid| if *wid == old { new } else { *wid }) + .collect::>(); + assert_eq!(after, replaced); + assert_eq!(system.selected_window(layout), Some(new)); + assert_eq!(system.tree.data.window.node_for(layout, old), None); + assert_eq!(system.tree.data.window.node_for(layout, new), Some(old_node)); + } } diff --git a/src/model/reactor.rs b/src/model/reactor.rs index 6c4d4a8c..15336664 100644 --- a/src/model/reactor.rs +++ b/src/model/reactor.rs @@ -150,6 +150,7 @@ pub(crate) struct WindowState { pub(crate) frame_monotonic: CGRect, pub(crate) is_manageable: bool, pub(crate) ignore_app_rule: bool, + pub(crate) native_tab: Option, } impl From for WindowState { @@ -159,23 +160,46 @@ impl From for WindowState { info, is_manageable: false, ignore_app_rule: false, + native_tab: None, } } } impl WindowState { + pub(crate) fn is_native_tab_suppressed(&self) -> bool { + matches!( + self.native_tab, + Some(NativeTabMembership { + role: NativeTabRole::Suppressed, + .. + }) + ) + } + pub(crate) fn is_effectively_manageable(&self) -> bool { - self.is_manageable && !self.ignore_app_rule + self.is_manageable && !self.ignore_app_rule && !self.is_native_tab_suppressed() } pub(crate) fn matches_filter(&self, filter: WindowFilter) -> bool { match filter { - WindowFilter::Manageable => self.is_manageable, + WindowFilter::Manageable => self.is_manageable && !self.is_native_tab_suppressed(), WindowFilter::EffectivelyManageable => self.is_effectively_manageable(), } } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct NativeTabMembership { + pub(crate) group_id: u32, + pub(crate) role: NativeTabRole, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NativeTabRole { + Active, + Suppressed, +} + #[derive(Clone, Copy, Debug)] pub(crate) enum WindowFilter { Manageable, diff --git a/src/model/virtual_workspace.rs b/src/model/virtual_workspace.rs index 35b22a05..586a9026 100644 --- a/src/model/virtual_workspace.rs +++ b/src/model/virtual_workspace.rs @@ -128,6 +128,15 @@ impl VirtualWorkspace { self.windows.remove(&window_id) } + pub fn rekey_window(&mut self, old: WindowId, new: WindowId) { + if self.windows.remove(&old) { + self.windows.insert(new); + } + if self.last_focused == Some(old) { + self.last_focused = Some(new); + } + } + pub fn set_last_focused(&mut self, window_id: Option) { self.last_focused = window_id; } @@ -1085,6 +1094,35 @@ impl VirtualWorkspaceManager { } } + pub fn rekey_window(&mut self, old: WindowId, new: WindowId) { + let workspace_keys: Vec<(SpaceId, WindowId, VirtualWorkspaceId)> = self + .window_to_workspace + .iter() + .filter_map(|(&(space, wid), &workspace_id)| { + (wid == old).then_some((space, wid, workspace_id)) + }) + .collect(); + + for (space, wid, workspace_id) in workspace_keys { + self.window_to_workspace.remove(&(space, wid)); + self.window_to_workspace.insert((space, new), workspace_id); + if let Some(workspace) = self.workspaces.get_mut(workspace_id) { + workspace.rekey_window(old, new); + } + + if let Some(rule) = self.window_rule_floating.remove(&(space, old)) { + self.window_rule_floating.insert((space, new), rule); + } + if let Some(last_rule) = self.last_rule_decision.remove(&(space, old)) { + self.last_rule_decision.insert((space, new), last_rule); + } + } + + for positions in self.floating_positions.values_mut() { + positions.rekey_window(old, new); + } + } + pub fn list_workspaces(&mut self, space: SpaceId) -> Vec<(VirtualWorkspaceId, String)> { self.ensure_space_initialized(space); let ids = self.workspaces_by_space.get(&space).cloned().unwrap_or_default(); @@ -1532,6 +1570,12 @@ impl FloatingWindowPositions { fn remove_app_windows(&mut self, pid: pid_t) { self.positions.retain(|window_id, _| window_id.pid != pid); } + + fn rekey_window(&mut self, old: WindowId, new: WindowId) { + if let Some(position) = self.positions.remove(&old) { + self.positions.insert(new, position); + } + } } #[derive(Debug, Clone)]