From 9d030cd0a262611eb26328a3b6ce4e8d2417b657 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:40:07 +0800 Subject: [PATCH] fix(window): stop macOS traffic lights jittering on resize AppKit puts the title bar container and window buttons back at their defaults on every live resize step, title change and fullscreen exit. Correcting them from window notifications ran after that layout had been drawn, so the buttons bounced between the two positions, and stayed at the defaults when no notification followed. Correct the frames in a main run loop observer that runs after AppKit's layout and before the Core Animation commit. Create project windows hidden so the buttons are placed before the first frame, and skip repeated height updates from the toolbar. --- Cargo.lock | 2 + src-tauri/Cargo.toml | 1 + src-tauri/src/titlebar.rs | 59 ++++++++++++++++----------- src-tauri/src/windows.rs | 11 +++++ src/workbench/nativeTitlebar.test.tsx | 19 +++++++++ src/workbench/nativeTitlebar.ts | 18 ++++---- 6 files changed, 80 insertions(+), 30 deletions(-) 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);