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 <[email protected]>
This commit is contained in:
ntbang0901 authored and GitHub committed 2026-11-07 16:43:44 +08:00
1 parent d235c432ec
commit 4cfdb972eb
2 files changed
+170 -7

No files matched your search

+2
View File
@@ -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());
+168 -7
View File
@@ -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<bool>);
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<ProtocolObject<dyn NSObjectProtocol>>);
impl Drop for Observer {
fn drop(&mut self) {
let observer: &ProtocolObject<dyn NSObjectProtocol> = &self.0;
unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) };
}
}
struct Tracked {
state: Rc<Fullscreen>,
_observers: [Observer; 2],
}
thread_local! {
static WINDOWS: RefCell<HashMap<String, Tracked>> = 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::<NSWindow>();
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<bool> = 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}");
}
}
}