diff --git a/Cargo.lock b/Cargo.lock index 8d3593ee0..2f5903ab9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5806,6 +5806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", "dispatch2", "objc2 0.6.5", ] @@ -6183,6 +6184,7 @@ dependencies = [ "ide-workspace", "objc2 0.6.5", "objc2-app-kit 0.3.2", + "objc2-core-foundation", "objc2-foundation 0.3.2", "parking_lot", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fc5613669..909f3e638 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -74,4 +74,5 @@ ide-github = { path = "../crates/ide-github" } block2 = "0.6" objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSResponder", "NSView", "NSWindow"] } +objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "CFRunLoop"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } diff --git a/src-tauri/src/titlebar.rs b/src-tauri/src/titlebar.rs index 432d951c2..fdf68376b 100644 --- a/src-tauri/src/titlebar.rs +++ b/src-tauri/src/titlebar.rs @@ -5,19 +5,26 @@ use std::collections::HashMap; use std::rc::Rc; use block2::RcBlock; -use objc2::rc::Retained; -use objc2::runtime::ProtocolObject; -use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowDidUpdateNotification, NSWindowStyleMask}; -use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSPoint}; +use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowStyleMask}; +use objc2_core_foundation::{ + CFIndex, CFOptionFlags, CFRetained, CFRunLoop, CFRunLoopActivity, CFRunLoopObserver, kCFRunLoopCommonModes, +}; +use objc2_foundation::NSPoint; + +/// AppKit lays out the title bar in a run loop observer and puts the buttons back at their +/// defaults on resize, title changes and fullscreen exit. Core Animation commits at 2000000, so +/// correcting the frames just before it keeps AppKit's positions off screen. +const BEFORE_COMMIT: CFIndex = 1_999_999; struct Titlebar { height: Rc>, - observer: Retained>, + spacing: f64, + observer: CFRetained, } impl Drop for Titlebar { fn drop(&mut self) { - unsafe { NSNotificationCenter::defaultCenter().removeObserver((*self.observer).as_ref()) }; + self.observer.invalidate(); } } @@ -30,22 +37,30 @@ pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<( // with_webview runs on the AppKit thread; the observer is also delivered there. window.with_webview(move |webview| unsafe { let native = &*webview.ns_window().cast::(); + let Some(close) = native.standardWindowButton(NSWindowButton::CloseButton) else { return }; + let Some(minimize) = native.standardWindowButton(NSWindowButton::MiniaturizeButton) else { return }; + let Some(run_loop) = CFRunLoop::main() else { return }; + let spacing = minimize.frame().origin.x - close.frame().origin.x; TITLEBARS.with_borrow_mut(|bars| { - let bar = bars.entry(label).or_insert_with(|| { - let height = Rc::new(Cell::new(height)); - let current = height.clone(); - let pointer = native as *const NSWindow; - let block = RcBlock::new(move |_| center_buttons(&*pointer, current.get())); - let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( - Some(NSWindowDidUpdateNotification), - Some(native), - None, - &block, - ); - Titlebar { height, observer } + if let Some(bar) = bars.get(&label) { + bar.height.set(height); + center_buttons(native, height, bar.spacing); + return; + } + let shared = Rc::new(Cell::new(height)); + let current = shared.clone(); + let pointer = native as *const NSWindow; + let block = RcBlock::new(move |_: *mut CFRunLoopObserver, _: CFRunLoopActivity| { + center_buttons(&*pointer, current.get(), spacing) }); - bar.height.set(height); - center_buttons(native, height); + let activities = CFRunLoopActivity::BeforeWaiting.0 as CFOptionFlags; + let Some(observer) = CFRunLoopObserver::with_handler(None, activities, true, BEFORE_COMMIT, Some(&block)) + else { + return; + }; + run_loop.add_observer(Some(&observer), kCFRunLoopCommonModes); + center_buttons(native, height, spacing); + bars.insert(label, Titlebar { height: shared, spacing, observer }); }); }) } @@ -57,14 +72,12 @@ pub fn forget(window: &tauri::Window) { }); } -unsafe fn center_buttons(window: &NSWindow, height: f64) { +unsafe fn center_buttons(window: &NSWindow, height: f64, spacing: f64) { // Fullscreen uses AppKit's own revealable title bar. if window.styleMask().contains(NSWindowStyleMask::FullScreen) { return; } let Some(close) = window.standardWindowButton(NSWindowButton::CloseButton) else { return }; - let Some(minimize) = window.standardWindowButton(NSWindowButton::MiniaturizeButton) else { return }; - let spacing = minimize.frame().origin.x - close.frame().origin.x; let Some(container) = (unsafe { close.superview().and_then(|view| view.superview()) }) else { return }; let Some(content) = window.contentView() else { return }; let mut frame = container.frame(); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index ebeb09f32..0108a4b97 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -284,6 +284,11 @@ fn hex_color(hex: &str) -> Option { /// Builds a project window painted in the saved theme's frame colour, so it /// doesn't flash the light default before its page (and splash) loads. fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult { + let visible = cfg!(target_os = "macos") && config.visible; + let focused = config.focus; + if visible { + config.visible = false; + } let state = app.try_state::(); let appearance = state.as_ref().map(|s| s.global_settings.effective().appearance); // A plugin theme replaces the built-in one in either OS mode; the UI records its resolved frame. @@ -305,6 +310,12 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< { let _ = window.set_background_color(Some(frame_color(&a, true))); } + if visible { + window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?; + if focused { + let _ = window.set_focus(); + } + } Ok(window) } diff --git a/src/workbench/nativeTitlebar.test.tsx b/src/workbench/nativeTitlebar.test.tsx index a864bc2ac..8e7547c03 100644 --- a/src/workbench/nativeTitlebar.test.tsx +++ b/src/workbench/nativeTitlebar.test.tsx @@ -49,6 +49,25 @@ function titlebar(height: number) { } describe("native title bar alignment", () => { + it("keeps the native startup position until saved settings arrive", () => { + useProject.setState({ settings: null }); + const { bar } = titlebar(40); + renderHook(() => useNativeTitlebar(bar)); + expect(setHeight).not.toHaveBeenCalled(); + act(() => zoom(110)); + expect(setHeight).toHaveBeenCalledExactlyOnceWith(44); + }); + + it("does not resend the same height on resize notifications or effect remounts", () => { + zoom(110); + const { bar } = titlebar(40); + const { rerender } = renderHook(({ bar }) => useNativeTitlebar(bar), { initialProps: { bar } }); + act(() => resize()); + rerender({ bar: titlebar(40).bar }); + act(() => resize()); + expect(setHeight).toHaveBeenCalledExactlyOnceWith(44); + }); + it.each([50, 100, 125, 150, 200, 300])("converts CSS height to AppKit points at %s%%", (percent) => { zoom(percent); const { bar } = titlebar(40); diff --git a/src/workbench/nativeTitlebar.ts b/src/workbench/nativeTitlebar.ts index 47d2632a1..e655ce406 100644 --- a/src/workbench/nativeTitlebar.ts +++ b/src/workbench/nativeTitlebar.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { call, commands } from "@/platform/ipc"; import { usePlatform } from "@/platform/platformInfo"; import { useProject } from "@/platform/project"; @@ -6,17 +6,21 @@ import { clampZoom } from "@/platform/zoom"; export function useNativeTitlebar(bar: HTMLElement | null) { const overlay = usePlatform((s) => s.overlayTitleBar); - const zoom = useProject((s) => clampZoom(s.settings?.appearance?.zoom)); + const zoom = useProject((s) => (s.settings ? clampZoom(s.settings.appearance?.zoom) : null)); + const lastHeight = useRef(null); useEffect(() => { - if (!overlay || !bar) return; + if (!overlay || !bar || zoom === null) return; bar.style.setProperty("--titlebar-zoom", String(zoom / 100)); const update = () => { // Webview zoom scales CSS pixels; Retina scale is already handled by AppKit. const height = (bar.getBoundingClientRect().height * zoom) / 100; - if (height > 0) - void call(commands.windowSetTitlebarHeight(height)).catch((e) => - console.warn("cannot align window buttons", e), - ); + if (height > 0 && height !== lastHeight.current) { + lastHeight.current = height; + void call(commands.windowSetTitlebarHeight(height)).catch((e) => { + if (lastHeight.current === height) lastHeight.current = null; + console.warn("cannot align window buttons", e); + }); + } }; update(); const observer = new ResizeObserver(update);