From 9da3a3056983070daef92e23f8f790720ecfe4a0 Mon Sep 17 00:00:00 2001 From: fguisso Date: Sat, 29 Aug 2026 15:44:28 -0300 Subject: [PATCH] fix: integrate macOS native tabs into titlebar --- rio-window/Cargo.toml | 4 + rio-window/src/platform_impl/macos/window.rs | 336 +++++++++++++++++- .../platform_impl/macos/window_delegate.rs | 69 +++- 3 files changed, 400 insertions(+), 9 deletions(-) diff --git a/rio-window/Cargo.toml b/rio-window/Cargo.toml index a48e4b153c..2a26a3364c 100644 --- a/rio-window/Cargo.toml +++ b/rio-window/Cargo.toml @@ -99,6 +99,8 @@ features = [ "NSGraphicsContext", "NSImage", "NSImageRep", + "NSLayoutAnchor", + "NSLayoutConstraint", "NSMenu", "NSMenuItem", "NSOpenGLView", @@ -108,8 +110,10 @@ features = [ "NSScreen", "NSTextInputClient", "NSTextInputContext", + "NSTitlebarAccessoryViewController", "NSToolbar", "NSView", + "NSViewController", "NSWindow", "NSWindowScripting", "NSWindowTabGroup", diff --git a/rio-window/src/platform_impl/macos/window.rs b/rio-window/src/platform_impl/macos/window.rs index f7e38ac914..7a13a95e29 100644 --- a/rio-window/src/platform_impl/macos/window.rs +++ b/rio-window/src/platform_impl/macos/window.rs @@ -1,9 +1,19 @@ #![allow(clippy::unnecessary_cast)] +use std::cell::RefCell; + use objc2::rc::{autoreleasepool, Retained}; -use objc2::{declare_class, mutability, ClassType, DeclaredClass}; -use objc2_app_kit::{NSResponder, NSWindow}; -use objc2_foundation::{MainThreadBound, MainThreadMarker, NSObject}; +use objc2::{ + declare_class, msg_send, msg_send_id, mutability, sel, ClassType, DeclaredClass, +}; +use objc2_app_kit::{ + NSAutoresizingMaskOptions, NSColor, NSLayoutAttribute, NSLayoutConstraint, + NSResponder, NSTitlebarAccessoryViewController, NSView, NSWindow, NSWindowButton, + NSWindowOrderingMode, +}; +use objc2_foundation::{ + MainThreadBound, MainThreadMarker, NSArray, NSObject, NSObjectNSDelayedPerforming, +}; use super::event_loop::ActiveEventLoop; use super::window_delegate::WindowDelegate; @@ -95,6 +105,54 @@ impl From for WindowId { } } +#[derive(Debug)] +pub struct WinitWindowState { + pub(crate) integrate_native_tabs: bool, + pub(crate) native_tab_constraints: RefCell>>, + pub(crate) integrated_titlebar_glass: RefCell>, +} + +fn retained(view: &NSView) -> Retained { + unsafe { Retained::retain(view as *const NSView as *mut NSView) } + .expect("NSView references are never null") +} + +fn first_descendant(view: &NSView, class_name: &str) -> Option> { + if view.class().name() == class_name { + return Some(retained(view)); + } + + for child in unsafe { view.subviews() }.iter() { + if let Some(found) = first_descendant(child, class_name) { + return Some(found); + } + } + + None +} + +fn first_ancestor(view: &NSView, class_names: &[&str]) -> Option> { + let mut ancestor = unsafe { view.superview() }; + while let Some(view) = ancestor { + if class_names.contains(&view.class().name()) { + return Some(view); + } + ancestor = unsafe { view.superview() }; + } + + None +} + +unsafe fn set_layer_background_color(layer: &objc2::runtime::AnyObject, color: &NSColor) { + use objc::runtime::Object; + use objc::{msg_send, sel, sel_impl}; + + let layer = layer as *const _ as *mut Object; + let color = color as *const _ as *mut Object; + let cg_color: core_graphics::sys::CGColorRef = msg_send![color, CGColor]; + let _: () = msg_send![layer, setBackgroundColor: cg_color]; +} + declare_class!( #[derive(Debug)] pub struct WinitWindow; @@ -106,7 +164,9 @@ declare_class!( const NAME: &'static str = "WinitWindow"; } - impl DeclaredClass for WinitWindow {} + impl DeclaredClass for WinitWindow { + type Ivars = WinitWindowState; + } unsafe impl WinitWindow { #[method(canBecomeMainWindow)] @@ -120,6 +180,142 @@ declare_class!( trace_scope!("canBecomeKeyWindow"); true } + + #[method(addTitlebarAccessoryViewController:)] + fn add_titlebar_accessory_view_controller( + &self, + child: &NSTitlebarAccessoryViewController, + ) { + if self.ivars().integrate_native_tabs { + unsafe { child.setLayoutAttribute(NSLayoutAttribute::Right) }; + } + + unsafe { + let _: () = msg_send![ + super(self), + addTitlebarAccessoryViewController: child + ]; + } + + if self.ivars().integrate_native_tabs { + self.reattach_integrated_titlebar_glass(); + unsafe { + self.performSelector_withObject_afterDelay( + sel!(setupNativeTabBar), + None, + 0.0, + ) + }; + } + } + + #[method(becomeMainWindow)] + fn become_main_window(&self) { + unsafe { + let _: () = msg_send![super(self), becomeMainWindow]; + } + if self.ivars().integrate_native_tabs { + self.reattach_integrated_titlebar_glass(); + unsafe { + self.performSelector_withObject_afterDelay( + sel!(setupNativeTabBar), + None, + 0.0, + ) + }; + } + } + + #[method(setupNativeTabBar)] + fn setup_native_tab_bar(&self) { + if !self.ivars().integrate_native_tabs + || !unsafe { self.isMainWindow() } + { + return; + } + + let Some(content_view) = self.contentView() else { + return; + }; + let mut root = content_view; + while let Some(parent) = unsafe { root.superview() } { + root = parent; + } + + let Some(tab_bar) = first_descendant(&root, "NSTabBar") else { + return; + }; + self.reattach_integrated_titlebar_glass(); + let Some(toolbar) = first_descendant(&root, "NSToolbarView") else { + return; + }; + let Some(clip_view) = first_ancestor( + &tab_bar, + // AppKit renamed this private container in macOS 27 beta. + &[ + "NSTitlebarAccessoryClipView", + "NSTitlebarAccessoryContainerView", + ], + ) else { + return; + }; + let subviews = unsafe { clip_view.subviews() }; + let Some(accessory_view) = subviews.iter().next() else { + return; + }; + + self.clear_native_tab_constraints(); + let left_inset = self + .standardWindowButton(NSWindowButton::NSWindowZoomButton) + .map(|button| { + let frame = button.frame(); + frame.origin.x + frame.size.width + }) + .unwrap_or(70.0); + + unsafe { + clip_view.setTranslatesAutoresizingMaskIntoConstraints(false); + accessory_view.setTranslatesAutoresizingMaskIntoConstraints(false); + + let constraints = vec![ + clip_view + .leftAnchor() + .constraintEqualToAnchor_constant(&toolbar.leftAnchor(), left_inset), + clip_view + .rightAnchor() + .constraintEqualToAnchor(&toolbar.rightAnchor()), + clip_view + .topAnchor() + .constraintEqualToAnchor_constant(&toolbar.topAnchor(), 2.0), + clip_view + .heightAnchor() + .constraintEqualToAnchor(&toolbar.heightAnchor()), + accessory_view + .leftAnchor() + .constraintEqualToAnchor(&clip_view.leftAnchor()), + accessory_view + .rightAnchor() + .constraintEqualToAnchor(&clip_view.rightAnchor()), + accessory_view + .topAnchor() + .constraintEqualToAnchor(&clip_view.topAnchor()), + accessory_view + .heightAnchor() + .constraintEqualToAnchor(&clip_view.heightAnchor()), + ]; + let constraint_refs = constraints + .iter() + .map(|constraint| &**constraint) + .collect::>(); + NSLayoutConstraint::activateConstraints(&NSArray::from_slice( + &constraint_refs, + )); + self.ivars().native_tab_constraints.replace(constraints); + + clip_view.setNeedsLayout(true); + accessory_view.setNeedsLayout(true); + } + } } ); @@ -127,4 +323,136 @@ impl WinitWindow { pub(super) fn id(&self) -> WindowId { WindowId(self as *const Self as usize) } + + pub(super) fn uses_integrated_native_tabs(&self) -> bool { + self.ivars().integrate_native_tabs + } + + pub(super) fn set_integrated_titlebar_background( + &self, + color: &NSColor, + active: bool, + ) { + if !self.ivars().integrate_native_tabs { + return; + } + + let Some(content_view) = self.contentView() else { + return; + }; + let mut root = content_view; + while let Some(parent) = unsafe { root.superview() } { + root = parent; + } + let Some(background) = first_descendant(&root, "NSTitlebarContainerView") else { + return; + }; + let has_glass = self.ivars().integrated_titlebar_glass.borrow().is_some(); + let background_color = unsafe { + color.colorWithAlphaComponent(if has_glass { + 0.0 + } else if active { + 1.0 + } else { + 0.0 + }) + }; + + unsafe { + background.setWantsLayer(true); + let layer: Option> = + msg_send_id![&*background, layer]; + if let Some(layer) = layer { + set_layer_background_color(&layer, &background_color); + } + } + } + + pub(super) fn install_or_update_integrated_titlebar_glass( + &self, + style: super::glass::GlassStyle, + color: &NSColor, + opacity: f64, + ) { + if !self.ivars().integrate_native_tabs { + return; + } + + if self.ivars().integrated_titlebar_glass.borrow().is_none() { + *self.ivars().integrated_titlebar_glass.borrow_mut() = + super::glass::GlassEffect::new(); + } + + if let Some(glass) = self.ivars().integrated_titlebar_glass.borrow().as_ref() { + glass.set_style(style); + glass.set_tint_color_with_opacity(color, opacity); + glass.set_corner_radius(0.0); + } + self.reattach_integrated_titlebar_glass(); + self.set_integrated_titlebar_background(color, self.isKeyWindow()); + } + + pub(super) fn update_integrated_titlebar_glass_tint( + &self, + color: &NSColor, + opacity: f64, + ) { + if let Some(glass) = self.ivars().integrated_titlebar_glass.borrow().as_ref() { + glass.set_tint_color_with_opacity(color, opacity); + } + } + + pub(super) fn uninstall_integrated_titlebar_glass(&self) { + if let Some(glass) = self.ivars().integrated_titlebar_glass.borrow_mut().take() { + unsafe { glass.as_ns_view().removeFromSuperview() }; + } + } + + fn reattach_integrated_titlebar_glass(&self) { + let Some(content_view) = self.contentView() else { + return; + }; + let mut root = content_view; + while let Some(parent) = unsafe { root.superview() } { + root = parent; + } + let Some(titlebar) = first_descendant(&root, "NSTitlebarContainerView") else { + return; + }; + let glass_ref = self.ivars().integrated_titlebar_glass.borrow(); + let Some(glass) = glass_ref.as_ref() else { + return; + }; + let view = glass.as_ns_view(); + + unsafe { + view.setFrame(titlebar.bounds()); + view.setAutoresizingMask( + NSAutoresizingMaskOptions::NSViewWidthSizable + | NSAutoresizingMaskOptions::NSViewHeightSizable, + ); + titlebar.addSubview_positioned_relativeTo( + view, + NSWindowOrderingMode::NSWindowBelow, + None, + ); + } + } + + fn clear_native_tab_constraints(&self) { + let constraints = self.ivars().native_tab_constraints.take(); + if constraints.is_empty() { + return; + } + + unsafe { + let constraint_refs = constraints + .iter() + .map(|constraint| &**constraint) + .collect::>(); + NSLayoutConstraint::deactivateConstraints(&NSArray::from_slice( + &constraint_refs, + )); + } + } } diff --git a/rio-window/src/platform_impl/macos/window_delegate.rs b/rio-window/src/platform_impl/macos/window_delegate.rs index f400133bac..d8e588eba7 100644 --- a/rio-window/src/platform_impl/macos/window_delegate.rs +++ b/rio-window/src/platform_impl/macos/window_delegate.rs @@ -29,7 +29,7 @@ use super::cursor::cursor_from_icon; use super::display_link::{DisplayLink, DisplayLinkSupport}; use super::monitor::{self, flip_window_screen_coordinates, get_display_id}; use super::view::WinitView; -use super::window::WinitWindow; +use super::window::{WinitWindow, WinitWindowState}; use super::{ffi, Fullscreen, MonitorHandle, OsError, WindowId}; use crate::dpi::{ LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize, Position, Size, @@ -243,6 +243,7 @@ declare_class!( // TODO: center the cursor if the window had mouse grab when it // lost focus self.queue_event(WindowEvent::Focused(true)); + self.update_integrated_titlebar_background(true); // Reapply traffic light positioning when window becomes active self.move_traffic_light(); } @@ -260,6 +261,7 @@ declare_class!( self.view().reset_modifiers(); self.queue_event(WindowEvent::Focused(false)); + self.update_integrated_titlebar_background(false); } /// Invoked when before enter fullscreen @@ -596,9 +598,16 @@ fn new_window( masks |= NSWindowStyleMask::FullSizeContentView; } + let integrate_native_tabs = attrs.platform_specific.unified_titlebar + && attrs.platform_specific.tabbing_identifier.is_some() + && super::glass::GlassEffect::class_available(); let window: Option> = unsafe { msg_send_id![ - super(mtm.alloc().set_ivars(())), + super(mtm.alloc().set_ivars(WinitWindowState { + integrate_native_tabs, + native_tab_constraints: RefCell::new(Vec::new()), + integrated_titlebar_glass: RefCell::new(None), + })), initWithContentRect: frame, styleMask: masks, backing: NSBackingStoreType::NSBackingStoreBuffered, @@ -652,7 +661,13 @@ fn new_window( // The toolbar style is ignored if there is no toolbar, so it is // necessary to add one. window.setToolbar(Some(&NSToolbar::new(mtm))); - window.setToolbarStyle(NSWindowToolbarStyle::Unified); + window.setToolbarStyle(if integrate_native_tabs { + window + .setTitleVisibility(NSWindowTitleVisibility::NSWindowTitleHidden); + NSWindowToolbarStyle::UnifiedCompact + } else { + NSWindowToolbarStyle::Unified + }); } } @@ -718,6 +733,17 @@ fn new_window( } impl WindowDelegate { + fn update_integrated_titlebar_background(&self, active: bool) { + if !self.window().uses_integrated_native_tabs() { + return; + } + + self.window().set_integrated_titlebar_background( + &self.ivars().background_color.borrow(), + active, + ); + } + pub(super) fn new( app_delegate: &ApplicationDelegate, attrs: WindowAttributes, @@ -1094,10 +1120,20 @@ impl WindowDelegate { // doesn't paint past them. `_cornerRadius` is a private // selector — gated on `respondsToSelector:` so it // degrades gracefully if Apple ever removes / renames it. - if let Some(radius) = self.window_corner_radius() { + if self.window().uses_integrated_native_tabs() { + // The content view starts below the unified titlebar. Giving it + // the window's full radius creates a second pair of rounded + // corners at that internal boundary. + glass.set_corner_radius(0.0); + } else if let Some(radius) = self.window_corner_radius() { glass.set_corner_radius(radius); } } + self.window().install_or_update_integrated_titlebar_glass( + style, + &self.ivars().background_color.borrow(), + self.ivars().glass_opacity.get(), + ); } /// Query the host `NSWindow`'s rounded-corner radius via the @@ -1133,7 +1169,14 @@ impl WindowDelegate { // borrow is held across the AppKit `setContentView` call // below — same re-entrancy concern as `install_or_update_glass`. let glass = self.ivars().glass_effect.borrow_mut().take(); - let Some(glass) = glass else { return }; + self.window().uninstall_integrated_titlebar_glass(); + let Some(glass) = glass else { + self.window().set_integrated_titlebar_background( + &self.ivars().background_color.borrow(), + self.window().isKeyWindow(), + ); + return; + }; let window = self.window(); if let Some(view) = glass.content_view() { @@ -1144,6 +1187,10 @@ impl WindowDelegate { } // glass drops here; AppKit releases the NSGlassEffectView. drop(glass); + self.window().set_integrated_titlebar_background( + &self.ivars().background_color.borrow(), + self.window().isKeyWindow(), + ); } pub fn set_visible(&self, visible: bool) { @@ -2192,6 +2239,14 @@ impl WindowExtMacOS for WindowDelegate { let mut background_color = self.ivars().background_color.borrow_mut(); *background_color = color_value.clone(); self.window().setBackgroundColor(Some(&color_value)); + self.window().set_integrated_titlebar_background( + &color_value, + self.window().isKeyWindow(), + ); + self.window().update_integrated_titlebar_glass_tint( + &color_value, + self.ivars().glass_opacity.get(), + ); } #[inline] @@ -2316,6 +2371,10 @@ impl WindowExtMacOS for WindowDelegate { clamped, ); } + self.window().update_integrated_titlebar_glass_tint( + &self.ivars().background_color.borrow(), + clamped, + ); } }