diff --git a/Cargo.lock b/Cargo.lock index 2932eebc..0eeec974 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 d292831d..6f795e85 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 46784eeb..94d02f71 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 0fbbd82b..b655bf6e 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 779118e8..006b6aa7 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 00000000..432d951c --- /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 9a6b2926..ebeb09f3 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 8e21756e..628def55 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 05f530ce..7fbeb984 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 6eb9ac43..3052bf65 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 777110ee..6ee7b326 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 c8e35206..ed4f12ae 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 00000000..2f9321a7 --- /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 f4dde604..db4c57ff 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 e714e4f4..22d18b4f 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 00000000..a864bc2a --- /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 00000000..47d2632a --- /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]); +}