From 4cfdb972eb117bb3bb5206090a5d715f151456bd Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:43:44 +0700 Subject: [PATCH] fix(window): hide the macOS title bar toolbar in native full screen (#1932) Native full screen on macOS drew the empty unified toolbar added in #1712 as an opaque strip over the top of the window, covering the web title bar (#1899). The toolbar is now hidden from will-enter-full-screen until did-exit, tracked per window through notifications, so a title bar height change in between (zoom, compact mode, restore) can't bring it back; every way into full screen is covered, including window groups whose member is switched while in full screen. Closes #1899 Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src-tauri/src/lib.rs | 2 + src-tauri/src/titlebar.rs | 175 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b3810cfc9..4ba2f58f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -211,6 +211,8 @@ pub fn run() { window_groups::frame_changed(window.app_handle(), window.label()); } tauri::WindowEvent::Destroyed => { + #[cfg(target_os = "macos")] + titlebar::forget(window); if windows::auxiliary_parent(window.label()).is_none() { window_groups::destroyed(window.app_handle(), window.label()); windows::close_auxiliary_windows(window.app_handle(), window.label()); diff --git a/src-tauri/src/titlebar.rs b/src-tauri/src/titlebar.rs index 9ab69d0d2..bf32bbac3 100644 --- a/src-tauri/src/titlebar.rs +++ b/src-tauri/src/titlebar.rs @@ -2,29 +2,190 @@ //! //! An empty unified toolbar makes AppKit lay out and vertically centre the buttons itself, so //! nothing moves them behind its back and they stay still during live resize. +//! +//! In native full screen the same toolbar is drawn as an opaque strip over the web title bar, so +//! it is hidden from the moment the window starts entering full screen until it has left (#1899). -use objc2::MainThreadMarker; -use objc2_app_kit::{NSToolbar, NSWindow, NSWindowToolbarStyle}; +use objc2::{ + MainThreadMarker, + rc::{Retained, Weak}, + runtime::ProtocolObject, +}; +use objc2_app_kit::{ + NSToolbar, NSWindow, NSWindowDidExitFullScreenNotification, NSWindowStyleMask, NSWindowToolbarStyle, + NSWindowWillEnterFullScreenNotification, +}; +use objc2_foundation::{NSNotificationCenter, NSObjectProtocol}; +use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc}; /// Unified compact (38pt) and unified (52pt) toolbars; pick the one closer to the web bar. const COMPACT_MAX_HEIGHT: f64 = 45.0; +fn style_for(height: f64) -> NSWindowToolbarStyle { + if height < COMPACT_MAX_HEIGHT { NSWindowToolbarStyle::UnifiedCompact } else { NSWindowToolbarStyle::Unified } +} + +/// Whether a window is in, or on its way into, native full screen. `styleMask` cannot answer +/// this: its full screen flag only turns on at `didEnterFullScreen`, after the resize. +#[derive(Default)] +struct Fullscreen(Cell); + +impl Fullscreen { + fn will_enter(&self) { + self.0.set(true); + } + + fn did_exit(&self) { + self.0.set(false); + } + + fn toolbar_visible(&self) -> bool { + !self.0.get() + } +} + +struct Observer(Retained>); + +impl Drop for Observer { + fn drop(&mut self) { + let observer: &ProtocolObject = &self.0; + unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) }; + } +} + +struct Tracked { + state: Rc, + _observers: [Observer; 2], +} + +thread_local! { + static WINDOWS: RefCell> = RefCell::default(); +} + +fn apply(window: &NSWindow, state: &Fullscreen) { + if let Some(toolbar) = window.toolbar() { + let visible = state.toolbar_visible(); + if toolbar.isVisible() != visible { + toolbar.setVisible(visible); + } + } +} + +fn track(window: &NSWindow) -> Tracked { + let state = Rc::new(Fullscreen::default()); + if window.styleMask().contains(NSWindowStyleMask::FullScreen) { + state.will_enter(); + } + let observe = |name, change: fn(&Fullscreen)| { + let weak = Weak::new(window); + let state = state.clone(); + let block = block2::RcBlock::new(move |_| { + change(&state); + if let Some(window) = weak.load() { + apply(&window, &state); + } + }); + let observer = unsafe { + NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( + Some(name), + Some(window), + None, + &block, + ) + }; + Observer(observer) + }; + let observers = unsafe { + [ + observe(NSWindowWillEnterFullScreenNotification, Fullscreen::will_enter), + observe(NSWindowDidExitFullScreenNotification, Fullscreen::did_exit), + ] + }; + Tracked { state, _observers: observers } +} + pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<()> { + let label = window.label().to_owned(); // with_webview runs on the AppKit thread. window.with_webview(move |webview| unsafe { let Some(mtm) = MainThreadMarker::new() else { return }; let native = &*webview.ns_window().cast::(); + let state = + WINDOWS.with_borrow_mut(|windows| windows.entry(label).or_insert_with(|| track(native)).state.clone()); if native.toolbar().is_none() { let toolbar = NSToolbar::new(mtm); native.setToolbar(Some(&toolbar)); } - let style = if height < COMPACT_MAX_HEIGHT { - NSWindowToolbarStyle::UnifiedCompact - } else { - NSWindowToolbarStyle::Unified - }; + let style = style_for(height); if native.toolbarStyle() != style { native.setToolbarStyle(style); } + apply(native, &state); }) } + +/// Stops tracking a destroyed window, which removes its notification observers. +pub fn forget(window: &tauri::Window) { + let label = window.label().to_owned(); + let forget = move || { + WINDOWS.with_borrow_mut(|windows| windows.remove(&label)); + }; + if MainThreadMarker::new().is_some() { + forget(); + } else { + let _ = window.run_on_main_thread(forget); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Copy, Debug)] + enum Step { + WillEnter, + DidExit, + } + + /// Visibility after each step, starting from a window on the desktop. + #[test] + fn toolbar_follows_native_full_screen() { + use Step::*; + let cases: &[(&[Step], &[bool])] = &[ + (&[], &[]), + (&[WillEnter], &[false]), + (&[WillEnter, DidExit], &[false, true]), + // `set_height` between the two notifications must not bring the toolbar back. + (&[WillEnter, WillEnter], &[false, false]), + // A second exit, as a group member leaving after its host did, changes nothing. + (&[WillEnter, DidExit, DidExit], &[false, true, true]), + (&[DidExit], &[true]), + (&[WillEnter, DidExit, WillEnter], &[false, true, false]), + ]; + for (steps, expected) in cases { + let state = Fullscreen::default(); + assert!(state.toolbar_visible(), "a new window is on the desktop"); + let seen: Vec = steps + .iter() + .map(|step| { + match step { + WillEnter => state.will_enter(), + DidExit => state.did_exit(), + } + state.toolbar_visible() + }) + .collect(); + assert_eq!(&seen, expected, "{steps:?}"); + } + } + + #[test] + fn toolbar_style_follows_the_web_bar_height() { + for (height, compact) in + [(31.9, true), (32.0, true), (40.0, true), (44.9, true), (45.0, false), (52.0, false), (120.0, false)] + { + let expected = if compact { NSWindowToolbarStyle::UnifiedCompact } else { NSWindowToolbarStyle::Unified }; + assert_eq!(style_for(height), expected, "{height}"); + } + } +}