From ad92b1577f5d126bde379ed7f5bdd37fb4e48d6a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:38:16 +0800 Subject: [PATCH] fix(window): align macOS traffic lights with IDE zoom (#574) IDE zoom changed the toolbar height while the macOS window buttons stayed at a fixed vertical position. The buttons are now centred in the title bar using its measured height and the zoom, and repositioned after AppKit layout updates. Zooming out keeps enough room for them so they do not cover the project icon, and compact mode follows the same alignment. Windows align from the saved zoom and compact setting when they are built, so the buttons do not move once the page loads. The height uses the zoom the webview applies, so an out-of-range setting cannot misplace them. --- Cargo.lock | 4 + src-tauri/Cargo.toml | 6 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window.rs | 14 +++ src-tauri/src/lib.rs | 4 + src-tauri/src/titlebar.rs | 93 +++++++++++++++++++ src-tauri/src/windows.rs | 18 ++++ src-tauri/tauri.conf.json | 4 - src/bindings.ts | 2 + src/features/settings/appearanceRuntime.ts | 2 +- src/features/settings/model/behaviorSchema.ts | 2 +- src/features/settings/pages/behavior.tsx | 2 +- src/platform/zoom.ts | 7 ++ src/workbench/TitleBar.module.css | 2 +- src/workbench/TitleBar.tsx | 6 +- src/workbench/nativeTitlebar.test.tsx | 85 +++++++++++++++++ src/workbench/nativeTitlebar.ts | 29 ++++++ 17 files changed, 272 insertions(+), 9 deletions(-) create mode 100644 src-tauri/src/titlebar.rs create mode 100644 src/platform/zoom.ts create mode 100644 src/workbench/nativeTitlebar.test.tsx create mode 100644 src/workbench/nativeTitlebar.ts diff --git a/Cargo.lock b/Cargo.lock index 2932eebcf..0eeec9743 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6144,6 +6144,7 @@ name = "openidea" version = "0.1.31" dependencies = [ "blake3", + "block2 0.6.2", "dirs 6.0.0", "dunce", "ide-agent", @@ -6180,6 +6181,9 @@ dependencies = [ "ide-vfs", "ide-web", "ide-workspace", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "parking_lot", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d292831dd..6f795e857 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -69,3 +69,9 @@ ide-tools = { path = "../crates/ide-tools" } ide-plugin = { path = "../crates/ide-plugin" } ide-docker = { path = "../crates/ide-docker" } ide-github = { path = "../crates/ide-github" } + +[target.'cfg(target_os = "macos")'.dependencies] +block2 = "0.6" +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSButton", "NSResponder", "NSView", "NSWindow"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 46784eeb0..94d02f711 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -356,6 +356,7 @@ pub fn builder() -> tauri_specta::Builder { window::window_open_settings, window::window_cycle, window::window_toggle_fullscreen, + window::window_set_titlebar_height, window::window_take_launch, window::window_close_context, window::window_open_projects, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 0fbbd82b7..b655bf6e4 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -102,6 +102,20 @@ pub fn window_toggle_fullscreen(window: WebviewWindow) -> IdeResult { Ok(on) } +/// Height of the web title bar in native logical pixels (CSS height × IDE zoom). +#[tauri::command] +#[specta::specta] +pub fn window_set_titlebar_height(window: WebviewWindow, height: f64) -> IdeResult<()> { + if !height.is_finite() || !(1.0..=1000.0).contains(&height) { + return Err(IdeError::invalid("invalid title bar height")); + } + #[cfg(target_os = "macos")] + crate::titlebar::set_height(&window, height).map_err(|e| IdeError::internal(e.to_string()))?; + #[cfg(not(target_os = "macos"))] + let _ = window; + Ok(()) +} + /// What this window was opened for (taken once). #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 779118e89..006b6aa7a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,8 @@ mod remote_dev; mod state; mod theme_frame; +#[cfg(target_os = "macos")] +mod titlebar; mod window_groups; mod windows; @@ -138,6 +140,8 @@ pub fn run() { } } tauri::WindowEvent::Destroyed => { + #[cfg(target_os = "macos")] + titlebar::forget(window); window_groups::destroyed(window.app_handle(), window.label()); if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) { let _ = settings.destroy(); diff --git a/src-tauri/src/titlebar.rs b/src-tauri/src/titlebar.rs new file mode 100644 index 000000000..432d951c2 --- /dev/null +++ b/src-tauri/src/titlebar.rs @@ -0,0 +1,93 @@ +//! Keep the native window buttons aligned with the web toolbar, in AppKit points. + +use std::cell::{Cell, RefCell}; +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}; + +struct Titlebar { + height: Rc>, + observer: Retained>, +} + +impl Drop for Titlebar { + fn drop(&mut self) { + unsafe { NSNotificationCenter::defaultCenter().removeObserver((*self.observer).as_ref()) }; + } +} + +thread_local! { + static TITLEBARS: RefCell> = RefCell::default(); +} + +pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<()> { + let label = window.label().to_owned(); + // 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::(); + 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 } + }); + bar.height.set(height); + center_buttons(native, height); + }); + }) +} + +pub fn forget(window: &tauri::Window) { + let label = window.label().to_owned(); + let _ = window.run_on_main_thread(move || { + TITLEBARS.with_borrow_mut(|bars| bars.remove(&label)); + }); +} + +unsafe fn center_buttons(window: &NSWindow, height: 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(); + let top = frame.origin.y + frame.size.height; + if (frame.size.height - height).abs() > 0.01 { + frame.size.height = height; + frame.origin.y = top - height; + container.setFrame(frame); + } + let bounds = content.bounds(); + let y = bounds.origin.y + if content.isFlipped() { height / 2.0 } else { bounds.size.height - height / 2.0 }; + for (index, kind) in [NSWindowButton::CloseButton, NSWindowButton::MiniaturizeButton, NSWindowButton::ZoomButton] + .into_iter() + .enumerate() + { + let Some(button) = window.standardWindowButton(kind) else { continue }; + let Some(parent) = (unsafe { button.superview() }) else { continue }; + let center = parent.convertPoint_fromView(NSPoint::new(0.0, y), Some(&content)); + let frame = button.frame(); + let x = 14.0 + index as f64 * spacing; + let target = center.y - frame.size.height / 2.0; + if (frame.origin.x - x).abs() > 0.01 || (frame.origin.y - target).abs() > 0.01 { + button.setFrameOrigin(NSPoint::new(x, target)); + } + } +} diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 9a6b29267..ebeb09f32 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -297,6 +297,8 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< .and_then(|b| b.build()) .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; apply_zoom(app, &window); + #[cfg(target_os = "macos")] + align_titlebar(app, &window); // "Sync with OS" needs the OS mode, known once the window exists. if let Some(a) = appearance.filter(|a| plugin_frame.is_none() && (a.sync_with_os || a.theme == "system")) && window.theme().is_ok_and(|t| t == Theme::Dark) @@ -317,6 +319,20 @@ fn apply_zoom(app: &AppHandle, window: &WebviewWindow) { } } +/// Aligns the native window buttons before the page reports its title bar height, +/// so they don't visibly move once it loads. +#[cfg(target_os = "macos")] +fn align_titlebar(app: &AppHandle, window: &WebviewWindow) { + // `--h-titlebar` in tokens.css. + const HEIGHT: f64 = 40.0; + const COMPACT_HEIGHT: f64 = 32.0; + let Some(a) = app.try_state::().map(|s| s.global_settings.effective().appearance) else { + return; + }; + let height = if a.compact_mode { COMPACT_HEIGHT } else { HEIGHT }; + let _ = crate::titlebar::set_height(window, height * f64::from(a.zoom.clamp(50, 300)) / 100.0); +} + /// The first window (`create: false` in tauri.conf.json so it can take the theme's colour). pub fn create_main_window(app: &AppHandle) -> IdeResult { build_project_window(app, window_config(app)?) @@ -747,6 +763,8 @@ pub fn open_settings_window( .and_then(|b| b.build()) .map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?; apply_zoom(app, &window); + #[cfg(target_os = "macos")] + align_titlebar(app, &window); // Restore runs during build; fit its size to the parent's current display before showing. #[cfg(target_os = "macos")] if let Err(e) = fit_settings_window(&window, &parent) { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8e21756e4..628def551 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,10 +21,6 @@ "minHeight": 500, "titleBarStyle": "Overlay", "hiddenTitle": true, - "trafficLightPosition": { - "x": 14, - "y": 22 - }, "backgroundColor": "#E9EAEE", "dragDropEnabled": true } diff --git a/src/bindings.ts b/src/bindings.ts index 05f530cef..7fbeb9845 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -720,6 +720,8 @@ export const commands = { /** Focuses the next / previous window (⌘` / ⇧⌘`). */ windowCycle: (forward: boolean) => __TAURI_INVOKE("window_cycle", { forward }), windowToggleFullscreen: () => typedError(__TAURI_INVOKE("window_toggle_fullscreen")), + /** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */ + windowSetTitlebarHeight: (height: number | null) => typedError(__TAURI_INVOKE("window_set_titlebar_height", { height })), /** What this window was opened for (taken once). */ windowTakeLaunch: () => __TAURI_INVOKE<{ /** Project folder to open. */ diff --git a/src/features/settings/appearanceRuntime.ts b/src/features/settings/appearanceRuntime.ts index 6eb9ac439..3052bf659 100644 --- a/src/features/settings/appearanceRuntime.ts +++ b/src/features/settings/appearanceRuntime.ts @@ -10,7 +10,7 @@ import { uiFontFamily } from "@/platform/fonts"; import { call, commands, isTauri } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { setDragRequiresAlt } from "@/ui"; -import { ZOOM_LIMITS } from "./model/behaviorSchema"; +import { ZOOM_LIMITS } from "@/platform/zoom"; import { DEFAULT_SETTINGS, validateSettings, type ValidSettings } from "./model/schema"; const FONT_VARS = [ diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index 777110ee2..6ee7b326f 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -5,6 +5,7 @@ */ import { BUILTIN_THEME_IDS, type BuiltinThemeId } from "@/platform/themes"; +import { ZOOM_LIMITS } from "@/platform/zoom"; export type Antialiasing = "subpixel" | "greyscale" | "none"; export type OpenProjectIn = "ask" | "new" | "same" | "attach"; @@ -61,7 +62,6 @@ export interface NotificationsValid { groups: Record; } -export const ZOOM_LIMITS = { min: 50, max: 300 } as const; export const IDLE_LIMITS = { min: 1, max: 3600 } as const; export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = { diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index c8e352062..ed4f12ae9 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -11,6 +11,7 @@ import clsx from "clsx"; import { shortcutLabel } from "@/platform/keymap"; import { usePlatform } from "@/platform/platformInfo"; import { isTauri } from "@/platform/ipc"; +import { ZOOM_LIMITS } from "@/platform/zoom"; import { SCHEMES as EDITOR_SCHEMES, listSchemes, customSchemes } from "@/platform/colorScheme"; import { formatTimestamp } from "@/platform/dateFormat"; import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui"; @@ -18,7 +19,6 @@ import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore"; import { ColorSwatches, NumberInput, Row, SearchLabel, Select, Toggle } from "../components/Form"; import { useLabelMatch, useSetting } from "../components/context"; import { - ZOOM_LIMITS, IDLE_LIMITS, type Antialiasing, type ConcreteTheme, diff --git a/src/platform/zoom.ts b/src/platform/zoom.ts new file mode 100644 index 000000000..2f9321a70 --- /dev/null +++ b/src/platform/zoom.ts @@ -0,0 +1,7 @@ +export const ZOOM_LIMITS = { min: 50, max: 300 } as const; + +/** The IDE zoom in percent that a settings value applies as. */ +export function clampZoom(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 100; + return Math.min(ZOOM_LIMITS.max, Math.max(ZOOM_LIMITS.min, Math.round(value))); +} diff --git a/src/workbench/TitleBar.module.css b/src/workbench/TitleBar.module.css index f4dde6046..db4c57ffd 100644 --- a/src/workbench/TitleBar.module.css +++ b/src/workbench/TitleBar.module.css @@ -12,7 +12,7 @@ } /* Room for the native macOS traffic lights (Overlay title bar). */ .overlay { - padding-left: 82px; + padding-left: max(82px, calc(82px / var(--titlebar-zoom, 1))); } /* Welcome window: plain title on the frame colour with a hairline, like IntelliJ's welcome frame. */ .minimal { diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index e714e4f4f..22d18b4fe 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -26,6 +26,7 @@ import { projectInitials } from "./projectColor"; import { keymapItems, themeItems, VIEW_MODE_ITEMS } from "./quickSwitch"; import { currentThemeId, useThemeOverride } from "./theme"; import styles from "./TitleBar.module.css"; +import { useNativeTitlebar } from "./nativeTitlebar"; export function orderTitleItems(items: Iterable) { const all = [...items]; @@ -277,12 +278,14 @@ export function TitleBar({ const frameless = usePlatform((s) => s.framelessTitleBar); const { map, shown, menu } = useToolbar(); const [menuExpanded, setMenuExpanded] = useState(false); + const [bar, setBar] = useState(null); + useNativeTitlebar(bar); const className = clsx(styles.bar, overlay && styles.overlay, frameless && styles.frameless); // Distraction Free Mode: the window frame only. Where the title bar carries the // main menu (Windows, Linux) it stays, or nothing would lead back out of the mode. if (bare) return ( -
+
{frameless && ( <> @@ -296,6 +299,7 @@ export function TitleBar({ const toolbar = !minimal && !lightEdit; return (
{ diff --git a/src/workbench/nativeTitlebar.test.tsx b/src/workbench/nativeTitlebar.test.tsx new file mode 100644 index 000000000..a864bc2ac --- /dev/null +++ b/src/workbench/nativeTitlebar.test.tsx @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { usePlatform } from "@/platform/platformInfo"; +import { useProject } from "@/platform/project"; +import type { Settings } from "@/platform/ipc"; +import { useNativeTitlebar } from "./nativeTitlebar"; + +const { setHeight } = vi.hoisted(() => ({ setHeight: vi.fn().mockResolvedValue({ status: "ok" }) })); +vi.mock("@/platform/ipc", async (original) => { + const ipc = await original(); + return { ...ipc, commands: { ...ipc.commands, windowSetTitlebarHeight: setHeight } }; +}); + +const platform = usePlatform.getState(); +const project = useProject.getState(); +let resize: () => void; +const disconnect = vi.fn(); + +beforeEach(() => { + setHeight.mockClear(); + disconnect.mockClear(); + usePlatform.setState({ overlayTitleBar: true }); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + resize = callback; + } + observe() {} + disconnect = disconnect; + }, + ); +}); + +afterEach(() => { + usePlatform.setState(platform); + useProject.setState(project); + vi.unstubAllGlobals(); +}); + +function zoom(percent: number) { + useProject.setState({ settings: { appearance: { zoom: percent } } as Settings }); +} + +function titlebar(height: number) { + const bar = document.createElement("header"); + const rect = vi.spyOn(bar, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 800, height)); + return { bar, rect }; +} + +describe("native title bar alignment", () => { + it.each([50, 100, 125, 150, 200, 300])("converts CSS height to AppKit points at %s%%", (percent) => { + zoom(percent); + const { bar } = titlebar(40); + renderHook(() => useNativeTitlebar(bar)); + expect(setHeight).toHaveBeenLastCalledWith((40 * percent) / 100); + }); + + it("uses the zoom the webview applies when the setting is out of range", () => { + zoom(500); + const { bar } = titlebar(40); + renderHook(() => useNativeTitlebar(bar)); + expect(setHeight).toHaveBeenLastCalledWith(120); + }); + + it("updates when zoom or compact mode changes, and disconnects on unmount", () => { + zoom(100); + const { bar, rect } = titlebar(40); + const { unmount } = renderHook(() => useNativeTitlebar(bar)); + act(() => zoom(150)); + expect(setHeight).toHaveBeenLastCalledWith(60); + rect.mockReturnValue(new DOMRect(0, 0, 800, 32)); + act(() => resize()); + expect(setHeight).toHaveBeenLastCalledWith(48); + unmount(); + expect(disconnect).toHaveBeenCalledTimes(2); + }); + + it("leaves windows without native overlay buttons alone", () => { + usePlatform.setState({ overlayTitleBar: false }); + const { bar } = titlebar(40); + renderHook(() => useNativeTitlebar(bar)); + expect(setHeight).not.toHaveBeenCalled(); + }); +}); diff --git a/src/workbench/nativeTitlebar.ts b/src/workbench/nativeTitlebar.ts new file mode 100644 index 000000000..47d2632a1 --- /dev/null +++ b/src/workbench/nativeTitlebar.ts @@ -0,0 +1,29 @@ +import { useEffect } from "react"; +import { call, commands } from "@/platform/ipc"; +import { usePlatform } from "@/platform/platformInfo"; +import { useProject } from "@/platform/project"; +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)); + useEffect(() => { + if (!overlay || !bar) 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), + ); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(bar); + return () => { + observer.disconnect(); + bar.style.removeProperty("--titlebar-zoom"); + }; + }, [bar, overlay, zoom]); +}