diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 9994abe0..d1d99d65 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -214,7 +214,9 @@ pub fn app_cancel_quit(app: AppHandle, windows: State<'_, WindowsState>) { }); } -/// Sets the menu's labels / shortcut text (on locale or keymap change) and rebuilds it. +/// Sets the menu's labels / shortcut text and rebuilds it when they changed. +/// Focusing another project window re-pushes the same shared menu; an identical +/// config must not call `set_menu`, which redraws the macOS menu bar. #[tauri::command] #[specta::specta] pub fn menu_configure( @@ -223,8 +225,9 @@ pub fn menu_configure( state: State<'_, AppState>, config: MenuConfig, ) { - *menu_state.config.lock() = config; - menu::install(&app, &menu_state, &state.recent.list()); + if menu::store_config(&menu_state, config) { + menu::install(&app, &menu_state, &state.recent.list()); + } } /// Rebuilds the menu (e.g. after the recent-projects list changed). diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 1a5ce3ca..7deeb54b 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -7,8 +7,9 @@ use objc2::{ rc::Retained, runtime::{AnyClass, AnyObject, Imp, ProtocolObject, Sel}, }; -use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference}; +use objc2_app_kit::{NSWindow, NSWindowAnimationBehavior, NSWindowUserTabbingPreference}; use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSRect}; +use std::cell::Cell; use tauri::{AppHandle, Manager, WebviewWindow}; fn tabbing_preference(app: &AppHandle) -> IdeResult { @@ -275,16 +276,102 @@ type ConstrainFrameRect = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, NSRec static NATIVE_CONSTRAIN_FRAME_RECT: std::sync::OnceLock = std::sync::OnceLock::new(); +thread_local! { + /// Native window whose next frame assignment must keep the rectangle it was given. + static EXACT_FRAME: Cell = const { Cell::new(0) }; +} + +struct ExactFrame(usize); + +impl ExactFrame { + fn begin(native: usize) -> Self { + EXACT_FRAME.with(|slot| slot.set(native)); + Self(native) + } +} + +impl Drop for ExactFrame { + fn drop(&mut self) { + EXACT_FRAME.with(|slot| { + if slot.get() == self.0 { + slot.set(0); + } + }); + } +} + +/// Copies `from`'s frame onto `to`. `setFrame:display:animate:` finishes before it returns; +/// tao's position and size setters only schedule the change. +pub fn copy_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> { + apply_window_frame(from, to, false) +} + +/// Copies the frame and orders `to` front before AppKit can animate or constrain it. +/// A later `show` runs `constrainFrameRect:toScreen:` and slides a titled window into place. +pub fn present_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> { + apply_window_frame(from, to, true) +} + +fn apply_window_frame(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> { + if MainThreadMarker::new().is_some() { + return apply_window_frame_on_main(from, to, show); + } + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let from = from.clone(); + let to = to.clone(); + to.clone().run_on_main_thread(move || { + let _ = tx.send(apply_window_frame_on_main(&from, &to, show)); + })?; + rx.recv().map_err(|error| tauri::Error::Io(std::io::Error::other(error.to_string())))? +} + +fn apply_window_frame_on_main(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> { + let source_pointer = from.ns_window()?; + let target_pointer = to.ns_window()?; + unsafe { + let source = &*source_pointer.cast::(); + let target = &*target_pointer.cast::(); + let frame = source.frame(); + let current = target.frame(); + let differs = frame.origin.x != current.origin.x + || frame.origin.y != current.origin.y + || frame.size.width != current.size.width + || frame.size.height != current.size.height; + if !differs && !show { + return Ok(()); + } + // Zoom and ordering use the window's animation behavior. `animate: false` alone does not + // stop a zoomed window from sliding to the new frame as it is shown. + let behavior = target.animationBehavior(); + target.setAnimationBehavior(NSWindowAnimationBehavior::None); + let result = (|| { + let _exact = ExactFrame::begin(target_pointer as usize); + if differs { + target.setFrame_display_animate(frame, false, false); + } + if show { + to.show()?; + to.set_focus()?; + } + Ok(()) + })(); + target.setAnimationBehavior(behavior); + result + } +} + /// AppKit keeps a titled window's top edge below the menu bar, and on a notched display below the /// camera housing, whenever the window is placed, shown or resized. A window in simple fullscreen -/// is titled for its buttons but must still cover the whole screen. +/// is titled for its buttons but must still cover the whole screen. A group-tab frame copy also +/// keeps the rectangle it was given, so the window about to be shown is not shifted. unsafe extern "C-unwind" fn constrain_frame_rect( this: *mut AnyObject, selector: Sel, frame: NSRect, screen: *mut AnyObject, ) -> NSRect { - if COVERING.with_borrow(|windows| windows.values().any(|covering| covering.native == this as usize)) { + let exact = EXACT_FRAME.with(|slot| slot.get() == this as usize); + if exact || COVERING.with_borrow(|windows| windows.values().any(|covering| covering.native == this as usize)) { return frame; } match NATIVE_CONSTRAIN_FRAME_RECT.get() { diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 8b9fc95a..efebf4d3 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -895,7 +895,7 @@ pub fn action_ids(spec: &[Entry]) -> Vec<&'static str> { } /// Localised labels and displayed shortcuts, pushed by the frontend. -#[derive(Debug, Clone, Default, Serialize, Deserialize, Type)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct MenuConfig { pub locale: String, @@ -961,6 +961,17 @@ pub struct MenuState { pub disabled: Mutex>, } +/// Stores `next` when it differs from the config last used to build the menu. +/// Callers must not replace the menu on `false`: `set_menu` redraws the macOS menu bar. +pub fn store_config(state: &MenuState, next: MenuConfig) -> bool { + let mut current = state.config.lock(); + if *current == next { + return false; + } + *current = next; + true +} + /// The action an item id stands for, if it is an IDE action item. fn item_action(id: &str) -> Option<&str> { if id.starts_with(RECENT_PREFIX) || id == "recent.none" || id.starts_with("submenu:") { @@ -1267,6 +1278,16 @@ mod tests { } } + #[test] + fn unchanged_menu_config_is_not_rebuilt() { + let state = MenuState::default(); + let mut config = MenuConfig { locale: "en".into(), ..MenuConfig::default() }; + assert!(store_config(&state, config.clone())); + assert!(!store_config(&state, config.clone())); + config.labels.insert("menu.file".into(), "File".into()); + assert!(store_config(&state, config)); + } + #[test] fn recent_items_route_to_open_recent_with_path() { let spec = spec(Os::Mac); diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 5cdb52a8..993ad606 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -56,30 +56,76 @@ fn changed(app: &AppHandle) { app.state::().sink.emit(IdeEvent::WindowGroupsChanged); } -/// Whether `to` already presents `from`'s frame, so moving it would only make it flicker. -fn same_frame(from: &WebviewWindow, to: &WebviewWindow) -> bool { - let (Ok(from_max), Ok(to_max)) = (from.is_maximized(), to.is_maximized()) else { return false }; - let (Ok(from_full), Ok(to_full)) = (from.is_fullscreen(), to.is_fullscreen()) else { return false }; - if from_max != to_max || from_full != to_full { - return false; +/// Outer frame in physical pixels. A maximized window is represented by the rectangle it +/// occupies, not by the maximized flag: that flag can match while the rectangles do not. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct OuterFrame { + x: i32, + y: i32, + width: u32, + height: u32, +} + +/// Where a group member can be placed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FrameHost { + /// The client can move and resize the window. + Desktop, + /// Wayland does not let clients place top-level windows. + #[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))] + Wayland, + /// macOS simple fullscreen already covers the screen; rewriting the frame would shrink it. + SimpleFullscreen, + /// Native fullscreen owns a Space. A different frame is transferred, not assigned. + NativeFullscreen, +} + +/// What to write before the target window is shown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FrameWrite { + /// Already the same rectangle, or this host must not be placed. + Leave, + TransferFullscreen, + Assign(OuterFrame), +} + +/// Two windows are aligned only when their outer rectangles match. Maximized or fullscreen +/// flags are not a substitute: `show` presents the current rectangle, and a later move is visible. +fn frame_write(host: FrameHost, from: OuterFrame, to: OuterFrame) -> FrameWrite { + match host { + FrameHost::Wayland | FrameHost::SimpleFullscreen => FrameWrite::Leave, + FrameHost::NativeFullscreen if from == to => FrameWrite::Leave, + FrameHost::NativeFullscreen => FrameWrite::TransferFullscreen, + FrameHost::Desktop if from == to => FrameWrite::Leave, + FrameHost::Desktop => FrameWrite::Assign(from), } - let same_monitor = match (from.current_monitor(), to.current_monitor()) { - (Ok(Some(a)), Ok(Some(b))) => a.position() == b.position() && a.size() == b.size(), - // An unmapped window may not report a monitor; treat only equal states as matching. - (Ok(None), Ok(None)) | (Err(_), Err(_)) => true, - _ => false, - }; - if !same_monitor { - return false; +} + +fn outer_frame(window: &WebviewWindow) -> IdeResult { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + let position = window.outer_position().map_err(err)?; + let size = window.outer_size().map_err(err)?; + Ok(OuterFrame { x: position.x, y: position.y, width: size.width, height: size.height }) +} + +fn frame_host(from: &WebviewWindow) -> IdeResult { + if simple_fullscreen(from.label()) { + return Ok(FrameHost::SimpleFullscreen); } - from_full - || from_max - || (from.outer_position().ok() == to.outer_position().ok() && from.inner_size().ok() == to.inner_size().ok()) + #[cfg(target_os = "linux")] + if crate::linux_window_state::is_wayland() { + return Ok(FrameHost::Wayland); + } + if from.is_fullscreen().map_err(|error| IdeError::internal(error.to_string()))? { + return Ok(FrameHost::NativeFullscreen); + } + Ok(FrameHost::Desktop) } /// Moves a hidden window onto `from`'s monitor. A restored window keeps its normal position on /// the monitor it last occupied; maximizing or going fullscreen there would make the window -/// visibly jump across displays when its tab is selected. +/// visibly jump across displays when its tab is selected. macOS copies the AppKit frame instead. +#[cfg(not(target_os = "macos"))] fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { // Wayland does not let clients place top-level windows; maximizing stays on that output. #[cfg(target_os = "linux")] @@ -93,47 +139,91 @@ fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { Ok(()) } +/// Gives `to` the source window's outer frame before `to` is shown. +/// +/// Tauri's position, size and maximize calls are queued. On macOS the AppKit calls return +/// before the frame changes, while `show` orders the window front immediately, so the old +/// rectangle appears and then jumps. The frame is written synchronously instead. fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { - if same_frame(from, to) { + let host = frame_host(from)?; + if matches!(host, FrameHost::Wayland | FrameHost::SimpleFullscreen) { return Ok(()); } - let err = |e: tauri::Error| IdeError::internal(e.to_string()); - let fullscreen = from.is_fullscreen().map_err(err)?; - if fullscreen { - #[cfg(target_os = "macos")] - crate::macos::transfer_fullscreen(from, to).map_err(err)?; - #[cfg(not(target_os = "macos"))] - { - to.set_fullscreen(false).map_err(err)?; - place_on_monitor(from, to)?; - from.set_fullscreen(false).map_err(err)?; - to.set_fullscreen(true).map_err(err)?; - } - return Ok(()); + // Tauri's physical rectangles round through the scale factor. On macOS the AppKit frame is + // the one `show` will present, so compare that instead of skipping a real difference. + #[cfg(target_os = "macos")] + if host == FrameHost::Desktop { + return crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string())); } - to.set_fullscreen(false).map_err(err)?; - let maximized = from.is_maximized().map_err(err)?; - if !maximized { - to.unmaximize().map_err(err)?; - to.set_position(from.outer_position().map_err(err)?).map_err(err)?; - // `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland. - #[cfg(target_os = "linux")] - let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?; - #[cfg(not(target_os = "linux"))] - let size = from.inner_size().map_err(err)?; - #[cfg(target_os = "linux")] - crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size); - to.set_size(size).map_err(err)?; - } else { - // Maximize the target on the source's monitor, not on the monitor it happened to - // occupy while hidden. - to.unmaximize().map_err(err)?; + match frame_write(host, outer_frame(from)?, outer_frame(to)?) { + FrameWrite::Leave => Ok(()), + FrameWrite::TransferFullscreen => transfer_native_fullscreen(from, to), + FrameWrite::Assign(frame) => assign_outer_frame(from, to, frame), + } +} + +fn transfer_native_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + #[cfg(target_os = "macos")] + crate::macos::transfer_fullscreen(from, to).map_err(err)?; + #[cfg(not(target_os = "macos"))] + { + to.set_fullscreen(false).map_err(err)?; place_on_monitor(from, to)?; - to.maximize().map_err(err)?; + from.set_fullscreen(false).map_err(err)?; + to.set_fullscreen(true).map_err(err)?; } Ok(()) } +fn assign_outer_frame(from: &WebviewWindow, to: &WebviewWindow, _frame: OuterFrame) -> IdeResult<()> { + #[cfg(target_os = "macos")] + { + crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string())) + } + #[cfg(not(target_os = "macos"))] + { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + to.set_fullscreen(false).map_err(err)?; + if from.is_maximized().map_err(err)? { + // Maximize on the source's monitor. The rectangles differed, so the maximized flags + // alone are not enough; a hidden peer would otherwise appear at its old size. + to.unmaximize().map_err(err)?; + place_on_monitor(from, to)?; + to.maximize().map_err(err)?; + } else { + to.unmaximize().map_err(err)?; + to.set_position(from.outer_position().map_err(err)?).map_err(err)?; + // `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland. + #[cfg(target_os = "linux")] + let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?; + #[cfg(not(target_os = "linux"))] + let size = from.inner_size().map_err(err)?; + #[cfg(target_os = "linux")] + crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size); + to.set_size(size).map_err(err)?; + } + Ok(()) + } +} + +/// Shows `to` in place of `from`, at `from`'s frame. +fn show_at_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + // Order the target front in the same AppKit turn as the frame write. A separate `show` runs + // after that turn and constrains the titled window, which is the jump. Native fullscreen is + // handed over by `copy_frame` instead. + #[cfg(target_os = "macos")] + if !from.is_fullscreen().map_err(err)? { + to.unminimize().map_err(err)?; + return crate::macos::present_window_frame(from, to).map_err(err); + } + copy_frame(from, to)?; + to.unminimize().map_err(err)?; + to.show().map_err(err)?; + to.set_focus().map_err(err) +} + /// Keeps hidden group members at the selected window's frame. Selecting a tab then only /// changes which window is visible instead of moving or resizing one on screen. fn sync_member_frames(app: &AppHandle, selected: &str) { @@ -235,11 +325,12 @@ pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> { && previous.selected != target && let Some(from) = app.get_webview_window(&previous.selected) { - copy_frame(&from, &to)?; + show_at_frame(&from, &to)?; + } else { + to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?; + to.show().map_err(|e| IdeError::internal(e.to_string()))?; + to.set_focus().map_err(|e| IdeError::internal(e.to_string()))?; } - to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?; - to.show().map_err(|e| IdeError::internal(e.to_string()))?; - to.set_focus().map_err(|e| IdeError::internal(e.to_string()))?; for member in &previous.members { if member != target && let Some(window) = app.get_webview_window(member) @@ -546,3 +637,54 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{FrameHost, FrameWrite, OuterFrame, frame_write}; + + fn frame(x: i32, y: i32, width: u32, height: u32) -> OuterFrame { + OuterFrame { x, y, width, height } + } + + #[test] + fn matching_outer_rects_are_left_alone() { + let frame = frame(100, 80, 1200, 800); + assert_eq!(frame_write(FrameHost::Desktop, frame, frame), FrameWrite::Leave); + } + + #[test] + fn a_different_outer_rect_is_assigned_before_the_window_is_shown() { + let from = frame(100, 80, 1200, 800); + let to = frame(40, 40, 900, 600); + assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from)); + } + + #[test] + fn maximized_windows_with_different_rects_still_assign() { + let from = frame(0, 25, 1728, 1055); + let to = frame(0, 25, 1512, 900); + assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from)); + } + + #[test] + fn simple_fullscreen_does_not_copy_a_frame() { + let from = frame(0, 0, 1728, 1117); + let to = frame(100, 80, 1200, 800); + assert_eq!(frame_write(FrameHost::SimpleFullscreen, from, to), FrameWrite::Leave); + } + + #[test] + fn wayland_does_not_place_a_window() { + let from = frame(0, 0, 1200, 800); + let to = frame(10, 10, 800, 600); + assert_eq!(frame_write(FrameHost::Wayland, from, to), FrameWrite::Leave); + } + + #[test] + fn native_fullscreen_transfers_only_when_the_rects_differ() { + let from = frame(0, 0, 1728, 1117); + let other = frame(100, 80, 1200, 800); + assert_eq!(frame_write(FrameHost::NativeFullscreen, from, from), FrameWrite::Leave); + assert_eq!(frame_write(FrameHost::NativeFullscreen, from, other), FrameWrite::TransferFullscreen); + } +} diff --git a/src/bindings.ts b/src/bindings.ts index bdb23e09..c9ca52c9 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -897,7 +897,11 @@ export const commands = { appQuit: () => __TAURI_INVOKE("app_quit"), /** A window vetoed the quit (user cancelled its unsaved-changes prompt). */ appCancelQuit: () => __TAURI_INVOKE("app_cancel_quit"), - /** Sets the menu's labels / shortcut text (on locale or keymap change) and rebuilds it. */ + /** + * Sets the menu's labels / shortcut text and rebuilds it when they changed. + * Focusing another project window re-pushes the same shared menu; an identical + * config must not call `set_menu`, which redraws the macOS menu bar. + */ menuConfigure: (config: MenuConfig) => __TAURI_INVOKE("menu_configure", { config }), /** Rebuilds the menu (e.g. after the recent-projects list changed). */ menuRefresh: () => __TAURI_INVOKE("menu_refresh"), diff --git a/src/features/app/menuBridge.ts b/src/features/app/menuBridge.ts index 1cb21086..2f8f4e6f 100644 --- a/src/features/app/menuBridge.ts +++ b/src/features/app/menuBridge.ts @@ -147,7 +147,8 @@ export function installMenuBridge(): () => void { push(); pushEnablement(true); }; - // The native menu bar is shared: a focused window re-pushes its own window-scoped labels. + // The menu bar is shared. A focused window re-pushes labels that can differ per window. + // An identical config does not replace the menu, so switching project tabs does not redraw it. window.addEventListener("focus", onFocus); const unsubs = [ useI18n.subscribe((s, prev) => {