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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-31 22:38:16 +08:00
1 джерело 06109aae95
коміт ad92b1577f
17 files changed
+272 -9

No files matched your search

+2
Переглянути файл
@@ -720,6 +720,8 @@ export const commands = {
/** Focuses the next / previous window (⌘` / ⇧⌘`). */
windowCycle: (forward: boolean) => __TAURI_INVOKE<void>("window_cycle", { forward }),
windowToggleFullscreen: () => typedError<boolean, IdeError>(__TAURI_INVOKE("window_toggle_fullscreen")),
/** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */
windowSetTitlebarHeight: (height: number | null) => typedError<null, IdeError>(__TAURI_INVOKE("window_set_titlebar_height", { height })),
/** What this window was opened for (taken once). */
windowTakeLaunch: () => __TAURI_INVOKE<{
/** Project folder to open. */
+1 -1
Переглянути файл
@@ -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 = [
+1 -1
Переглянути файл
@@ -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<string, NotificationGroupValid>;
}
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 = {
+1 -1
Переглянути файл
@@ -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,
+7
Переглянути файл
@@ -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)));
}
+1 -1
Переглянути файл
@@ -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 {
+5 -1
Переглянути файл
@@ -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<TitleItemContribution>) {
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<HTMLElement | null>(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 (
<header className={className} data-tauri-drag-region data-titlebar="bare">
<header ref={setBar} className={className} data-tauri-drag-region data-titlebar="bare">
{frameless && (
<>
<MainMenu />
@@ -296,6 +299,7 @@ export function TitleBar({
const toolbar = !minimal && !lightEdit;
return (
<header
ref={setBar}
className={clsx(className, minimal && styles.minimal, dialog && styles.dialogBar)}
data-tauri-drag-region
onContextMenu={(e) => {
+85
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
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();
});
});
+29
Переглянути файл
@@ -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]);
}