diff --git a/crates/ide-workspace/src/window_groups.rs b/crates/ide-workspace/src/window_groups.rs index da095146b..53b61d1ae 100644 --- a/crates/ide-workspace/src/window_groups.rs +++ b/crates/ide-workspace/src/window_groups.rs @@ -84,16 +84,24 @@ impl WindowGroups { Ok(before) } + /// The member `detach(member)` will make active: the selection, or the selected member's neighbour. + pub fn successor(&self, member: &str) -> Option { + let group = self.group(member)?; + if group.selected != member { + return Some(group.selected.clone()); + } + let index = group.members.iter().position(|m| m == member)?; + let next = if index + 1 < group.members.len() { index + 1 } else { index.checked_sub(1)? }; + Some(group.members[next].clone()) + } + /// Returns the remaining active member, including when the last pair dissolves. pub fn detach(&mut self, member: &str) -> Option { + let selected = self.successor(member)?; let i = self.groups.iter().position(|g| g.members.iter().any(|m| m == member))?; let group = &mut self.groups[i]; - let index = group.members.iter().position(|m| m == member).unwrap(); - group.members.remove(index); - if group.selected == member { - group.selected = group.members[index.min(group.members.len() - 1)].clone(); - } - let selected = group.selected.clone(); + group.members.retain(|m| m != member); + group.selected = selected.clone(); if group.members.len() < 2 { self.groups.remove(i); } @@ -213,6 +221,19 @@ mod tests { assert!(groups.group("a").is_none()); } + #[test] + fn the_successor_is_the_member_detach_selects() { + let mut groups = WindowGroups::default(); + groups.merge(members(&["a", "b", "c"]), "b").unwrap(); + assert_eq!(groups.successor("a"), Some("b".into())); + assert_eq!(groups.successor("b"), Some("c".into())); + groups.select("a", "c").unwrap(); + assert_eq!(groups.successor("c"), Some("b".into())); + assert_eq!(groups.successor("unrelated"), None); + assert_eq!(groups.detach("c"), Some("b".into())); + assert_eq!(groups.group("a").unwrap().selected, "b"); + } + #[test] fn merging_groups_and_reordering_keep_unique_members() { let mut groups = WindowGroups::default(); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ecb5fd3a8..9b9173e6f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -87,6 +87,7 @@ pub fn builder() -> tauri_specta::Builder { window_group::window_group_close, window_group::window_group_close_tabs, window_group::window_group_cancel_close, + window_group::window_group_before_close, window_group::window_group_reorder, window_group::window_group_restore, app::light_edit_open, diff --git a/src-tauri/src/commands/window_group.rs b/src-tauri/src/commands/window_group.rs index f6d4d29ae..f016e9519 100644 --- a/src-tauri/src/commands/window_group.rs +++ b/src-tauri/src/commands/window_group.rs @@ -55,6 +55,20 @@ pub fn window_group_cancel_close(window: WebviewWindow) { groups::cancel_close(window.app_handle(), window.label()); } +/// This window closes next (its prompts passed): a fullscreen group's host first hands its Space +/// to the tab that takes over. +#[tauri::command] +#[specta::specta] +pub async fn window_group_before_close(window: WebviewWindow) -> IdeResult<()> { + #[cfg(target_os = "macos")] + return groups::before_close(window.app_handle(), window.label()).await; + #[cfg(not(target_os = "macos"))] + { + let _ = window; + Ok(()) + } +} + #[tauri::command] #[specta::specta] pub async fn window_group_reorder(window: WebviewWindow, labels: Vec) -> IdeResult<()> { diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 3d32a8de1..7b6a63add 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -173,6 +173,44 @@ pub fn watch_fullscreen_host(host: &WebviewWindow, left: impl Fn() + Send + 'sta }) } +thread_local! { + /// Windows being waited on to finish entering fullscreen. + static ENTERING_FULLSCREEN: std::cell::RefCell> = std::cell::RefCell::default(); +} + +/// Toggles `window` into native fullscreen and resolves once it is there, or after `timeout`. +pub async fn enter_fullscreen_and_wait(window: &WebviewWindow, timeout: std::time::Duration) -> tauri::Result<()> { + let (tx, rx) = tokio::sync::oneshot::channel(); + let label = window.label().to_owned(); + let target = window.clone(); + on_main(window, move || unsafe { + let Ok(pointer) = target.ns_window() else { return }; + let native = &*pointer.cast::(); + let tx = std::cell::Cell::new(Some(tx)); + let block = block2::RcBlock::new(move |_| { + if let Some(tx) = tx.take() { + let _ = tx.send(()); + } + }); + let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( + Some(objc2_app_kit::NSWindowDidEnterFullScreenNotification), + Some(native), + None, + &block, + ); + ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.insert(label, FullscreenObserver(observer))); + })?; + let toggled = native_toggle_fullscreen(window); + if toggled.is_ok() { + let _ = tokio::time::timeout(timeout, rx).await; + } + let label = window.label().to_owned(); + on_main(window, move || { + ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.remove(&label)); + })?; + toggled +} + /// An observer on the workspace's notification center, removed when dropped. struct WorkspaceObserver(Retained, Retained>); diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 0601fb32f..70ea2dd37 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -19,6 +19,9 @@ const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100); /// Longest wait for a closed host's fullscreen Space to go before the next tab takes the screen. #[cfg(target_os = "macos")] const SPACE_TEARDOWN: Duration = Duration::from_millis(1500); +/// Longest wait for the tab taking over from a closing host to enter its own fullscreen Space. +#[cfg(target_os = "macos")] +const SPACE_HANDOVER: Duration = Duration::from_millis(2000); /// Coalesces the move/resize events a drag emits. static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0); @@ -327,6 +330,13 @@ pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult { /// Makes `host` the fullscreen window of its group, or of its own Space when ungrouped. #[cfg(target_os = "macos")] fn host_fullscreen(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { + prepare_host(app, host)?; + crate::macos::native_toggle_fullscreen(host).map_err(|e| IdeError::internal(e.to_string())) +} + +/// `host` and the other members of its group, ready for `host` to enter fullscreen. +#[cfg(target_os = "macos")] +fn prepare_host(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { let err = |e: tauri::Error| IdeError::internal(e.to_string()); let members = app.state::().groups.lock().group(host.label()).map(|g| g.members.clone()); crate::macos::set_fullscreen_auxiliary(host, None).map_err(err)?; @@ -338,8 +348,29 @@ fn host_fullscreen(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { if members.is_some() { watch_host(app, host)?; } - host.show().map_err(err)?; - crate::macos::native_toggle_fullscreen(host).map_err(err) + host.show().map_err(err) +} + +/// A fullscreen group's host is about to close, past its prompts. The tab that takes over leaves +/// for a Space of its own first, so the screen moves straight to it; were the host closed first, +/// its Space would end and show the desktop until the next one opened. +#[cfg(target_os = "macos")] +pub async fn before_close(app: &AppHandle, caller: &str) -> IdeResult<()> { + let state = app.state::(); + let Some(members) = state.groups.lock().group(caller).map(|g| g.members.clone()) else { return Ok(()) }; + if state.quitting() || fullscreen_host(app, &members).is_none_or(|host| host.label() != caller) { + return Ok(()); + } + // Detached now, the closing host leaves nothing for `destroyed` to hand over. + let next = state.groups.lock().detach(caller); + changed(app); + let Some(next) = next.and_then(|label| app.get_webview_window(&label)) else { return Ok(()) }; + prepare_host(app, &next)?; + crate::macos::enter_fullscreen_and_wait(&next, SPACE_HANDOVER) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + windows::focus(&next); + Ok(()) } #[cfg(target_os = "macos")] diff --git a/src/bindings.ts b/src/bindings.ts index 91103e03b..f185b0b2f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -42,6 +42,11 @@ export const commands = { windowGroupCloseTabs: (keep: string, labels: string[]) => typedError(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })), /** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */ windowGroupCancelClose: () => __TAURI_INVOKE("window_group_cancel_close"), + /** + * This window closes next (its prompts passed): a fullscreen group's host first hands its Space + * to the tab that takes over. + */ + windowGroupBeforeClose: () => typedError(__TAURI_INVOKE("window_group_before_close")), windowGroupReorder: (labels: string[]) => typedError(__TAURI_INVOKE("window_group_reorder", { labels })), windowGroupRestore: () => typedError(__TAURI_INVOKE("window_group_restore")), /** diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 084fb5244..448435502 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -205,6 +205,7 @@ async function onCloseRequested(deps: CloseDeps): Promise { deps, ); if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose()); + else if (!ctx.quitting) await commands.windowGroupBeforeClose(); return ok; } catch (e) { maybeEditorService()?.cancelClose?.();