From de1b18b9e15511d11a272d90ac7fb5ec16bd35e2 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:39:48 +0700 Subject: [PATCH 01/10] Restyle the tray panel as the macOS menu The panel is a light 310 px menu (#DEDEE2, 4 px vertical padding, separators inset 14 px) under every app theme, and the cost block shows again. Footer rows get icons, Ctrl shortcuts in a 44 px column and an About row with the version. Usage Dashboard and Status Page come from get_provider_detail for the selected provider, or the only one. The menu selection uses the Windows accent color. The zoom slider, resize grips, move strip and stored-size sizing are gone. The window is 310 px times the Panel scale wide and fits its content height. --- .../src/components/MenuSurface.test.tsx | 130 +++- .../src/components/MenuSurface.tsx | 125 +++- .../src/hooks/useTrayPanelController.ts | 301 ++++----- .../hooks/useTrayPanelLayout.sizing.test.tsx | 87 ++- .../src/hooks/useTrayPanelLayout.ts | 254 ++----- apps/desktop-tauri/src/i18n/keys.ts | 6 +- .../src/lib/menuSelection.test.ts | 23 + apps/desktop-tauri/src/lib/menuSelection.ts | 25 + apps/desktop-tauri/src/lib/tauri.ts | 16 +- apps/desktop-tauri/src/lib/trayScale.test.ts | 11 + apps/desktop-tauri/src/lib/trayScale.ts | 10 + apps/desktop-tauri/src/lib/traySizing.test.ts | 2 +- apps/desktop-tauri/src/lib/traySizing.ts | 2 +- apps/desktop-tauri/src/styles.css | 639 ++++++------------ .../src/surfaces/TrayPanel.period.test.tsx | 15 +- .../src/surfaces/TrayPanel.test.tsx | 479 +++++-------- apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 240 +------ .../src/surfaces/menu-surface-layout.css | 19 +- rust/src/locale.rs | 6 +- rust/src/locale/en-US.ftl | 6 +- rust/src/locale/es-MX.ftl | 6 +- rust/src/locale/ja-JP.ftl | 6 +- rust/src/locale/ko-KR.ftl | 6 +- rust/src/locale/pt-BR.ftl | 6 +- rust/src/locale/ru-RU.ftl | 6 +- rust/src/locale/tests.rs | 4 - rust/src/locale/tr-TR.ftl | 6 +- rust/src/locale/uk-UA.ftl | 6 +- rust/src/locale/zh-CN.ftl | 6 +- rust/src/locale/zh-TW.ftl | 6 +- 30 files changed, 998 insertions(+), 1456 deletions(-) create mode 100644 apps/desktop-tauri/src/lib/menuSelection.test.ts create mode 100644 apps/desktop-tauri/src/lib/menuSelection.ts create mode 100644 apps/desktop-tauri/src/lib/trayScale.test.ts create mode 100644 apps/desktop-tauri/src/lib/trayScale.ts diff --git a/apps/desktop-tauri/src/components/MenuSurface.test.tsx b/apps/desktop-tauri/src/components/MenuSurface.test.tsx index 041df7ec2e..439929a646 100644 --- a/apps/desktop-tauri/src/components/MenuSurface.test.tsx +++ b/apps/desktop-tauri/src/components/MenuSurface.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const tauriMocks = vi.hoisted(() => ({ @@ -18,7 +18,7 @@ vi.mock("@tauri-apps/api/event", () => eventMocks); import { LocaleProvider } from "../i18n/LocaleProvider"; import { buildBundle } from "../test/localeHarness"; -import { MenuSummary } from "./MenuSurface"; +import MenuSurface, { MenuSummary, type MenuFooterRow } from "./MenuSurface"; function renderSummary(total: number) { return render( @@ -54,3 +54,129 @@ describe("MenuSummary", () => { expect(await screen.findByText("2 providers")).toBeInTheDocument(); }); }); + +function renderFooter(groups: MenuFooterRow[][]) { + return render( + + +
cards
+
+
, + ); +} + +describe("MenuSurface footer", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.getLocaleStrings.mockResolvedValue(buildBundle()); + eventMocks.listen.mockResolvedValue(() => {}); + }); + + it("renders the Mac footer groups in order, each after a separator", async () => { + const noop = vi.fn(); + renderFooter([ + [ + { id: "switchAccount", label: "Switch Account...", onClick: noop }, + { id: "dashboard", label: "Usage Dashboard", onClick: noop }, + { id: "statusPage", label: "Status Page", onClick: noop }, + ], + [ + { id: "refresh", label: "Refresh", shortcut: "Ctrl+R", onClick: noop }, + { id: "settings", label: "Settings...", shortcut: "Ctrl+,", onClick: noop }, + { id: "about", label: "About CodexBar (v0.70.0)", onClick: noop }, + { id: "quit", label: "Quit", shortcut: "Ctrl+Q", onClick: noop }, + ], + ]); + + const nav = await screen.findByRole("navigation", { name: "PanelMenu" }); + const rows = within(nav).getAllByRole("button"); + expect(rows.map((row) => row.textContent)).toEqual([ + "Switch Account...", + "Usage Dashboard", + "Status Page", + "RefreshCtrl+R", + "Settings...Ctrl+,", + "About CodexBar (v0.70.0)", + "QuitCtrl+Q", + ]); + expect( + Array.from(nav.children).map((child) => + child.classList.contains("menu-surface__footer-sep") ? "sep" : child.textContent, + ), + ).toEqual([ + "sep", + "Switch Account...", + "Usage Dashboard", + "Status Page", + "sep", + "RefreshCtrl+R", + "Settings...Ctrl+,", + "About CodexBar (v0.70.0)", + "QuitCtrl+Q", + ]); + }); + + it("draws an SVG icon on every row except Refresh, which gets the taller row", async () => { + const noop = vi.fn(); + renderFooter([ + [ + { id: "switchAccount", label: "Switch Account...", onClick: noop }, + { id: "dashboard", label: "Usage Dashboard", onClick: noop }, + { id: "statusPage", label: "Status Page", onClick: noop }, + { id: "refresh", label: "Refresh", shortcut: "Ctrl+R", onClick: noop }, + { id: "settings", label: "Settings...", shortcut: "Ctrl+,", onClick: noop }, + { id: "about", label: "About CodexBar (v0.70.0)", onClick: noop }, + { id: "quit", label: "Quit", shortcut: "Ctrl+Q", onClick: noop }, + ], + ]); + + const nav = await screen.findByRole("navigation", { name: "PanelMenu" }); + const rows = within(nav).getAllByRole("button"); + expect(rows.map((row) => row.querySelectorAll("svg").length)).toEqual([1, 1, 1, 0, 1, 1, 1]); + expect( + rows.map((row) => row.classList.contains("menu-surface__footer-row--refresh")), + ).toEqual([false, false, false, true, false, false, false]); + expect( + Array.from(nav.querySelectorAll(".menu-surface__footer-shortcut")).map((s) => s.textContent), + ).toEqual(["Ctrl+R", "Ctrl+,", "Ctrl+Q"]); + }); + + it("skips empty groups, so no separator is drawn for them", async () => { + renderFooter([[], [{ id: "quit", label: "Quit", shortcut: "Ctrl+Q", onClick: vi.fn() }]]); + + const nav = await screen.findByRole("navigation", { name: "PanelMenu" }); + expect(nav.querySelectorAll(".menu-surface__footer-sep")).toHaveLength(1); + expect(within(nav).getAllByRole("button").map((row) => row.textContent)).toEqual([ + "QuitCtrl+Q", + ]); + }); + + it("runs the clicked row's action and names its shortcut for assistive tech", async () => { + const onRefresh = vi.fn(); + const onSettings = vi.fn(); + renderFooter([ + [ + { id: "refresh", label: "Refresh", shortcut: "Ctrl+R", onClick: onRefresh }, + { id: "settings", label: "Settings...", shortcut: "Ctrl+,", onClick: onSettings }, + ], + ]); + + const settings = await screen.findByRole("button", { name: "Settings..." }); + expect(screen.getByRole("button", { name: "Refresh" })).toHaveAttribute( + "aria-keyshortcuts", + "Control+R", + ); + expect(settings).toHaveAttribute("aria-keyshortcuts", "Control+,"); + fireEvent.click(settings); + + expect(onSettings).toHaveBeenCalledTimes(1); + expect(onRefresh).toHaveBeenCalledTimes(0); + }); + + it("renders no footer when every group is empty", async () => { + renderFooter([[], []]); + + expect(await screen.findByText("cards")).toBeInTheDocument(); + expect(screen.queryByRole("navigation")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/components/MenuSurface.tsx b/apps/desktop-tauri/src/components/MenuSurface.tsx index f9b08c48b0..9ec13766ab 100644 --- a/apps/desktop-tauri/src/components/MenuSurface.tsx +++ b/apps/desktop-tauri/src/components/MenuSurface.tsx @@ -1,21 +1,75 @@ import type { CSSProperties, ReactNode } from "react"; import { useLocale } from "../hooks/useLocale"; +export type MenuRowId = + | "switchAccount" + | "dashboard" + | "statusPage" + | "refresh" + | "settings" + | "about" + | "quit"; + export interface MenuFooterRow { - icon: string; + id: MenuRowId; label: string; shortcut?: string; onClick: () => void; } +function lineIcon(...paths: ReactNode[]) { + return ( + + {paths} + + ); +} + +/** 16px line icons after the SF Symbols the macOS menu uses + * (key, chart.xyaxis.line, waveform.path.ecg, gearshape, info.circle, + * xmark.rectangle). Refresh has no icon on the Mac. */ +const ROW_ICONS: Record = { + switchAccount: lineIcon( + , + , + ), + dashboard: lineIcon( + , + , + ), + statusPage: lineIcon(), + refresh: null, + settings: lineIcon( + , + , + ), + about: lineIcon( + , + , + , + ), + quit: lineIcon( + , + , + ), +}; + interface MenuSurfaceProps { summary?: ReactNode; banner?: ReactNode; - /** Non-button content rendered in the footer nav BEFORE the mapped - * `footerRows` (e.g. the tray zoom control). Rendering it here — rather - * than as a `footerRows` entry — keeps it a plain `div`, not a `button`. */ - footerLead?: ReactNode; - footerRows?: MenuFooterRow[]; + /** Footer rows in groups. A separator is drawn before each non-empty + * group, like the separators between the macOS menu sections. */ + footerGroups?: MenuFooterRow[][]; /** Inline style applied to the root `menu-surface` element (e.g. CSS * `zoom` for the tray flyout). */ style?: CSSProperties; @@ -26,46 +80,53 @@ interface MenuSurfaceProps { * Flush, compact container for the tray panel (`TrayPanel`), the only * dashboard layout. It renders in the tray-panel flyout window. * - * Mirrors the upstream macOS `MenuContent`: a narrow VStack(spacing: 8) - * inside an NSMenu-like popover (310pt wide, vertical 6 / horizontal 10 - * padding, no hero framing). The body holds a stack of full provider - * cards (`MenuCard`) — one per enabled provider — exactly like upstream. + * Mirrors the upstream macOS `MenuContent`: a 310pt menu panel holding a + * stack of full provider cards (`MenuCard`), one per enabled provider, + * followed by the menu rows. */ export default function MenuSurface({ summary, banner, - footerLead, - footerRows, + footerGroups = [], style, children, }: MenuSurfaceProps) { const { t } = useLocale(); + const groups = footerGroups.filter((group) => group.length > 0); return (
{banner} {summary}
{children}
- {(footerLead || (footerRows && footerRows.length > 0)) && ( + {groups.length > 0 && ( )}
diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index 9ae83df78a..64c15de620 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -1,17 +1,18 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import type { BootstrapState, ProviderUsageSnapshot } from "../types/bridge"; +import type { BootstrapState } from "../types/bridge"; import { beginFlyoutGesture, dismissTrayPanel, endFlyoutGesture, - flyoutStoredSize, + getAppInfo, + getProviderDetail, + getSystemAccentColor, + openProviderDashboard, + openProviderStatusPage, openSettingsWindow, quitApp as quitApplication, - reanchorTrayPanel, reorderProviders, - setFlyoutSize, - updateSettings, } from "../lib/tauri"; import { useProviders } from "./useProviders"; import { useSettings } from "./useSettings"; @@ -21,7 +22,9 @@ import { useProviderSwitcherKeys } from "./useProviderSwitcherKeys"; import { useSurfaceTarget } from "./useSurfaceMode"; import { useTrayPanelLayout } from "./useTrayPanelLayout"; import type { MenuFooterRow } from "../components/MenuSurface"; +import { hasSuccessfulClaudeCliQuota } from "../lib/claudeAccountActions"; import { orderProviderSnapshots } from "../lib/providerOrder"; +import { clampTrayScalePercent } from "../lib/trayScale"; import { hydrateProviderSlots, orderedEnabledProviderSlots, @@ -30,18 +33,11 @@ import { const TRAY_INITIAL_REFRESH_DELAY_MS = 250; const DENSE_OVERVIEW_THRESHOLD = 32; -// ── Tray flyout zoom (footer slider, above Refresh) ─────────────────── -// Applied via CSS `zoom` on the MenuSurface root (see TrayPanel render). -export const TRAY_SCALE_MIN = 100; -export const TRAY_SCALE_MAX = 200; -export const TRAY_SCALE_STEP = 5; -const TRAY_SCALE_COMMIT_DEBOUNCE_MS = 250; - -function clampTrayScalePercent(value: number): number { - return Math.min( - TRAY_SCALE_MAX, - Math.max(TRAY_SCALE_MIN, Number.isFinite(value) ? value : 100), - ); +/** Which of a provider's web pages the backend can open. */ +interface ProviderLinks { + providerId: string; + dashboard: boolean; + statusPage: boolean; } /** @@ -67,46 +63,29 @@ export function useTrayPanelController(state: BootstrapState) { const { t } = useLocale(); const surfaceTarget = useSurfaceTarget("trayPanel"); - // Zoom slider: LOCAL draft state drives both the thumb and the live CSS - // zoom preview while dragging; persistence trails behind a ~250ms debounce - // (fire-and-forget updateSettings). The settings_changed echo — from our - // own commit round-trip or another window — only re-syncs the draft when - // no debounce is pending, so it can't fight the thumb mid-drag. - const settingsTrayScalePercent = clampTrayScalePercent( - settings.trayScalePercent, - ); - const [trayScaleDraft, setTrayScaleDraft] = useState( - settingsTrayScalePercent, - ); - const trayScaleCommitTimerRef = useRef(undefined); + // Settings > Menu > Panel scale, applied as CSS zoom on the panel. + const trayScale = clampTrayScalePercent(settings.trayScalePercent) / 100; + + const [appVersion, setAppVersion] = useState(null); + const [accentColor, setAccentColor] = useState(null); useEffect(() => { - if (trayScaleCommitTimerRef.current === undefined) { - setTrayScaleDraft(settingsTrayScalePercent); - } - }, [settingsTrayScalePercent]); - useEffect( - () => () => { - if (trayScaleCommitTimerRef.current !== undefined) { - window.clearTimeout(trayScaleCommitTimerRef.current); - } - }, - [], - ); - const handleTrayScaleChange = useCallback((value: number) => { - const next = clampTrayScalePercent(value); - setTrayScaleDraft(next); - if (trayScaleCommitTimerRef.current !== undefined) { - window.clearTimeout(trayScaleCommitTimerRef.current); - } - trayScaleCommitTimerRef.current = window.setTimeout(() => { - trayScaleCommitTimerRef.current = undefined; - void updateSettings({ trayScalePercent: next }).catch(() => {}); - }, TRAY_SCALE_COMMIT_DEBOUNCE_MS); + let cancelled = false; + void getAppInfo().then( + (info) => { + if (!cancelled) setAppVersion(info.version); + }, + () => {}, + ); + void getSystemAccentColor().then( + (color) => { + if (!cancelled) setAccentColor(color); + }, + () => {}, + ); + return () => { + cancelled = true; + }; }, []); - const trayScale = trayScaleDraft / 100; - const trayScaleFillPercent = - ((trayScaleDraft - TRAY_SCALE_MIN) / (TRAY_SCALE_MAX - TRAY_SCALE_MIN)) * - 100; const sorted = useMemo( () => @@ -176,6 +155,96 @@ export function useTrayPanelController(state: BootstrapState) { return [match]; }, [denseTrayProviders, sorted, selectedProviderId, gridExpanded]); + // The account and link rows act on the selected provider, or on the only + // one: macOS shows them on a provider tab, and with a single provider there + // is no overview tab. + const actionProvider = + selectedProviderId !== null + ? (providersById.get(selectedProviderId) ?? null) + : sorted.length === 1 + ? sorted[0] + : null; + const actionProviderId = actionProvider?.providerId ?? null; + const [providerLinks, setProviderLinks] = useState(null); + useEffect(() => { + if (actionProviderId === null) return; + let cancelled = false; + void getProviderDetail(actionProviderId) + .then( + (detail) => ({ + dashboard: Boolean(detail.dashboardUrl), + statusPage: Boolean(detail.statusPageUrl), + }), + () => ({ dashboard: false, statusPage: false }), + ) + .then((links) => { + if (!cancelled) setProviderLinks({ providerId: actionProviderId, ...links }); + }); + return () => { + cancelled = true; + }; + }, [actionProviderId]); + // Links resolved for a previously selected provider never show. + const links = + providerLinks !== null && providerLinks.providerId === actionProviderId + ? providerLinks + : null; + + const openSettings = useCallback(() => { + void openSettingsWindow("general").finally(() => { + void getCurrentWindow().close(); + }); + }, []); + const openAbout = useCallback(() => { + void openSettingsWindow("about").finally(() => { + void getCurrentWindow().close(); + }); + }, []); + const quitApp = useCallback(() => { + void quitApplication(); + }, []); + + const actionRows: MenuFooterRow[] = []; + if (actionProvider !== null && hasSuccessfulClaudeCliQuota(actionProvider)) { + actionRows.push({ + id: "switchAccount", + label: t("TrayMenuSwitchAccount"), + onClick: () => void openSettingsWindow("providers").catch(() => {}), + }); + } + if (links?.dashboard) { + const { providerId } = links; + actionRows.push({ + id: "dashboard", + label: t("TrayMenuUsageDashboard"), + onClick: () => void openProviderDashboard(providerId).catch(() => {}), + }); + } + if (links?.statusPage) { + const { providerId } = links; + actionRows.push({ + id: "statusPage", + label: t("TrayMenuStatusPage"), + onClick: () => void openProviderStatusPage(providerId).catch(() => {}), + }); + } + const footerGroups: MenuFooterRow[][] = [ + actionRows, + [ + { id: "refresh", label: t("ActionRefresh"), shortcut: "Ctrl+R", onClick: refresh }, + { id: "settings", label: t("MenuSettings"), shortcut: "Ctrl+,", onClick: openSettings }, + { + id: "about", + label: appVersion + ? t("MenuAboutVersion").replace("{}", appVersion) + : t("MenuAbout"), + onClick: openAbout, + }, + { id: "quit", label: t("MenuQuit"), shortcut: "Ctrl+Q", onClick: quitApp }, + ], + ]; + const actionRowIds = actionRows.map((row) => row.id).join(","); + const layoutKey = useMemo( () => [ @@ -188,7 +257,8 @@ export function useTrayPanelController(state: BootstrapState) { expectsDenseOverview ? "dense" : "normal", hasLoadedCache ? "cache-ready" : "cache-pending", visibleProviders.map((provider) => provider.providerId).join(","), - trayScaleDraft, + actionRowIds, + trayScale, ].join("|"), [ selectedProviderId, @@ -200,122 +270,19 @@ export function useTrayPanelController(state: BootstrapState) { expectsDenseOverview, hasLoadedCache, visibleProviders, - trayScaleDraft, + actionRowIds, + trayScale, ], ); - // Flyout sizing: auto-fit to content until the user manually drags the border, - // then remember + honor their size (position always re-anchors above the tray). - // `flyoutSize`: undefined = loading, null = auto-fit, [w,h] = user's fixed size. - const [flyoutSize, setFlyoutSizeState] = useState< - [number, number] | null | undefined - >(undefined); - const [autoFitKilled, setAutoFitKilled] = useState(false); - useEffect(() => { - let active = true; - void flyoutStoredSize() - .then((size) => { - if (active) setFlyoutSizeState(size); - }) - .catch(() => { - if (active) setFlyoutSizeState(null); - }); - return () => { - active = false; - }; - }, []); - - const saveSizeTimerRef = useRef(undefined); - const handleUserResize = useCallback((width: number, height: number) => { - // Stop auto-fit immediately so it can't fight the drag; commit the size - // (state + persistence) after the drag settles. - setAutoFitKilled(true); - if (saveSizeTimerRef.current !== undefined) { - window.clearTimeout(saveSizeTimerRef.current); - } - saveSizeTimerRef.current = window.setTimeout(() => { - // The drag reports physical px. Keep logical px so the size holds on a - // monitor with another DPI; the scale is read once the window settled, - // so a resize caused by such a move converts with the new scale. - void getCurrentWindow() - .scaleFactor() - .then((scale) => { - const logical: [number, number] = [ - Math.round(width / scale), - Math.round(height / scale), - ]; - setFlyoutSizeState(logical); - // Re-anchor at the new size like every other resize, so a panel - // widened from its tray-facing edge doesn't stay past the taskbar. - void Promise.resolve(reanchorTrayPanel()).catch(() => {}); - return setFlyoutSize(logical[0], logical[1]); - }) - .catch(() => {}); - }, 300); - }, []); - useEffect( - () => () => { - if (saveSizeTimerRef.current !== undefined) { - window.clearTimeout(saveSizeTimerRef.current); - } - }, - [], - ); - - // TrayPanel now renders exclusively inside its own dedicated "flyout" OS - // window (see App.tsx's isFlyoutWindow() routing) — it is no longer a - // state of the shared `main` window's surface-mode machine. The old - // `useSurfaceMode() === "trayPanel"` check would be permanently false - // here (outside proof mode that machine only tracks Hidden/Settings), - // which would silently gate off the fixed-size restore + reveal below - // (useTrayPanelLayout's `isOpen` gate) — a user-resized flyout would never - // reveal itself. Hardcoded true: being mounted IS "the flyout is open". - const isFlyoutOpen = true; - const fixedFlyoutSize = Array.isArray(flyoutSize) ? flyoutSize : null; - const useWideColumns = - selectedProviderId === null && - fixedFlyoutSize !== null && - fixedFlyoutSize[0] >= 640; - const wideColumns = useMemo(() => { - const columns: ProviderUsageSnapshot[][] = [[], []]; - visibleProviders.forEach((provider, index) => { - columns[index % 2].push(provider); - }); - return columns; - }, [visibleProviders]); const { layoutReady, requestLayout } = useTrayPanelLayout({ canMeasure: hasLoadedCache || sorted.length > 0, denseOverview: expectsDenseOverview, detailMode: selectedProviderId !== null, layoutKey, - autoFit: flyoutSize === null && !autoFitKilled, - fixedSize: fixedFlyoutSize, - isOpen: isFlyoutOpen, zoom: trayScale, - onUserResize: handleUserResize, }); - const openSettings = useCallback(() => { - void openSettingsWindow("general").finally(() => { - void getCurrentWindow().close(); - }); - }, []); - const openAbout = useCallback(() => { - void openSettingsWindow("about").finally(() => { - void getCurrentWindow().close(); - }); - }, []); - const quitApp = useCallback(() => { - void quitApplication(); - }, []); - - const footerRows: MenuFooterRow[] = [ - { icon: "↻", label: t("ActionRefresh"), shortcut: "Ctrl+R", onClick: refresh }, - { icon: "⚙", label: t("MenuSettings"), shortcut: "Ctrl+,", onClick: openSettings }, - { icon: "ⓘ", label: t("MenuAbout"), onClick: openAbout }, - { icon: "⌧", label: t("MenuQuit"), shortcut: "Ctrl+Q", onClick: quitApp }, - ]; - // Keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -376,7 +343,7 @@ export function useTrayPanelController(state: BootstrapState) { void endFlyoutGesture().catch(() => {}); }, []); - const revealClassName = `tray-panel-reveal${layoutReady ? " tray-panel-reveal--ready" : ""}${expectsDenseOverview ? " tray-panel-reveal--dense" : ""}${fixedFlyoutSize ? " tray-panel-reveal--usersized" : ""}`; + const revealClassName = `tray-panel-reveal${layoutReady ? " tray-panel-reveal--ready" : ""}`; return { t, @@ -385,21 +352,17 @@ export function useTrayPanelController(state: BootstrapState) { refreshingProviderIds, refresh, hasCachedData, - trayScaleDraft, trayScale, - trayScaleFillPercent, - handleTrayScaleChange, + accentColor, sorted, gridProviders, selectedProviderId, gridExpanded, setGridExpanded, visibleProviders, - wideColumns, - useWideColumns, layoutReady, requestLayout, - footerRows, + footerGroups, updateState, checkNow, download, diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx index 1e70c015ff..2ac0eadd54 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx @@ -26,7 +26,7 @@ const tauriMocks = vi.hoisted(() => ({ const windowMocks = vi.hoisted(() => ({ setSize: vi.fn().mockResolvedValue(undefined), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 420 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 420 }), getCurrentWindow: vi.fn(), LogicalSize: vi.fn((width: number, height: number) => ({ width, height })), PhysicalSize: vi.fn((width: number, height: number) => ({ width, height })), @@ -41,19 +41,20 @@ let surface: HTMLElement; let feedbackObserverCallbacks = 0; class StyleFeedbackResizeObserver { - private mutationObserver: MutationObserver | null = null; + private mutationObservers: MutationObserver[] = []; constructor(private readonly callback: ResizeObserverCallback) {} observe(target: Element): void { - this.mutationObserver = new MutationObserver(() => { + const mutationObserver = new MutationObserver(() => { feedbackObserverCallbacks += 1; this.callback([], this as unknown as ResizeObserver); }); - this.mutationObserver.observe(target, { + mutationObserver.observe(target, { attributes: true, attributeFilter: ["style"], }); + this.mutationObservers.push(mutationObserver); } unobserve(): void { @@ -61,8 +62,10 @@ class StyleFeedbackResizeObserver { } disconnect(): void { - this.mutationObserver?.disconnect(); - this.mutationObserver = null; + for (const mutationObserver of this.mutationObservers) { + mutationObserver.disconnect(); + } + this.mutationObservers = []; } } @@ -79,7 +82,7 @@ function mountSurface(): void { } /** Drive the auto-fit measure: jsdom rects are 0, so scrollHeight dominates → - * contentHeight = scrollHeight + 4 (measure pipeline, zoom=1). */ + * contentHeight = scrollHeight + 1 (measure pipeline, zoom=1). */ function setScrollHeight(px: number): void { Object.defineProperty(surface, "scrollHeight", { configurable: true, @@ -126,9 +129,6 @@ describe("useTrayPanelLayout sizing", () => { ); windowMocks.getCurrentWindow.mockReturnValue({ setSize: windowMocks.setSize, - close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(SCALE), - onResized: vi.fn().mockResolvedValue(() => {}), innerSize: windowMocks.innerSize, } as never); }); @@ -203,7 +203,7 @@ describe("useTrayPanelLayout sizing", () => { }); it("commits stable small changes, locks the reporter pair on the larger member, tracks retained height in the DOM", async () => { - setScrollHeight(535); // → 539 logical → 674 physical + setScrollHeight(538); // → 539 logical → 674 physical const { result } = renderHook(() => useTrayPanelLayout(hookProps())); await waitFor(() => expect(result.current.layoutReady).toBe(true), { timeout: 3000, @@ -215,48 +215,48 @@ describe("useTrayPanelLayout sizing", () => { ).toBe(true); // (1) Stable one-way +5-physical change COMMITS (no blanket deadband). - await nudgePass(result, 539, "543px"); // → 543 → 679 phys - expect(lastResize()).toEqual({ width: 328, height: 543 }); + await nudgePass(result, 542, "543px"); // → 543 → 679 phys + expect(lastResize()).toEqual({ width: 310, height: 543 }); expect(surface.style.maxHeight).toBe("543px"); // (2) Reporter-class alternation: one commit to 549 (686 phys), then the // 543↔549 pair (679↔686 phys, span 7) is detected on the flip-down. - await nudgePass(result, 545, "549px"); // → 549 → 686 phys - expect(lastResize()).toEqual({ width: 328, height: 549 }); + await nudgePass(result, 548, "549px"); // → 549 → 686 phys + expect(lastResize()).toEqual({ width: 310, height: 549 }); expect(surface.style.maxHeight).toBe("549px"); const lockedResizes = windowMocks.setSize.mock.calls.length; const lockedAnchors = tauriMocks.reanchorTrayPanel.mock.calls.length; - // Flip-down evidence (measure 539→543): detected, suppressed, and the DOM + // Flip-down evidence (measure 542→543): detected, suppressed, and the DOM // constraint stays the RETAINED height — never the smaller candidate. - await nudgePass(result, 539, "549px"); + await nudgePass(result, 542, "549px"); expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors); expect(surface.style.maxHeight).toBe("549px"); // (3) Repeated flips stay suppressed; surface stays at the retained 549. - await nudgePass(result, 545, "549px"); - await nudgePass(result, 539, "549px"); + await nudgePass(result, 548, "549px"); + await nudgePass(result, 542, "549px"); expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors); expect(surface.style.maxHeight).toBe("549px"); // Retained window (549 logical) fully contains BOTH measured sides // (543 and 549 candidates) — no clipping by construction. - expect(lastResize()).toEqual({ width: 328, height: 549 }); + expect(lastResize()).toEqual({ width: 310, height: 549 }); // (4) Real growth outside the pair clears the lock and commits once. - await nudgePass(result, 700, "704px"); // → 704 → 880 phys - expect(lastResize()).toEqual({ width: 328, height: 704 }); + await nudgePass(result, 703, "704px"); // → 704 → 880 phys + expect(lastResize()).toEqual({ width: 310, height: 704 }); expect(surface.style.maxHeight).toBe("704px"); // Real shrink commits once. - await nudgePass(result, 410, "420px"); // → clamp 420 → 525 phys - expect(lastResize()).toEqual({ width: 328, height: 420 }); + await nudgePass(result, 413, "420px"); // → clamp 420 → 525 phys + expect(lastResize()).toEqual({ width: 310, height: 420 }); expect(surface.style.maxHeight).toBe("420px"); // (5) No blanket absorption: a stable 1-physical-px change still commits. - await nudgePass(result, 417, "421px"); // → 421 → 526 phys - expect(lastResize()).toEqual({ width: 328, height: 421 }); + await nudgePass(result, 420, "421px"); // → 421 → 526 phys + expect(lastResize()).toEqual({ width: 310, height: 421 }); expect(surface.style.maxHeight).toBe("421px"); }, 30_000); // 8 bounded 3s settling passes + 3s readiness can exceed Vitest's 5s default. @@ -272,7 +272,7 @@ describe("useTrayPanelLayout sizing", () => { }, ); - setScrollHeight(535); // → 539 target (674 phys requested) → applied 669 + setScrollHeight(538); // → 539 target (674 phys requested) → applied 669 const { result } = renderHook(() => useTrayPanelLayout(hookProps())); await waitFor(() => expect(result.current.layoutReady).toBe(true), { timeout: 3000, @@ -289,20 +289,45 @@ describe("useTrayPanelLayout sizing", () => { // target says 674: suppress (no setSize/reanchor), adopt the candidate. // The prior frame is 420 (525 phys), 144 px away from 669 — the A↔B // detector cannot fire here. - await nudgePass(result, 531, "535px"); // → 535 → 669 phys + await nudgePass(result, 534, "535px"); // → 535 → 669 phys expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors); expect(surface.style.maxHeight).toBe("535px"); // Identical next pass: now exact same-frame stable — still zero churn. - await nudgePass(result, 531, "535px"); + await nudgePass(result, 534, "535px"); expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors); expect(surface.style.maxHeight).toBe("535px"); // Recovery: a real +5-physical change from the reconciled frame commits. - await nudgePass(result, 535, "539px"); // → 539 → 674 phys - expect(lastResize()).toEqual({ width: 328, height: 539 }); + await nudgePass(result, 538, "539px"); // → 539 → 674 phys + expect(lastResize()).toEqual({ width: 310, height: 539 }); expect(surface.style.maxHeight).toBe("539px"); }); + + it("sizes the window at 310 px times the Panel scale and leaves the spare height above the footer out", async () => { + const body = surface.querySelector(".menu-surface__body")!; + const footer = surface.querySelector(".menu-surface__footer")!; + // WebView2 at zoom 1.5: rects are rendered px, scrollHeight is local px, + // and the filled surface parks 201 px of spare height above the footer. + surface.getBoundingClientRect = () => + ({ top: 0, bottom: 800, height: 800 }) as DOMRect; + body.getBoundingClientRect = () => + ({ top: 0, bottom: 400, height: 400 }) as DOMRect; + footer.getBoundingClientRect = () => + ({ top: 601, bottom: 799, height: 198 }) as DOMRect; + setScrollHeight(532); + + const { result } = renderHook(() => + useTrayPanelLayout(hookProps({ detailMode: false, zoom: 1.5 })), + ); + await waitFor(() => expect(result.current.layoutReady).toBe(true), { + timeout: 3000, + }); + + expect(windowMocks.setSize.mock.calls[0][0]).toEqual({ width: 465, height: 200 }); + expect(lastResize()).toEqual({ width: 465, height: 600 }); + expect(surface.style.maxHeight).toBe("400px"); + }); }); diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts index e2543d8921..730b913283 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts @@ -1,9 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { - getCurrentWindow, - LogicalSize, - PhysicalSize, -} from "@tauri-apps/api/window"; +import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window"; import { getWorkAreaRect, reanchorTrayPanel, @@ -16,43 +12,21 @@ import { type TrayAutoFitState, } from "../lib/traySizing"; -const TRAY_WIDTH = 328; +/** The macOS 0.70 menu width (tray-card-spec.md section 1) at 100% Panel scale. */ +const TRAY_BASE_WIDTH = 310; const TRAY_MAX_MEASURE_HEIGHT = 920; const TRAY_OVERVIEW_MIN_HEIGHT = 200; const TRAY_DETAIL_MIN_HEIGHT = 420; const TRAY_DENSE_OVERVIEW_HEIGHT = 776; -/** Scale a measured content height into the window's pixel space. TrayPanel - * renders the surface with `zoom: trayScale` (CSS zoom), but every DOM metric - * the layout pass reads — scrollHeight AND bounding rects — is reported in - * the surface's LOCAL, pre-zoom px (measured identical at 100/150/200%). The - * WIN32 window must be sized in POST-zoom px or tall cards clip below the - * fold (and the zoomed width overflows into a horizontal scrollbar), so the - * raw measure is multiplied by the active zoom before clamping (#265). */ -export function scaledContentHeight(rawHeight: number, zoom: number): number { - if (!Number.isFinite(zoom) || zoom <= 0 || zoom === 1) return rawHeight; - return Math.round(rawHeight * zoom); -} - export interface TrayPanelLayoutOptions { canMeasure: boolean; denseOverview: boolean; detailMode: boolean; layoutKey: string; - /** Auto-fit the window to its content (until the user sets a size). */ - autoFit?: boolean; - /** The user's remembered size in logical px, re-applied + re-anchored each - * time the flyout opens. `null` when the user has not resized yet. */ - fixedSize?: [number, number] | null; - /** Whether the flyout is currently open (surface mode === trayPanel). Used as - * the "just opened" trigger for the fixed-size restore + re-anchor. */ - isOpen?: boolean; - /** Active tray-panel zoom factor (the CSS `zoom` TrayPanel applies to the - * surface). Auto-fit scales its measured content height by this before - * clamping — see `scaledContentHeight`. Defaults to 1 (no zoom). */ + /** Panel scale as a factor: the CSS `zoom` TrayPanel puts on the surface. + * The window width and height follow it. Defaults to 1. */ zoom?: number; - /** Called with the new logical size on a genuine user drag-resize. */ - onUserResize?: (width: number, height: number) => void; } export interface TrayPanelLayout { @@ -60,137 +34,41 @@ export interface TrayPanelLayout { requestLayout: () => void; } +/** + * Fits the tray flyout window to its content: a fixed width of 310 px times + * the Panel scale, and a height measured from the rendered surface. + */ export function useTrayPanelLayout({ canMeasure, denseOverview, detailMode, layoutKey, - autoFit = true, - fixedSize = null, - isOpen = false, zoom = 1, - onUserResize, }: TrayPanelLayoutOptions): TrayPanelLayout { const [layoutReady, setLayoutReady] = useState(false); const [layoutRevision, setLayoutRevision] = useState(0); const layoutReadyRef = useRef(false); const resizeRunRef = useRef(0); const layoutTimerRef = useRef(undefined); - // The window's actual PHYSICAL size after the last resize WE performed. The - // onResized event also reports physical pixels, so comparing physical-to- - // physical needs no scale factor — Tauri scaleFactor / webview devicePixelRatio - // / Win32 can all disagree on a scaled display, and that disagreement is what - // compounded a per-open size growth. + // The window's actual physical size after our last resize. Win32 may snap + // a requested size, and lib/traySizing compares candidates against it. const lastSizeRef = useRef<{ width: number; height: number } | null>(null); const programmaticInFlightRef = useRef(0); - // Auto-fit sizing decision state (committed frame + one-frame history + - // learned oscillation pair) — all frame logic lives in lib/traySizing so - // the #261 cycle detection stays pure and directly testable. + // Sizing decision state (committed frame, one-frame history, learned + // oscillation pair). The #261 cycle detection lives in lib/traySizing. const sizingStateRef = useRef(EMPTY_AUTOFIT_STATE); - const fixedSizeRef = useRef(fixedSize); - useEffect(() => { - fixedSizeRef.current = fixedSize; - }, [fixedSize]); - const onUserResizeRef = useRef(onUserResize); - useEffect(() => { - onUserResizeRef.current = onUserResize; - }, [onUserResize]); - - // Resize the window and record the resulting ACTUAL physical size. Wrapped in - // an in-flight counter so the Resized event(s) this triggers can never be - // mistaken for a user drag, regardless of event timing. - const applySize = useCallback( - async (size: LogicalSize | PhysicalSize): Promise => { - const win = getCurrentWindow(); - programmaticInFlightRef.current += 1; - try { - await win.setSize(size); - const actual = await win.innerSize(); - lastSizeRef.current = { width: actual.width, height: actual.height }; - } catch { - /* ignore */ - } finally { - programmaticInFlightRef.current -= 1; - } - }, - [], - ); - // Report genuine user drag-resizes. Ignore resizes that fire while WE are - // resizing (in-flight counter) or whose physical size still matches the last - // size we applied; anything else is the user dragging the border. Everything - // is in PHYSICAL pixels — no scale conversion, so it can't drift. A DPI change - // (the panel moved onto a monitor with other scaling) announces its rescaled - // size first; adopting it keeps that rescale from counting as a user resize, - // which would freeze an auto-fit panel at a fixed size. - useEffect(() => { - let unlisten: (() => void) | undefined; - let unlistenScale: (() => void) | undefined; - let cancelled = false; + const applySize = useCallback(async (size: LogicalSize): Promise => { const win = getCurrentWindow(); - void (async () => { - try { - unlistenScale = await win.onScaleChanged(({ payload }) => { - lastSizeRef.current = { - width: payload.size.width, - height: payload.size.height, - }; - }); - } catch { - unlistenScale = undefined; - } - try { - unlisten = await win.onResized(({ payload }) => { - if (programmaticInFlightRef.current > 0) return; - const last = lastSizeRef.current; - if ( - last && - Math.abs(payload.width - last.width) <= 3 && - Math.abs(payload.height - last.height) <= 3 - ) { - return; - } - onUserResizeRef.current?.(payload.width, payload.height); - }); - } catch { - unlisten = undefined; - } - if (cancelled) { - unlisten?.(); - unlistenScale?.(); - } - })(); - return () => { - cancelled = true; - unlisten?.(); - unlistenScale?.(); - }; + try { + await win.setSize(size); + const actual = await win.innerSize(); + lastSizeRef.current = { width: actual.width, height: actual.height }; + } catch { + /* ignore */ + } }, []); - // User-sized flyout: on each open, apply the remembered size, re-anchor above - // the tray at THAT size (so the anchor math uses the real height, not the - // default), then reveal. Content scrolls inside the fixed window via CSS. - const hasFixedSize = fixedSize != null; - useEffect(() => { - if (autoFit || !isOpen || !canMeasure) return; - const fixed = fixedSizeRef.current; - if (!fixed) return; - let cancelled = false; - void (async () => { - // `fixed` is the user's remembered LOGICAL size; the window converts it - // with the DPI of the monitor it is on now. - await applySize(new LogicalSize(fixed[0], fixed[1])); - await Promise.resolve(reanchorTrayPanel()).catch(() => {}); - if (cancelled) return; - layoutReadyRef.current = true; - setLayoutReady(true); - await Promise.resolve(revealTrayPanelWindow()).catch(() => {}); - })(); - return () => { - cancelled = true; - }; - }, [autoFit, isOpen, canMeasure, hasFixedSize, applySize]); - const requestLayout = useCallback(() => { if (layoutTimerRef.current !== undefined) { window.clearTimeout(layoutTimerRef.current); @@ -209,7 +87,7 @@ export function useTrayPanelLayout({ if (!surface || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { // Measuring temporarily removes the surface/body constraints, which - // resizes the observed surface. Do not feed that programmatic change + // resizes the observed elements. Do not feed that programmatic change // back into another pass or the capped flyout flashes between its // measured and committed layouts forever. if ( @@ -220,7 +98,15 @@ export function useTrayPanelLayout({ } requestLayout(); }); - observer.observe(surface); + // The surface fills the window, so content changes show up on the body + // and the footer rather than on the surface itself. + for (const part of [ + surface, + surface.querySelector(".menu-surface__body"), + surface.querySelector(".menu-surface__footer"), + ]) { + if (part) observer.observe(part); + } return () => observer.disconnect(); }, [requestLayout]); @@ -233,13 +119,14 @@ export function useTrayPanelLayout({ }, []); useEffect(() => { - if (!autoFit || !canMeasure) return; + if (!canMeasure) return; const minHeight = detailMode ? TRAY_DETAIL_MIN_HEIGHT : denseOverview ? TRAY_DENSE_OVERVIEW_HEIGHT : TRAY_OVERVIEW_MIN_HEIGHT; + const width = Math.round(TRAY_BASE_WIDTH * zoom); const resize = async () => { const run = ++resizeRunRef.current; @@ -258,6 +145,7 @@ export function useTrayPanelLayout({ const body = surface.querySelector(".menu-surface__body"); const stack = surface.querySelector(".menu-stack"); + const footer = surface.querySelector(".menu-surface__footer"); const previous = { htmlOverflow: html.style.overflow, @@ -298,19 +186,23 @@ export function useTrayPanelLayout({ } }; - // Suppress every Resized event this whole auto-fit pass causes (the burst - // of setSize calls + any that arrive shortly after) so none is mistaken - // for a user drag. The trailing delay absorbs late-delivered events. + // Every resize this pass causes, and any that arrive shortly after, + // must not start another pass. The trailing delay absorbs late events. programmaticInFlightRef.current += 1; try { - if (!layoutReadyRef.current) { + // Text wraps at the window width, so the width has to be in place + // before the height is measured. + const committed = sizingStateRef.current.committed; + if (!layoutReadyRef.current || committed?.width !== width) { + const height = + layoutReadyRef.current && committed ? committed.height : minHeight; sizingStateRef.current = recordAutoFitCommit( sizingStateRef.current, - TRAY_WIDTH, - minHeight, + width, + height, window.devicePixelRatio, ); - await applySize(new LogicalSize(TRAY_WIDTH, minHeight)); + await applySize(new LogicalSize(width, height)); } await new Promise((resolve) => requestAnimationFrame(() => resolve())); @@ -318,37 +210,26 @@ export function useTrayPanelLayout({ if (run !== resizeRunRef.current) return; + // WebView2 reports bounding rects in rendered px but scrollHeight in + // the zoomed surface's local px (measured on Edge 154), so the local + // value is scaled before the two are compared. The surface fills the + // window and parks spare height above the footer; that gap is not + // content. The extra 1 px absorbs DPI rounding of the window height. const surfaceRect = surface.getBoundingClientRect(); - // Everything measured here is in the surface's LOCAL, pre-zoom px; - // scale into post-zoom window px via `scaledContentHeight` at each - // derivation point so the clamped window fits the rendered content. - let contentHeight = scaledContentHeight( - Math.max(surface.scrollHeight, Math.ceil(surfaceRect.height)), - zoom, - ); - let maxBottom = surfaceRect.top + contentHeight; const bodyRect = body?.getBoundingClientRect(); - if (bodyRect && bodyRect.height > 0) { - const scaledBottom = - surfaceRect.top + - scaledContentHeight(bodyRect.bottom - surfaceRect.top, zoom); - if (scaledBottom > maxBottom) { - maxBottom = scaledBottom; - } - } - const footer = surface.querySelector(".menu-surface__footer"); const footerRect = footer?.getBoundingClientRect(); - if (footerRect && footerRect.height > 0) { - const scaledBottom = - surfaceRect.top + - scaledContentHeight(footerRect.bottom - surfaceRect.top, zoom); - if (scaledBottom > maxBottom) { - maxBottom = scaledBottom; - } - } - contentHeight = Math.ceil(maxBottom - surfaceRect.top) + 4; - - const height = Math.min(Math.max(contentHeight, minHeight), maxHeight); + const spare = + bodyRect && footerRect + ? Math.max(0, footerRect.top - bodyRect.bottom) + : 0; + const contentBottom = Math.max( + surfaceRect.top + surface.scrollHeight * zoom, + surfaceRect.bottom, + ); + const height = Math.min( + Math.max(Math.ceil(contentBottom - spare) + 1, minHeight), + maxHeight, + ); // #261: two-state cycle detection on physical targets. Normal rule // commits ANY real change (even +5 physical px); only exact same- @@ -359,11 +240,11 @@ export function useTrayPanelLayout({ const decision = decideTrayHeight( { measuredHeight: height, - expectedWidth: TRAY_WIDTH, + expectedWidth: width, minHeight, maxHeight, // WebView layout px ↔ Win32 physical px ratio; CSS zoom does not - // affect it (zoom is already in `height` via scaledContentHeight). + // affect it. scaleFactor: window.devicePixelRatio, zoom, lastAppliedPhysicalHeight: lastSizeRef.current?.height ?? null, @@ -371,11 +252,12 @@ export function useTrayPanelLayout({ sizingStateRef.current, ); sizingStateRef.current = decision.state; - surface.style.maxHeight = `${decision.height}px`; + // A zoomed element's max-height renders at value × zoom. + surface.style.maxHeight = `${decision.height / zoom}px`; committedHeight = true; if (decision.commit) { - await applySize(new LogicalSize(TRAY_WIDTH, decision.height)); + await applySize(new LogicalSize(width, decision.height)); await Promise.resolve(reanchorTrayPanel()).catch(() => {}); } @@ -418,7 +300,7 @@ export function useTrayPanelLayout({ window.clearTimeout(timer); resizeRunRef.current += 1; }; - }, [autoFit, canMeasure, denseOverview, detailMode, layoutRevision, applySize, zoom]); + }, [canMeasure, denseOverview, detailMode, layoutRevision, applySize, zoom]); return { layoutReady, requestLayout }; } diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index f64b59e2c3..d51a6988bd 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -225,7 +225,11 @@ export const ALL_LOCALE_KEYS = [ "ButtonOpenProviderSettings", "MenuSettings", "MenuAbout", + "MenuAboutVersion", "MenuQuit", + "TrayMenuSwitchAccount", + "TrayMenuUsageDashboard", + "TrayMenuStatusPage", "StatusJustUpdated", "StatusUnableToGetUsage", "ActionRefresh", @@ -694,7 +698,6 @@ export const ALL_LOCALE_KEYS = [ "DisplayModeMinimal", "TrayPanelAlwaysOnTopLabel", "TrayPanelAlwaysOnTopHelper", - "TrayMoveHandleHint", "WindowMinimize", "WindowRestore", "WindowClose", @@ -784,7 +787,6 @@ export const ALL_LOCALE_KEYS = [ "PanelAllProvidersShort", "PanelShowAllProviders", "PanelShowFewerProviders", - "PanelZoom", "PanelMenu", "PanelCopied", "PanelToday", diff --git a/apps/desktop-tauri/src/lib/menuSelection.test.ts b/apps/desktop-tauri/src/lib/menuSelection.test.ts new file mode 100644 index 0000000000..0e2d2b5792 --- /dev/null +++ b/apps/desktop-tauri/src/lib/menuSelection.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { accentSelectionStyle } from "./menuSelection"; + +describe("accentSelectionStyle", () => { + it("paints the selection with the Windows accent and white text when white reads on it", () => { + expect(accentSelectionStyle("#0078d4")).toEqual({ + "--mac-selection-bg": "#0078d4", + "--mac-selection-text": "#fff", + }); + }); + + it("switches to dark text on a light accent", () => { + expect(accentSelectionStyle("#ffb900")).toEqual({ + "--mac-selection-bg": "#ffb900", + "--mac-selection-text": "rgba(0, 0, 0, 0.85)", + }); + }); + + it("keeps the stylesheet defaults without an accent", () => { + expect(accentSelectionStyle(null)).toEqual({}); + expect(accentSelectionStyle("accent")).toEqual({}); + }); +}); diff --git a/apps/desktop-tauri/src/lib/menuSelection.ts b/apps/desktop-tauri/src/lib/menuSelection.ts new file mode 100644 index 0000000000..381f184bdb --- /dev/null +++ b/apps/desktop-tauri/src/lib/menuSelection.ts @@ -0,0 +1,25 @@ +import type { CSSProperties } from "react"; + +const HEX_COLOR = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i; + +/** WCAG 2 linear value of one sRGB channel byte. */ +function linearChannel(byte: number): number { + const c = byte / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +/** + * Menu selection colors from the Windows accent, the way macOS menus use the + * system accent. White text stays only where it keeps 4.5:1 contrast. + */ +export function accentSelectionStyle(accent: string | null): CSSProperties { + const match = accent ? HEX_COLOR.exec(accent) : null; + if (!match) return {}; + const [r, g, b] = match.slice(1).map((hex) => linearChannel(parseInt(hex, 16))); + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + const whiteContrast = 1.05 / (luminance + 0.05); + return { + "--mac-selection-bg": accent, + "--mac-selection-text": whiteContrast >= 4.5 ? "#fff" : "rgba(0, 0, 0, 0.85)", + } as CSSProperties; +} diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index d1f8b7131a..a843d6e9e0 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -550,19 +550,9 @@ export function revealTrayPanelWindow(): Promise { return invoke("reveal_tray_panel_window"); } -/** Persist the user's manually-chosen flyout (Pop Out Dashboard) size. */ -export function setFlyoutSize(width: number, height: number): Promise { - return invoke("set_flyout_size", { width, height }); -} - -/** Forget where the user dragged the flyout and anchor it to the tray again. */ -export function resetFlyoutPosition(): Promise { - return invoke("reset_flyout_position"); -} - -/** The remembered flyout size ([w, h]) if the user has resized it, else null. */ -export function flyoutStoredSize(): Promise<[number, number] | null> { - return invoke<[number, number] | null>("flyout_stored_size"); +/** The Windows accent color as `#rrggbb`, or null when Windows has none. */ +export function getSystemAccentColor(): Promise { + return invoke("get_system_accent_color"); } export function quitApp(): Promise { diff --git a/apps/desktop-tauri/src/lib/trayScale.test.ts b/apps/desktop-tauri/src/lib/trayScale.test.ts new file mode 100644 index 0000000000..ce10786fe8 --- /dev/null +++ b/apps/desktop-tauri/src/lib/trayScale.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { clampTrayScalePercent } from "./trayScale"; + +describe("clampTrayScalePercent", () => { + it("keeps the panel scale between 100% and 200%", () => { + expect(clampTrayScalePercent(150)).toBe(150); + expect(clampTrayScalePercent(40)).toBe(100); + expect(clampTrayScalePercent(260)).toBe(200); + expect(clampTrayScalePercent(Number.NaN)).toBe(100); + }); +}); diff --git a/apps/desktop-tauri/src/lib/trayScale.ts b/apps/desktop-tauri/src/lib/trayScale.ts new file mode 100644 index 0000000000..2936c3d2bb --- /dev/null +++ b/apps/desktop-tauri/src/lib/trayScale.ts @@ -0,0 +1,10 @@ +/** Panel scale bounds in percent. The backend clamps `trayScalePercent` to + * the same range when it loads or saves settings. */ +export const TRAY_SCALE_MIN = 100; +export const TRAY_SCALE_MAX = 200; +export const TRAY_SCALE_STEP = 5; + +export function clampTrayScalePercent(value: number): number { + if (!Number.isFinite(value)) return TRAY_SCALE_MIN; + return Math.min(TRAY_SCALE_MAX, Math.max(TRAY_SCALE_MIN, value)); +} diff --git a/apps/desktop-tauri/src/lib/traySizing.test.ts b/apps/desktop-tauri/src/lib/traySizing.test.ts index ff990acef3..2dce585a9c 100644 --- a/apps/desktop-tauri/src/lib/traySizing.test.ts +++ b/apps/desktop-tauri/src/lib/traySizing.test.ts @@ -8,7 +8,7 @@ import { type TraySizingInput, } from "./traySizing"; -const TRAY_WIDTH = 328; +const TRAY_WIDTH = 310; const MIN = 420; const MAX = 920; diff --git a/apps/desktop-tauri/src/lib/traySizing.ts b/apps/desktop-tauri/src/lib/traySizing.ts index 10a357535e..cf53edcc71 100644 --- a/apps/desktop-tauri/src/lib/traySizing.ts +++ b/apps/desktop-tauri/src/lib/traySizing.ts @@ -74,7 +74,7 @@ export const EMPTY_AUTOFIT_STATE: TrayAutoFitState = { export interface TraySizingInput { /** Measured content height, logical px, post zoom-scale, pre clamp. */ measuredHeight: number; - /** Logical content width the flyout always carries (TRAY_WIDTH). */ + /** Logical window width: 310 px times the Panel scale. */ expectedWidth: number; minHeight: number; maxHeight: number; diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index b2c32b6156..1b7b7b47f7 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -1046,181 +1046,32 @@ html { /* Hide the panel during the initial resize + reposition dance so the user never sees the window jump around. Once layout is measured and the window is anchored in its final position, .tray-panel-reveal--ready - fades the content in smoothly. */ + fades the content in smoothly. The page behind the panel carries the + panel fill (--mac-panel-bg, and PANEL_FILL in shell/flyout_window.rs) so + no other color shows while the panel is hidden. */ html:has(.tray-panel-reveal), -body:has(.tray-panel-reveal) { - background: var(--menu-popover-bg); +body:has(.tray-panel-reveal), +.tray-panel-reveal { + background: #dedee2; } .tray-panel-reveal { display: flex; flex-direction: column; min-height: 100vh; - background: var(--menu-popover-bg); opacity: 0; } .tray-panel-reveal--ready { opacity: 1; transition: opacity 80ms ease-out; } - -.tray-panel-reveal--dense .menu-surface--tray { - min-height: 100vh; -} - -/* User-resized flyout: the window is a fixed (remembered) size, so the surface - fills the viewport height and its body scrolls instead of the window growing - to fit content. */ -.tray-panel-reveal--usersized { - height: 100vh; - min-height: 0; -} -.tray-panel-reveal--usersized .menu-surface--tray { - flex: 1 1 auto; - min-height: 0; - height: auto; - max-height: 100%; -} - -/* User-sized flyout body must scroll vertically when content overflows the - remembered window height — the base `.menu-surface--tray .menu-surface__body` - rule (below) uses `overflow-y: visible` because auto-fit sizes the window to - content instead of scrolling. Usersized mode is a fixed window, so it needs - the opposite: clip + scroll. The body also flex-grows to absorb any free - space so the footer (Zoom/Refresh/Settings/About/Quit) pins to the bottom - edge instead of floating mid-panel after the user widens the flyout. - Scoped to `--usersized` only; auto-fit path is untouched. Applies in both - narrow (single column) and wide (side-by-side, see the 640px media query - below) layouts. */ -.tray-panel-reveal--usersized .menu-surface--tray .menu-surface__body { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; -} - -/* Side-by-side provider cards once the user has widened the flyout enough to - fit two columns. The columns are independent so a tall card in one column - doesn't force empty space under the shorter card beside it. */ -@media (min-width: 640px) { - .tray-panel-reveal--usersized .menu-surface--tray .menu-stack { - display: flex; - flex-direction: row; - align-content: flex-start; - align-items: flex-start; - gap: 12px 16px; - width: 100%; - } - - .tray-panel-reveal--usersized .menu-surface--tray .menu-stack__column { - flex: 1 1 0; - min-width: min(300px, 100%); - display: flex; - flex-direction: column; - gap: 12px; - } - - .tray-panel-reveal--usersized .menu-surface--tray .menu-stack__sep { - display: none; - } -} - -/* Flyout window handles. Resize grips are invisible strips on every edge and - corner (a moved flyout can face open screen on any side); the cursor change - signals they're draggable. The move strip spans the top between the corner - grips, under the top edge grip, and shows a small grab bar. */ -.tray-resize { - position: fixed; - z-index: 50; -} -.tray-resize--top, -.tray-resize--bottom { - left: 0; - right: 0; - height: 6px; - cursor: ns-resize; -} -.tray-resize--top { - top: 0; -} -.tray-resize--bottom { - bottom: 0; -} -.tray-resize--left, -.tray-resize--right { - top: 0; - bottom: 0; - width: 6px; - cursor: ew-resize; -} -.tray-resize--left { - left: 0; -} -.tray-resize--right { - right: 0; -} -.tray-resize--topleft, -.tray-resize--topright, -.tray-resize--bottomleft, -.tray-resize--bottomright { - width: 14px; - height: 14px; - z-index: 51; -} -.tray-resize--topleft { - top: 0; - left: 0; - cursor: nwse-resize; -} -.tray-resize--bottomright { - bottom: 0; - right: 0; - cursor: nwse-resize; -} -.tray-resize--topright { - top: 0; - right: 0; - cursor: nesw-resize; -} -.tray-resize--bottomleft { - bottom: 0; - left: 0; - cursor: nesw-resize; -} -.tray-move-handle { - position: fixed; - top: 0; - left: 14px; - right: 14px; - height: 12px; - z-index: 49; - display: flex; - align-items: center; - justify-content: center; - cursor: move; -} -.tray-move-handle__grip { - width: 32px; - height: 3px; - border-radius: 2px; - background: rgba(255, 255, 255, 0.16); - transition: background 120ms ease-out; -} -.tray-move-handle:hover .tray-move-handle__grip { - background: rgba(255, 255, 255, 0.36); -} -[data-theme="light"] .tray-move-handle__grip { - background: rgba(0, 0, 0, 0.14); -} -[data-theme="light"] .tray-move-handle:hover .tray-move-handle__grip { - background: rgba(0, 0, 0, 0.32); -} -/* Keep the tray surface's first row clear of the move strip. */ +/* The window keeps a height floor, so it can be taller than the content. + The panel fills the window and parks the spare height above the footer, + which keeps the footer rows next to the tray. */ .tray-panel-reveal .menu-surface--tray { - padding-top: 12px; + flex-grow: 1; } - -.shell--tray-panel { - max-width: 320px; - padding: 8px 10px 12px; +.tray-panel-reveal .menu-surface--tray .menu-surface__footer { + margin-top: auto; } /* spin animation for refresh */ @@ -3529,20 +3380,124 @@ html:has(.menu-surface--tray) { overflow: visible; } -/* Tray variant — auto-sized by JS, no forced min-height. */ +/* Tray panel: a fixed light panel after the macOS 0.70 menu material + (tray-card-spec.md section 1) under every app theme. The wrapper sets + data-theme="light"; tokens that only the dark theme block defines get + tray values here so theme dark, light and auto render the same panel. */ .menu-surface--tray { - min-height: 0; - border-radius: 10px; - overflow: clip; - border: 1px solid rgba(255, 255, 255, 0.10); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); - padding: 4px 0; + --mac-font: "Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI", system-ui, sans-serif; + --mac-text-headline: 600 13px/16px var(--mac-font); + --mac-text-body-medium: 500 13px/16px var(--mac-font); + --mac-text-subheadline: 400 11px/14px var(--mac-font); + --mac-text-footnote: 400 10px/13px var(--mac-font); + --mac-text-caption: 400 10px/13px var(--mac-font); + --mac-text-section: 600 10px/13px var(--mac-font); + --mac-text-caption2: 400 10px/13px var(--mac-font); + --mac-text-menu: 400 13px/16px var(--mac-font); + --mac-text-shortcut: 400 13px/16px var(--mac-font); + + --mac-panel-bg: #dedee2; + --mac-panel-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.25); + --mac-panel-border: rgba(0, 0, 0, 0.38); + --mac-text: rgba(0, 0, 0, 0.85); + --mac-text-2: rgba(0, 0, 0, 0.5); + --mac-text-3: rgba(0, 0, 0, 0.26); + --mac-track: rgba(0, 0, 0, 0.057); + --mac-divider: rgba(0, 0, 0, 0.15); + --mac-separator: rgba(0, 0, 0, 0.1); + --mac-error: #ff3b30; + --mac-pace-ok: #34c759; + --mac-pace-bad: #ff3b30; + --mac-marker: rgba(0, 0, 0, 0.58); + --mac-accent: #007aff; + /* TrayPanel overrides this inline with the Windows accent color. */ + --mac-selection-bg: #0078d4; + --mac-selection-text: #fff; + + --mac-panel-width: 310px; + --mac-panel-radius: 6px; + --mac-bar-h: 6px; + --mac-bar-r: 3px; + --mac-pace-punch: 6px; + --mac-pace-stripe: 2px; + --mac-warn-punch: 5px; + --mac-warn-stripe: 1px; + --mac-metric-card-bg: rgba(0, 0, 0, 0.04); + --mac-metric-card-r: 10px; + --mac-icon-btn-r: 4px; + --mac-chart-h: 58px; + --mac-chart-bar-r: 1.5px; + --mac-sel-inset-x: 6px; + --mac-sel-inset-y: 2px; + --mac-sel-r: 6px; + --mac-sel-fade: 60ms; + --mac-sel-r-refresh: 7px; + + --mac-pad-x: 20px; + --mac-pad-header-y: 6px; + --mac-gap-header: 6px; + --mac-section-pt: 6px; + --mac-usage-pt: 10px; + --mac-section-pb: 6px; + --mac-header-line-gap: 4px; + --mac-header-col-gap: 12px; + --mac-after-divider: 16px; + --mac-metric-gap: 12px; + --mac-metric-inner: 6px; + --mac-inline-gap: 8px; + --mac-kv-section: 6px; + --mac-kv-row: 3px; + --mac-kv-value: 1px; + --mac-kv-gap: 12px; + --mac-dash-gap: 10px; + --mac-kpi-gap: 6px; + --mac-row-h: 24px; + --mac-menu-row-h: 22px; + --mac-row-pl: 15px; + --mac-row-icon: 16px; + /* The Mac code says 4.5pt, but the 0.70 shots put footer labels 38px from + the panel edge, which a 6px gap after the 16px icon reproduces. */ + --mac-row-icon-gap: 6px; + --mac-shortcut-w: 44px; + + --font-subheadline: 11px; + --font-footnote: 10px; + --font-caption: 10px; + + --menu-popover-bg: var(--mac-panel-bg); + --text-primary: var(--mac-text); + --text-secondary: var(--mac-text-2); + --text-muted: var(--mac-text-3); + --panel-border: var(--mac-divider); + + --usage-bar-track: var(--mac-track); + --pace-slow-bg: rgba(0, 122, 255, 0.12); + --pace-slow-fg: #007aff; + --pace-steady-bg: rgba(21, 128, 61, 0.12); + --pace-steady-fg: #15803d; + --pace-racing-bg: rgba(180, 83, 9, 0.14); + --pace-racing-fg: #b45309; + --pace-burning-bg: rgba(185, 28, 28, 0.12); + --pace-burning-fg: #b91c1c; + --pace-track-expected-fg: rgba(0, 122, 255, 0.45); + --chart-cost-fg: #007aff; + --chart-credits-fg: var(--chart-credits); + + color-scheme: light; display: flex; flex-direction: column; - /* Auto-size to content; JS resizes the Tauri window to match. - No max-height here — JS sets an explicit pixel cap after measuring. */ - height: auto; + gap: 0; box-sizing: border-box; + min-width: 0; + min-height: 0; + height: auto; + padding: 4px 0; + overflow: clip; + border: 1px solid var(--mac-panel-border); + border-radius: var(--mac-panel-radius); + background: var(--mac-panel-bg); + box-shadow: var(--mac-panel-highlight); + color: var(--mac-text); } .menu-surface--tray .menu-surface__body { flex: 0 1 auto; @@ -3586,22 +3541,34 @@ html:has(.menu-surface--tray) { border-radius: 6px; padding: 4px 4px; } -/* Tray separators — subtle hairlines matching macOS card dividers */ -.menu-surface--tray .menu-stack__sep { - height: 0.5px; - margin: 2px 16px; - background: rgba(255, 255, 255, 0.08); +/* The panel is light under every app theme. These base rules paint white + tints meant for a dark surface, so the tray gets dark tints instead. */ +.menu-surface--tray .menu-card__copy-btn:hover { + background: rgba(0, 0, 0, 0.06); } -[data-theme="light"] .menu-surface--tray .menu-stack__sep { - background: rgba(0, 0, 0, 0.08); +.menu-surface--tray .provider-grid__item .provider-icon { + filter: brightness(0); +} +.menu-surface--tray .provider-grid__item--more .provider-grid__icon-overview { + background: rgba(0, 0, 0, 0.06); } -[data-theme="light"] .menu-surface--tray .menu-stack__item { - background: rgba(0, 0, 0, 0.03); - border: 0.5px solid rgba(0, 0, 0, 0.04); +.menu-surface--tray .provider-grid__item--more:hover .provider-grid__icon-overview { + background: rgba(0, 0, 0, 0.1); } -[data-theme="light"] .menu-surface--tray .menu-stack__item--selected { +.menu-surface--tray .update-banner__action:hover { background: rgba(0, 0, 0, 0.06); - border: none; +} +.menu-surface--tray .update-banner--available, +.menu-surface--tray .update-banner--ready { + color: var(--provider-status-ok); +} +.menu-surface--tray .credential-btn { + border-color: rgba(0, 0, 0, 0.12); + background: rgba(0, 0, 0, 0.05); +} +.menu-surface--tray .credential-btn:hover:not(:disabled) { + border-color: rgba(0, 0, 0, 0.18); + background: rgba(0, 0, 0, 0.08); } /* Tray grid — flush with no background container, matching macOS */ .menu-surface--tray .provider-grid { @@ -3627,12 +3594,6 @@ html:has(.menu-surface--tray) { .menu-surface--tray .provider-grid--sparse .provider-grid__label { display: none; } -/* Divider between provider grid and card stack */ -.menu-surface--tray .provider-grid__divider { - height: 1px; - margin: 2px 0; - background: rgba(255, 255, 255, 0.08); -} /* Tray font sizing — match macOS: .headline=13pt, .footnote=10pt */ /* macOS v0.32.2: H padding 20, header-only V padding 7, section V padding 6 */ @@ -3726,52 +3687,6 @@ html:has(.menu-surface--tray) { display: none; } -/* ── Context actions (detail mode) ────────────────────────────────── - Matches macOS actionsSection: "Usage Dashboard" + "Status Page" - with SF Symbol–style icons. Shown only in detail (single provider) mode. */ -.context-actions { - display: flex; - flex-direction: column; - gap: 1px; - flex: 0 0 auto; - margin-top: 4px; - padding: 0 10px; -} -.context-actions__divider { - height: 1px; - margin: 4px 0; - background: rgba(255, 255, 255, 0.10); -} -[data-theme="light"] .context-actions__divider { - background: rgba(0, 0, 0, 0.10); -} -.context-actions__btn { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 2px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--text-primary, #f5f5f7); - font-size: 12px; - cursor: pointer; - text-align: left; -} -.context-actions__btn:hover { - background: rgba(255, 255, 255, 0.08); -} -[data-theme="light"] .context-actions__btn:hover { - background: rgba(0, 0, 0, 0.06); -} -.context-actions__icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - font-size: 13px; -} - /* Tray card content — match macOS spacing values from SwiftUI source */ .menu-surface--tray .menu-card__divider { margin: 3px 0; @@ -3818,11 +3733,6 @@ html:has(.menu-surface--tray) { font-size: 10px; line-height: 1.25; } -/* Hide cost section in tray — macOS compact popover only shows usage metrics */ -.menu-surface--tray .menu-card__cost, -.menu-surface--tray .menu-card__content > .menu-card__divider:has(+ .menu-card__cost) { - display: none; -} .menu-card__local-grid { display: grid; @@ -3869,38 +3779,6 @@ html:has(.menu-surface--tray) { line-height: 1.3; } -/* Tray footer — match macOS NSMenuItem native look (10pt, ~16px row height) */ -.menu-surface--tray .menu-surface__footer { - padding: 4px 0 3px; - margin-top: 2px; - border-top: 0.5px solid rgba(255, 255, 255, 0.12); -} -.menu-surface--tray .menu-surface__footer-row { - padding: 3px 16px; - font-size: 11px; - line-height: 1.3; - min-height: 18px; -} -/* Indent must override the tray padding above so iconless rows (Settings / - About / Quit) align with the iconified Refresh row. */ -.menu-surface--tray .menu-surface__footer-row--no-icon { - padding-left: calc(16px + 14px + 8px); -} -.menu-surface--tray .menu-surface__footer-icon { - font-size: 11px; - width: 14px; - opacity: 0.7; -} -/* Zoom row keeps a taller (16px) slider hit area even though the tray - footer's row padding/font-size are otherwise compacted. */ -.menu-surface--tray .menu-surface__footer-zoom { - padding-top: 4px; - padding-bottom: 4px; -} -.menu-surface--tray .menu-surface__footer-zoom-value { - font-size: 10px; -} - /* ── Provider card — direct mirror of UsageMenuCardView ─────────────── */ .menu-card { @@ -4625,46 +4503,6 @@ html:has(.menu-surface--tray) { scrollbar-color: rgba(255, 255, 255, 0.18) transparent; } -/* ── Menu surface: rounded NSMenu-style panel matching macOS popover ── */ -.menu-surface { - width: 100%; - min-width: 260px; - background: var(--menu-popover-bg); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 10px; - overflow: hidden; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 1px rgba(0, 0, 0, 0.3); - font-size: var(--font-body); - line-height: var(--line-tight); -} -/* Re-assert tray overrides after the second .menu-surface rule (cascade order) */ -.menu-surface--tray { - border: 1px solid rgba(255, 255, 255, 0.10); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); - overflow: hidden; - /* #265: TrayPanel applies CSS `zoom` on this surface, so it lays out in - windowWidth/zoom local px; the 260px `min-width` floor from .menu-surface - would be multiplied by the zoom on render (→ 390px at 150%) and force a - horizontal scrollbar in the 328px tray window. The window's own 300px - minimum already covers the floor — let the zoomed surface wrap instead. */ - min-width: 0; -} -.menu-surface--tray { - border: 1px solid rgba(255, 255, 255, 0.10); - border-radius: 10px; - overflow: hidden; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); -} -[data-theme="light"] .menu-surface { - background: var(--menu-popover-bg); - border: 1px solid rgba(0, 0, 0, 0.15); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18), 0 0 1px rgba(0, 0, 0, 0.12); -} -[data-theme="light"] .menu-surface--tray { - border: none; - box-shadow: none; -} - .menu-surface__header { min-height: 20px; padding: 0; @@ -4690,148 +4528,87 @@ html:has(.menu-surface--tray) { font-size: 13px; } -/* Footer of native-style menu rows (Settings… / About / Quit). - Sits flush with the surface's 10/6 padding and reads like an NSMenu - item: full-width row, 18px icon column, subtle hover highlight. */ +/* Footer rows follow the macOS menu: 22px rows (24px for Refresh), a 16px + icon column, a 44px shortcut column, and an inset selection plate. */ .menu-surface__footer { display: flex; flex-direction: column; - gap: 0; - padding: 8px 0 4px; - margin-top: 0; - border-top: 1px solid rgba(255, 255, 255, 0.12); flex-shrink: 0; + margin: 0; + padding: 0; + border-top: none; +} +.menu-surface__footer-sep { + height: 1px; + margin: 5px 14px; + background: var(--mac-separator); } .menu-surface__footer-row { + position: relative; + isolation: isolate; display: flex; align-items: center; - gap: 8px; + gap: var(--mac-row-icon-gap); width: 100%; - padding: 4px 6px; + min-height: var(--mac-menu-row-h); + padding: 0 var(--mac-row-pl); border: 0; - border-radius: 4px; background: transparent; - color: var(--text-primary); - font-size: var(--font-body); - font-weight: 400; + color: var(--mac-text); + font: var(--mac-text-menu); text-align: left; cursor: pointer; - transition: background 0.08s ease; } -/* Footer rows without an SF Symbol icon (Settings / About / Quit on - macOS): indent by the same 18px the icon column would occupy so the - text aligns with iconified rows above. */ -.menu-surface__footer-row--no-icon { - padding-left: calc(6px + 18px + 8px); +.menu-surface__footer-row--refresh { + min-height: var(--mac-row-h); +} +.menu-surface__footer-row::before { + content: ""; + position: absolute; + inset: var(--mac-sel-inset-y) var(--mac-sel-inset-x); + z-index: -1; + border-radius: var(--mac-sel-r); + background: transparent; + transition: background-color var(--mac-sel-fade) ease-out; +} +.menu-surface__footer-row--refresh::before { + inset: 0 5px; + border-radius: var(--mac-sel-r-refresh); } -.menu-surface__footer-row:hover, +.menu-surface__footer-row:hover:not(:disabled), .menu-surface__footer-row:focus-visible { - background: rgba(255, 255, 255, 0.07); - color: var(--text-primary); + color: var(--mac-selection-text); outline: none; } -[data-theme="light"] .menu-surface__footer-row:hover, -[data-theme="light"] .menu-surface__footer-row:focus-visible { - background: rgba(0, 0, 0, 0.06); +.menu-surface__footer-row:hover:not(:disabled)::before, +.menu-surface__footer-row:focus-visible::before { + background: var(--mac-selection-bg); } -.menu-surface__footer-row .menu-surface__footer-icon { - width: 18px; +.menu-surface__footer-row:disabled { + color: var(--mac-text-3); + cursor: default; +} +.menu-surface__footer-icon { display: inline-flex; - justify-content: center; - font-size: 12px; - color: var(--text-secondary); + flex-shrink: 0; + width: var(--mac-row-icon); + height: var(--mac-row-icon); } -.menu-surface__footer-row:hover .menu-surface__footer-icon, -.menu-surface__footer-row:focus-visible .menu-surface__footer-icon { - color: var(--text-primary); +.menu-surface__footer-icon svg { + width: 100%; + height: 100%; } .menu-surface__footer-shortcut { + min-width: var(--mac-shortcut-w); margin-left: auto; - font-size: var(--font-caption2); - color: var(--text-muted); + color: var(--mac-text-3); + font: var(--mac-text-shortcut); font-variant-numeric: tabular-nums; -} -.menu-surface__footer-row:hover .menu-surface__footer-shortcut, -.menu-surface__footer-row:focus-visible .menu-surface__footer-shortcut { - color: var(--text-secondary); -} - -/* Tray flyout zoom row — sits above Refresh in the footer. A plain `div` - (not a nav button), so it opts out of the button hover highlight and - instead hosts a Win11-volume-style slider with a fixed-width % readout - on the right (tabular nums + fixed width = no jitter while dragging). */ -.menu-surface__footer-zoom { - cursor: default; -} -.menu-surface__footer-zoom:hover, -.menu-surface__footer-zoom:focus-visible { - background: transparent; -} -.menu-surface__footer-zoom-value { - width: 34px; - flex-shrink: 0; text-align: right; - font-size: var(--font-caption2); - color: var(--text-muted); - font-variant-numeric: tabular-nums; -} -/* Win11-style range input: thin rounded 4px track, accent fill left of the - thumb (painted via the `--zoom-fill` custom property set inline from - React — WebKit has no ::fill pseudo), round accent thumb. WebView2 is - Chromium-only, so ::-webkit-* pseudos are sufficient. */ -.menu-surface__footer-zoom-slider { - -webkit-appearance: none; - appearance: none; - flex: 1 1 auto; - min-width: 0; - height: 16px; - margin: 0 2px; - padding: 0; - background: transparent; - cursor: pointer; -} -.menu-surface__footer-zoom-slider::-webkit-slider-runnable-track { - height: 4px; - border-radius: 2px; - background: linear-gradient( - to right, - var(--accent, #0a84ff) 0%, - var(--accent, #0a84ff) var(--zoom-fill, 0%), - rgba(255, 255, 255, 0.2) var(--zoom-fill, 0%) - ); -} -.menu-surface__footer-zoom-slider::-webkit-slider-thumb { - -webkit-appearance: none; - width: 12px; - height: 12px; - margin-top: -4px; /* centres the 12px thumb on the 4px track */ - border-radius: 50%; - background: var(--accent, #0a84ff); - box-shadow: - 0 0 0 1px rgba(255, 255, 255, 0.35), - 0 1px 2px rgba(0, 0, 0, 0.4); -} -.menu-surface__footer-zoom-slider:focus-visible { - outline: none; -} -.menu-surface__footer-zoom-slider:focus-visible::-webkit-slider-thumb { - box-shadow: - 0 0 0 2px var(--accent-muted, rgba(10, 132, 255, 0.25)), - 0 0 0 1px rgba(255, 255, 255, 0.35); } -[data-theme="light"] - .menu-surface__footer-zoom-slider::-webkit-slider-runnable-track { - background: linear-gradient( - to right, - var(--accent, #007aff) 0%, - var(--accent, #007aff) var(--zoom-fill, 0%), - rgba(0, 0, 0, 0.15) var(--zoom-fill, 0%) - ); -} -[data-theme="light"] .menu-surface__footer-zoom-slider::-webkit-slider-thumb { - box-shadow: - 0 0 0 1px rgba(0, 0, 0, 0.15), - 0 1px 2px rgba(0, 0, 0, 0.2); +.menu-surface__footer-row:hover:not(:disabled) .menu-surface__footer-shortcut, +.menu-surface__footer-row:focus-visible .menu-surface__footer-shortcut { + color: var(--mac-selection-text); } /* ── Provider icon grid ────────────────────────────────────────────── @@ -5838,14 +5615,6 @@ html:has(.menu-surface--tray) { padding-bottom: 6px; } -.menu-surface__footer-icon { - opacity: 0.5; -} -.menu-surface__footer-row:hover .menu-surface__footer-icon, -.menu-surface__footer-row:focus-visible .menu-surface__footer-icon { - opacity: 0.8; -} - /* ── Provider tab status dots removed (replaced by .provider-grid__dot) */ /* ── Plan badge pill ────────────────────────────────────────────────── */ diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx index 0f6263c089..a1d83f9f69 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx @@ -22,8 +22,15 @@ const tauriMocks = vi.hoisted(() => ({ getWorkAreaRect: vi.fn(), reanchorTrayPanel: vi.fn(), revealTrayPanelWindow: vi.fn(), - flyoutStoredSize: vi.fn().mockResolvedValue(null), - setFlyoutSize: vi.fn().mockResolvedValue(undefined), + getAppInfo: vi.fn().mockResolvedValue({ + name: "CodexBar", + version: "0.70.0", + buildNumber: "1", + updateChannel: "stable", + tagline: "", + }), + getSystemAccentColor: vi.fn().mockResolvedValue(null), + getProviderDetail: vi.fn().mockResolvedValue({ dashboardUrl: null, statusPageUrl: null }), openProviderDashboard: vi.fn(), openProviderStatusPage: vi.fn(), getProviderChartData: vi.fn(), @@ -43,9 +50,7 @@ const windowMocks = vi.hoisted(() => ({ getCurrentWindow: vi.fn(() => ({ setSize: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), })), LogicalSize: vi.fn((width: number, height: number) => ({ width, height })), PhysicalSize: vi.fn((width: number, height: number) => ({ width, height })), diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 0556b6466d..a144310725 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -1,4 +1,12 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const tauriMocks = vi.hoisted(() => ({ @@ -23,9 +31,9 @@ const tauriMocks = vi.hoisted(() => ({ getWorkAreaRect: vi.fn(), reanchorTrayPanel: vi.fn(), revealTrayPanelWindow: vi.fn(), - flyoutStoredSize: vi.fn().mockResolvedValue(null), - setFlyoutSize: vi.fn().mockResolvedValue(undefined), - resetFlyoutPosition: vi.fn().mockResolvedValue(undefined), + getAppInfo: vi.fn(), + getSystemAccentColor: vi.fn(), + getProviderDetail: vi.fn(), openProviderDashboard: vi.fn(), openProviderStatusPage: vi.fn(), getProviderChartData: vi.fn(), @@ -43,17 +51,11 @@ const eventMocks = vi.hoisted(() => ({ })); const windowMocks = vi.hoisted(() => ({ - startDragging: vi.fn().mockResolvedValue(undefined), - startResizeDragging: vi.fn().mockResolvedValue(undefined), // Loosely typed so tests can swap in windows with only the methods they use. getCurrentWindow: vi.fn((): Record => ({ - startDragging: windowMocks.startDragging, - startResizeDragging: windowMocks.startResizeDragging, setSize: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), })), LogicalSize: vi.fn((width: number, height: number) => ({ width, height })), PhysicalSize: vi.fn((width: number, height: number) => ({ width, height })), @@ -229,6 +231,14 @@ function renderTrayPanel( ); } +/** The pages `get_provider_detail` reports for a provider with both links. */ +function providerLinks(id: string) { + return { + dashboardUrl: `https://${id}.example/usage`, + statusPageUrl: `https://status.${id}.example`, + }; +} + function emitEvent(event: string, payload: unknown) { for (const listener of eventMocks.listeners.get(event) ?? []) { listener({ payload }); @@ -242,11 +252,22 @@ describe("TrayPanel provider grid", () => { tauriMocks.claudeReconciliationState.mockResolvedValue(null); tauriMocks.getDeepSeekPricingStatus.mockResolvedValue(null); tauriMocks.getUsageSpendSummary.mockResolvedValue({ rows: [], models: [] }); - tauriMocks.flyoutStoredSize.mockResolvedValue(null); tauriMocks.beginFlyoutGesture.mockResolvedValue(undefined); - tauriMocks.resetFlyoutPosition.mockResolvedValue(undefined); - windowMocks.startDragging.mockResolvedValue(undefined); - windowMocks.startResizeDragging.mockResolvedValue(undefined); + tauriMocks.getAppInfo.mockResolvedValue({ + name: "CodexBar", + version: "0.70.0", + buildNumber: "1", + updateChannel: "stable", + tagline: "", + }); + tauriMocks.getSystemAccentColor.mockResolvedValue(null); + tauriMocks.getProviderDetail.mockResolvedValue({ + dashboardUrl: null, + statusPageUrl: null, + }); + tauriMocks.openProviderDashboard.mockResolvedValue(undefined); + tauriMocks.openProviderStatusPage.mockResolvedValue(undefined); + tauriMocks.openSettingsWindow.mockResolvedValue(undefined); tauriMocks.refreshProviders.mockResolvedValue(undefined); tauriMocks.refreshProvidersIfStale.mockResolvedValue(undefined); tauriMocks.dismissTrayPanel.mockResolvedValue(undefined); @@ -285,17 +306,19 @@ describe("TrayPanel provider grid", () => { ActionRefresh: "Refresh", OverviewSpendProviderCoverage: "{} of {} providers have spend", MenuAbout: "About CodexBar", + MenuAboutVersion: "About CodexBar (v{})", MenuQuit: "Quit", MenuSettings: "Settings...", - ActionUsageDashboard: "Usage Dashboard", - ActionStatusPage: "Status Page", + PanelMenu: "Menu", + TrayMenuSwitchAccount: "Switch Account...", + TrayMenuUsageDashboard: "Usage Dashboard", + TrayMenuStatusPage: "Status Page", PanelAllProviders: "All providers", PanelAllProvidersShort: "All", PanelLeftSuffix: "left", PanelShowAllProviders: "Show all providers", PanelShowFewerProviders: "Show fewer providers", PanelUsedSuffix: "used", - PanelZoom: "Zoom", }), ); eventMocks.listen.mockImplementation( @@ -319,8 +342,7 @@ describe("TrayPanel provider grid", () => { // window (see App.tsx's isFlyoutWindow() routing), so it must not depend // on `main`'s surface-mode machine to know it's "open" — that machine // can report something other than "trayPanel". Overriding the snapshot - // mock to another mode confirms the fixed-size restore + reveal gate - // (isFlyoutOpen, hardcoded true in TrayPanel.tsx) is no longer wired to + // mock to another mode confirms the reveal gate is not wired to // useSurfaceMode() at all. tauriMocks.getCurrentSurfaceState.mockResolvedValue({ mode: "settings", @@ -440,7 +462,10 @@ describe("TrayPanel provider grid", () => { }); }); - it("scopes the status-page action to the selected provider", async () => { + it("offers the selected provider's Usage Dashboard and Status Page rows", async () => { + tauriMocks.getProviderDetail.mockImplementation((id: string) => + Promise.resolve(providerLinks(id)), + ); const { container } = renderTrayPanel([ provider("claude", "Claude", 35), provider("codex", "Codex", 45), @@ -449,20 +474,84 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(container.querySelector(".tray-panel-reveal--ready")).not.toBeNull(); }); + // The overview of several providers has no provider to act on. + expect(screen.queryByRole("button", { name: "Usage Dashboard" })).toBeNull(); + expect(container.querySelectorAll(".menu-surface__footer-sep")).toHaveLength(1); fireEvent.click(screen.getByRole("button", { name: /^Claude$/ })); - fireEvent.click(await screen.findByRole("button", { name: /^Usage Dashboard$/ })); + fireEvent.click(await screen.findByRole("button", { name: "Usage Dashboard" })); expect(tauriMocks.openProviderDashboard).toHaveBeenLastCalledWith("claude"); - fireEvent.click(await screen.findByRole("button", { name: /^Status Page$/ })); + fireEvent.click(screen.getByRole("button", { name: "Status Page" })); expect(tauriMocks.openProviderStatusPage).toHaveBeenLastCalledWith("claude"); + expect(container.querySelectorAll(".menu-surface__footer-sep")).toHaveLength(2); fireEvent.click(screen.getByRole("button", { name: /^Codex$/ })); - fireEvent.click(await screen.findByRole("button", { name: /^Usage Dashboard$/ })); + fireEvent.click(await screen.findByRole("button", { name: "Usage Dashboard" })); expect(tauriMocks.openProviderDashboard).toHaveBeenLastCalledWith("codex"); - fireEvent.click(await screen.findByRole("button", { name: /^Status Page$/ })); + fireEvent.click(screen.getByRole("button", { name: "Status Page" })); expect(tauriMocks.openProviderStatusPage).toHaveBeenLastCalledWith("codex"); }); + it("offers the only provider's rows without selecting its tab", async () => { + tauriMocks.getProviderDetail.mockImplementation((id: string) => + Promise.resolve(providerLinks(id)), + ); + renderTrayPanel([provider("codex", "Codex", 45)]); + + fireEvent.click(await screen.findByRole("button", { name: "Usage Dashboard" })); + + expect(tauriMocks.openProviderDashboard).toHaveBeenCalledWith("codex"); + }); + + it("leaves out a link the provider does not have", async () => { + tauriMocks.getProviderDetail.mockResolvedValue({ + dashboardUrl: "https://claude.example/usage", + statusPageUrl: null, + }); + renderTrayPanel([provider("claude", "Claude", 35)]); + + expect( + await screen.findByRole("button", { name: "Usage Dashboard" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Status Page" })).toBeNull(); + }); + + it("never shows the previous provider's links while the next one loads", async () => { + let resolveCodex: (detail: ReturnType) => void = () => {}; + tauriMocks.getProviderDetail.mockImplementation((id: string) => + id === "codex" + ? new Promise((resolve) => { + resolveCodex = resolve; + }) + : Promise.resolve(providerLinks(id)), + ); + renderTrayPanel([provider("claude", "Claude", 35), provider("codex", "Codex", 45)]); + + fireEvent.click(await screen.findByRole("button", { name: /^Claude$/ })); + expect( + await screen.findByRole("button", { name: "Usage Dashboard" }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /^Codex$/ })); + expect(screen.queryByRole("button", { name: "Usage Dashboard" })).toBeNull(); + + await act(async () => { + resolveCodex(providerLinks("codex")); + }); + fireEvent.click(screen.getByRole("button", { name: "Usage Dashboard" })); + expect(tauriMocks.openProviderDashboard).toHaveBeenLastCalledWith("codex"); + }); + + it("offers Switch Account for a Claude account with CLI quota", async () => { + renderTrayPanel([ + { ...provider("claude", "Claude", 35), hasSuccessfulClaudeCliQuota: true }, + ]); + + fireEvent.click(await screen.findByRole("button", { name: "Switch Account..." })); + + expect(tauriMocks.openSettingsWindow).toHaveBeenCalledWith("providers"); + }); + it("localizes static tray panel labels in Japanese", async () => { tauriMocks.getLocaleStrings.mockResolvedValue( buildBundle( @@ -479,7 +568,7 @@ describe("TrayPanel provider grid", () => { PanelPeriodTokens: "{}間のトークン", PanelTopModelPrefix: "トップモデル", PanelEstimatedFromLocalLogs: "ローカルログから推定", - PanelZoom: "ズーム", + MenuAboutVersion: "CodexBar について (v{})", UpdatedDaysAgo: "{}日前", }, "japanese", @@ -512,11 +601,9 @@ describe("TrayPanel provider grid", () => { ).not.toBeNull(); }); expect(container.querySelector(".provider-grid__item")?.textContent).toContain("すべて"); - expect(screen.getByText("ズーム")).toBeInTheDocument(); - expect(screen.getByLabelText("ズーム")).toBeInTheDocument(); expect(screen.getByText("更新")).toBeInTheDocument(); expect(screen.getByText("設定...")).toBeInTheDocument(); - expect(screen.getByText("CodexBar について")).toBeInTheDocument(); + expect(await screen.findByText("CodexBar について (v0.70.0)")).toBeInTheDocument(); expect(screen.getByText("終了")).toBeInTheDocument(); expect(await screen.findByText("30日間のコスト")).toBeInTheDocument(); expect(container.querySelector(".menu-card__subtitle")?.textContent).toContain("日前"); @@ -664,58 +751,6 @@ describe("TrayPanel provider grid", () => { expect(container.querySelectorAll(".menu-metric")).toHaveLength(3); }); - it("uses independent columns for a wide user-sized overview", async () => { - tauriMocks.flyoutStoredSize.mockResolvedValue([700, 700]); - const providers = [ - provider("codex", "Codex"), - provider("claude", "Claude"), - provider("antigravity", "Antigravity"), - provider("copilot", "GitHub Copilot"), - ]; - - const { container } = renderTrayPanel(providers, { - enabledProviders: providers.map((snapshot) => snapshot.providerId), - }); - - await waitFor(() => { - expect(container.querySelector(".tray-panel-reveal--usersized")).not.toBeNull(); - }); - - expect( - Array.from(container.querySelectorAll(".menu-stack__column")).map((column) => - Array.from(column.querySelectorAll(".menu-stack__item")).map( - (item) => item.id, - ), - ), - ).toEqual([ - ["card-codex", "card-antigravity"], - ["card-claude", "card-copilot"], - ]); - expect(container.querySelector(".menu-stack__sep")).toBeNull(); - }); - - it("keeps the stacked layout when the saved flyout width is narrow", async () => { - vi.spyOn(window, "innerWidth", "get").mockReturnValue(700); - tauriMocks.flyoutStoredSize.mockResolvedValue([500, 700]); - const providers = [ - provider("codex", "Codex"), - provider("claude", "Claude"), - provider("antigravity", "Antigravity"), - provider("copilot", "GitHub Copilot"), - ]; - - const { container } = renderTrayPanel(providers, { - enabledProviders: providers.map((snapshot) => snapshot.providerId), - }); - - await waitFor(() => { - expect(container.querySelector(".tray-panel-reveal--usersized")).not.toBeNull(); - }); - - expect(container.querySelector(".menu-stack__column")).toBeNull(); - expect(container.querySelectorAll(".menu-stack__sep")).toHaveLength(3); - }); - it("collapses and expands the full provider catalog in the dense tray grid", async () => { const providers = TEST_PROVIDER_CATALOG.map(([id, displayName], index) => provider(id, displayName, (index * 7) % 100), @@ -847,19 +882,18 @@ describe("TrayPanel provider grid", () => { it("renders the default tray panel layout with no legacy window chrome", async () => { // Pins the one dashboard layout: tray-variant surface, icon-first - // provider switcher, and the Zoom / Refresh / Settings... / About / Quit - // footer. The retired PopOut layout had a "CodexBar" title bar with - // window controls and a Settings / About / Quit footer without Zoom or - // Refresh. + // provider switcher, and the Refresh / Settings... / About / Quit rows + // with their Ctrl shortcuts. The retired PopOut layout had a "CodexBar" + // title bar with window controls. const { container } = renderTrayPanel([ provider("claude", "Claude", 35), provider("codex", "Codex", 20), ]); - await waitFor(() => { - expect(container.querySelector(".menu-surface__footer-zoom")).not.toBeNull(); - expect(container.querySelector(".provider-grid")).not.toBeNull(); - }); + const menu = await screen.findByRole("navigation", { name: "Menu" }); + expect( + await within(menu).findByRole("button", { name: "About CodexBar (v0.70.0)" }), + ).toBeInTheDocument(); const surface = container.querySelector(".menu-surface"); expect(surface?.classList.contains("menu-surface--tray")).toBe(true); @@ -867,221 +901,35 @@ describe("TrayPanel provider grid", () => { expect(container.querySelector(".popout-titlebar")).toBeNull(); expect(container.querySelector(".popout-scale-shell")).toBeNull(); expect(container.querySelector(".provider-grid")).not.toBeNull(); - - const footerLabels = Array.from( - container.querySelectorAll(".menu-surface__footer > *"), - ).map((el) => el.textContent ?? ""); - expect(footerLabels[0]).toContain("Zoom"); - expect(footerLabels.slice(1).map((label) => label.replace(/Ctrl\+.*/, ""))).toEqual([ - "↻Refresh", - "⚙Settings...", - "ⓘAbout CodexBar", - "⌧Quit", + expect( + within(menu) + .getAllByRole("button") + .map((row) => [row.textContent, row.getAttribute("aria-keyshortcuts")]), + ).toEqual([ + ["RefreshCtrl+R", "Control+R"], + ["Settings...Ctrl+,", "Control+,"], + ["About CodexBar (v0.70.0)", null], + ["QuitCtrl+Q", "Control+Q"], ]); expect(tauriMocks.setSurfaceMode).not.toHaveBeenCalled(); }); - it("offers a move strip and resize grips on every edge and corner", async () => { - const { container, unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - - await waitFor(() => { - expect(container.querySelector(".tray-move-handle")).not.toBeNull(); - }); - - const edges = Array.from(container.querySelectorAll(".tray-resize")).map((grip) => - Array.from(grip.classList).find((name) => name.startsWith("tray-resize--")), - ); - expect(edges.sort()).toEqual([ - "tray-resize--bottom", - "tray-resize--bottomleft", - "tray-resize--bottomright", - "tray-resize--left", - "tray-resize--right", - "tray-resize--top", - "tray-resize--topleft", - "tray-resize--topright", - ]); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); - }); - - it("starts a native resize toward the grabbed corner without arming the blur guard", async () => { - const { container, unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - await waitFor(() => { - expect(container.querySelector(".tray-resize--bottomright")).not.toBeNull(); - }); - - fireEvent.mouseDown(container.querySelector(".tray-resize--bottomright")!); - - await waitFor(() => { - expect(windowMocks.startResizeDragging).toHaveBeenCalledWith("SouthEast"); - }); - // The backend keeps the panel open while the button is held on it; a - // guard would also swallow the next real outside click. - expect(tauriMocks.beginFlyoutGesture).not.toHaveBeenCalled(); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); - }); - - it("moves the flyout from the strip and puts it back by the tray on double-click", async () => { - const { container, unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - await waitFor(() => { - expect(container.querySelector(".tray-move-handle")).not.toBeNull(); - }); - const strip = container.querySelector(".tray-move-handle")!; - - fireEvent.mouseDown(strip, { button: 0, detail: 1 }); - await waitFor(() => { - expect(windowMocks.startDragging).toHaveBeenCalledTimes(1); - }); - expect(tauriMocks.beginFlyoutGesture).not.toHaveBeenCalled(); - - fireEvent.mouseDown(strip, { button: 0, detail: 2 }); - await waitFor(() => { - expect(tauriMocks.resetFlyoutPosition).toHaveBeenCalledTimes(1); - }); - expect(windowMocks.startDragging).toHaveBeenCalledTimes(1); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); - }); - - function captureWindowEvents(scale: number) { - const handlers: { - resized?: (event: { payload: { width: number; height: number } }) => void; - scaleChanged?: (event: { - payload: { scaleFactor: number; size: { width: number; height: number } }; - }) => void; - } = {}; - windowMocks.getCurrentWindow.mockReturnValue({ - startDragging: windowMocks.startDragging, - startResizeDragging: windowMocks.startResizeDragging, - setSize: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(scale), - innerSize: vi.fn().mockResolvedValue({ width: 656, height: 400 }), - onResized: vi.fn((handler) => { - handlers.resized = handler; - return Promise.resolve(() => {}); - }), - onScaleChanged: vi.fn((handler) => { - handlers.scaleChanged = handler; - return Promise.resolve(() => {}); - }), - }); - return handlers; - } - - it("remembers a user resize in logical px and re-anchors at the new size", async () => { - const handlers = captureWindowEvents(2); - const { unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - await waitFor(() => expect(handlers.resized).toBeDefined()); - // Let the first auto-fit pass and its trailing guard settle. - await new Promise((resolve) => setTimeout(resolve, 500)); - tauriMocks.reanchorTrayPanel.mockClear(); - - act(() => { - handlers.resized!({ payload: { width: 900, height: 1500 } }); - }); - - await waitFor(() => { - expect(tauriMocks.setFlyoutSize).toHaveBeenCalledWith(450, 750); - }); - expect(tauriMocks.reanchorTrayPanel).toHaveBeenCalled(); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); - }); - - it("does not treat a DPI rescale as a user resize", async () => { - const handlers = captureWindowEvents(2); - const { unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - await waitFor(() => expect(handlers.scaleChanged).toBeDefined()); - // Let the first auto-fit pass and its trailing guard settle. - await new Promise((resolve) => setTimeout(resolve, 500)); - - act(() => { - handlers.scaleChanged!({ - payload: { scaleFactor: 2.25, size: { width: 738, height: 1755 } }, - }); - handlers.resized!({ payload: { width: 738, height: 1755 } }); - }); - await new Promise((resolve) => setTimeout(resolve, 400)); - - expect(tauriMocks.setFlyoutSize).not.toHaveBeenCalled(); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); - }); - - it("ignores non-primary presses on the move strip", async () => { - const { container, unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - await waitFor(() => { - expect(container.querySelector(".tray-move-handle")).not.toBeNull(); - }); - - fireEvent.mouseDown(container.querySelector(".tray-move-handle")!, { button: 2, detail: 1 }); - - expect(windowMocks.startDragging).not.toHaveBeenCalled(); - expect(tauriMocks.resetFlyoutPosition).not.toHaveBeenCalled(); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); - }); - - it("renders the tray footer zoom slider above Refresh and persists trayScalePercent after the debounce", async () => { - const { container } = renderTrayPanel( - [provider("claude", "Claude", 35)], - { trayScalePercent: 120 }, - ); - - await waitFor(() => { - expect(container.querySelector(".menu-surface__footer-zoom")).not.toBeNull(); + it("applies the saved Panel scale and the Windows accent to the panel", async () => { + tauriMocks.getSystemAccentColor.mockResolvedValue("#c42b1c"); + const { container } = renderTrayPanel([provider("claude", "Claude", 35)], { + trayScalePercent: 150, }); - const footerChildren = Array.from( - container.querySelectorAll(".menu-surface__footer > *"), - ); - const zoomIndex = footerChildren.findIndex((el) => - el.classList.contains("menu-surface__footer-zoom"), - ); - const refreshIndex = footerChildren.findIndex( - (el) => el.textContent?.includes("Refresh"), - ); - expect(zoomIndex).toBeGreaterThanOrEqual(0); - expect(refreshIndex).toBeGreaterThan(zoomIndex); - - // Slider reflects the persisted settings value. - const slider = container.querySelector( - ".menu-surface__footer-zoom-slider", - )!; - expect(slider).not.toBeNull(); - expect(slider.value).toBe("120"); - expect(slider.min).toBe("100"); - expect(slider.max).toBe("200"); - expect(slider.step).toBe("5"); - expect( - container.querySelector(".menu-surface__footer-zoom-value")?.textContent, - ).toBe("120%"); - - fireEvent.change(slider, { target: { value: "150" } }); - - // Live preview: thumb and readout update immediately from local state… - expect(slider.value).toBe("150"); - expect( - container.querySelector(".menu-surface__footer-zoom-value")?.textContent, - ).toBe("150%"); - - // …while persistence trails the ~250ms debounce (not synchronous). - expect(tauriMocks.updateSettings).not.toHaveBeenCalled(); await waitFor(() => { - expect(tauriMocks.updateSettings).toHaveBeenCalledWith({ - trayScalePercent: 150, - }); + expect( + container + .querySelector(".menu-surface--tray") + ?.style.getPropertyValue("--mac-selection-bg"), + ).toBe("#c42b1c"); }); - expect(tauriMocks.updateSettings).toHaveBeenCalledTimes(1); + const surface = container.querySelector(".menu-surface--tray")!; + expect(surface.style.getPropertyValue("--mac-selection-text")).toBe("#fff"); + expect(surface.style.zoom).toBe("1.5"); }); it("reveals the tray panel if the native resize pass fails", async () => { @@ -1089,9 +937,7 @@ describe("TrayPanel provider grid", () => { windowMocks.getCurrentWindow.mockReturnValue({ setSize: vi.fn().mockRejectedValue(new Error("resize failed")), close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), }); const { container } = renderTrayPanel([provider("claude", "Claude", 35)]); @@ -1108,9 +954,7 @@ describe("TrayPanel provider grid", () => { windowMocks.getCurrentWindow.mockReturnValue({ setSize, close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), }); const { container } = renderTrayPanel([provider("claude", "Claude", 35)]); @@ -1137,9 +981,7 @@ describe("TrayPanel provider grid", () => { windowMocks.getCurrentWindow.mockReturnValue({ setSize, close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), }); const denseProviders = TEST_PROVIDER_CATALOG.slice(0, 36).map(([id, displayName]) => provider(id, displayName), @@ -1151,19 +993,17 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(setSize).toHaveBeenCalledWith( - expect.objectContaining({ width: 328, height: 776 }), + expect.objectContaining({ width: 310, height: 776 }), ); }); }); - it("keeps provider detail mode tall enough for context actions and footer", async () => { + it("keeps provider detail mode tall enough for the menu rows and footer", async () => { const setSize = vi.fn().mockResolvedValue(undefined); windowMocks.getCurrentWindow.mockReturnValue({ setSize, close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), }); const errorProvider = { ...provider("abacus", "Abacus AI", 0), @@ -1185,19 +1025,17 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(setSize).toHaveBeenCalledWith( - expect.objectContaining({ width: 328, height: 420 }), + expect.objectContaining({ width: 310, height: 420 }), ); }); }); - it("scales the auto-fit measure by the active tray zoom before clamping (#265)", async () => { + it("fits the window to the zoomed panel at 150% Panel scale (#265)", async () => { const setSize = vi.fn().mockResolvedValue(undefined); windowMocks.getCurrentWindow.mockReturnValue({ setSize, close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 465, height: 200 }), }); // jsdom has no layout engine (scrollHeight always reads 0), so pin it // globally to a deterministic PRE-zoom content height: TrayPanel applies @@ -1211,17 +1049,18 @@ describe("TrayPanel provider grid", () => { trayScalePercent: 150, }); - // 505 raw × 1.5 zoom = 757.5 → 758 rounded, + the 4px fudge = 762. + // Width 310 × 1.5 = 465. Height 505 × 1.5 = 757.5, rounded up to 758, + // plus 1 px for DPI rounding = 759. await waitFor(() => { expect(setSize).toHaveBeenCalledWith( - expect.objectContaining({ width: 328, height: 762 }), + expect.objectContaining({ width: 465, height: 759 }), ); }); first.unmount(); setSize.mockClear(); - // Same zoom, taller content: round(700 × 1.5) + 4 = 1054 exceeds the - // mocked work-area cap (900 - 16 = 884), so the clamp still wins. + // Same zoom, taller content: 700 × 1.5 + 1 = 1051 exceeds the mocked + // work-area cap (900 - 16 = 884), so the clamp still wins. scrollHeight.mockReturnValue(700); renderTrayPanel([provider("codex", "Codex", 61)], { trayScalePercent: 150, @@ -1229,7 +1068,7 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(setSize).toHaveBeenCalledWith( - expect.objectContaining({ width: 328, height: 884 }), + expect.objectContaining({ width: 465, height: 884 }), ); }); }); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 82b13a5586..013376b486 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -1,57 +1,27 @@ import { Fragment, useEffect, useState, type CSSProperties } from "react"; -import { getCurrentWindow, type Window } from "@tauri-apps/api/window"; import type { BootstrapState, ProviderUsageSnapshot, UsageSpendSummary } from "../types/bridge"; import type { LocaleKey } from "../i18n/keys"; import { costPeriodShortLabel } from "../lib/costPeriod"; import { useCurrency } from "../hooks/CurrencyProvider"; import { sumDisplayCurrencyAmounts } from "../lib/currency"; -import { - getUsageSpendSummary, - openProviderDashboard, - openProviderStatusPage, - openSettingsWindow, - resetFlyoutPosition, -} from "../lib/tauri"; -import { - TRAY_SCALE_MAX, - TRAY_SCALE_MIN, - TRAY_SCALE_STEP, - useTrayPanelController, -} from "../hooks/useTrayPanelController"; +import { getUsageSpendSummary } from "../lib/tauri"; +import { useTrayPanelController } from "../hooks/useTrayPanelController"; import { useStayAwakeStatus } from "../hooks/useStayAwakeStatus"; import MenuCard from "../components/MenuCard"; import MenuSurface, { MenuEmpty } from "../components/MenuSurface"; import UpdateBanner from "../components/UpdateBanner"; import ProviderGrid from "../components/ProviderGrid"; import AgentSessions from "../components/AgentSessions"; -import { hasSuccessfulClaudeCliQuota } from "../lib/claudeAccountActions"; +import { accentSelectionStyle } from "../lib/menuSelection"; import { filterUsageSpendSummaryForOverview } from "../lib/usageSpendSharing"; -/** Provider IDs that have a dashboard URL in the backend */ -const HAS_DASHBOARD = new Set([ - "abacus", "alibaba", "alibabatokenplan", "amp", "atlascloud", "augment", - "azureopenai", "bedrock", "claude", "codex", "codebuff", - "aiand", "aixy", "commandcode", "copilot", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", - "doubao", "elevenlabs", "factory", "gemini", "grok", "groq", - "infini", "jetbrains", "kilo", "kimi", "kimik2", "kiro", "manus", "replicate", - "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", - "opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun", - "t3chat", "venice", "vertexai", "warp", "windsurf", - "xai", "zai", "fireworks", "meta", "muse", "nous", "llmman", "devpass", "xkiro", - "raycast", "vercel", -]); -/** Provider IDs that have a status page URL in the backend */ -const HAS_STATUS_PAGE = new Set([ - "alibabatokenplan", "amp", "augment", "azureopenai", "bedrock", - "claude", "codex", "copilot", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", "elevenlabs", - "gemini", "grok", "groq", "kiro", "mistral", "openaiapi", - "openrouter", "vertexai", "windsurf", "xai", -]); - /** * Tray popover surface — two modes like macOS CodexBar: * 1. Overview (default): provider grid + all cards stacked * 2. Detail: click a provider in grid → show only that provider's card + * + * The panel is a light menu under every app theme, like the macOS menu, so + * the wrappers pin `data-theme="light"`. */ export default function TrayPanel({ state }: { state: BootstrapState }) { const stayAwakeHeld = useStayAwakeStatus(); @@ -61,20 +31,16 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { isRefreshing, refreshingProviderIds, hasCachedData, - trayScaleDraft, trayScale, - trayScaleFillPercent, - handleTrayScaleChange, + accentColor, sorted, gridProviders, selectedProviderId, gridExpanded, setGridExpanded, visibleProviders, - wideColumns, - useWideColumns, requestLayout, - footerRows, + footerGroups, updateState, checkNow, download, @@ -89,25 +55,10 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { revealClassName, } = useTrayPanelController(state); - const zoomRow = ( -
- {t("PanelZoom")} - handleTrayScaleChange(Number(e.target.value))} - style={{ "--zoom-fill": `${trayScaleFillPercent}%` } as CSSProperties} - /> - - {trayScaleDraft}% - -
- ); + const surfaceStyle = { + zoom: trayScale, + ...accentSelectionStyle(accentColor), + } as CSSProperties; const banner = ( provider.providerId === selectedProviderId) ?? null - : null; - const canSwitchClaudeAccount = - selectedProvider !== null && hasSuccessfulClaudeCliQuota(selectedProvider); const stayAwakeStatus = stayAwakeHeld ? (

{t("TrayStayAwakeActive")} @@ -162,13 +108,8 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { if (sorted.length === 0) { return ( -

- +
+ {stayAwakeStatus} {settings.agentSessionsEnabled && } -
); } return ( -
- +
+ {stayAwakeStatus} {settings.agentSessionsEnabled && } )}
- {useWideColumns - ? wideColumns.map((column) => ( -
p.providerId).join("|") || "empty"} - > - {column.map(renderProviderCard)} -
- )) - : visibleProviders.map((p, idx) => ( - - {idx > 0 &&
} - {renderProviderCard(p)} - - ))} + {visibleProviders.map((p, idx) => ( + + {idx > 0 &&
} + {renderProviderCard(p)} + + ))}
- {/* Context actions — detail mode only, matches macOS actionsSection */} - {selectedProviderId && - (HAS_DASHBOARD.has(selectedProviderId) || - HAS_STATUS_PAGE.has(selectedProviderId) || - canSwitchClaudeAccount) && ( -
-
- {canSwitchClaudeAccount && ( - - )} - {HAS_DASHBOARD.has(selectedProviderId) && ( - - )} - {HAS_STATUS_PAGE.has(selectedProviderId) && ( - - )} -
- )} -
); } -type ResizeDirection = Parameters[0]; - -/** Every edge and corner, so a flyout the user moved away from the tray can - * be resized from whichever side faces open screen. */ -const RESIZE_GRIPS: ReadonlyArray<{ edge: string; direction: ResizeDirection }> = [ - { edge: "top", direction: "North" }, - { edge: "bottom", direction: "South" }, - { edge: "left", direction: "West" }, - { edge: "right", direction: "East" }, - { edge: "topleft", direction: "NorthWest" }, - { edge: "topright", direction: "NorthEast" }, - { edge: "bottomleft", direction: "SouthWest" }, - { edge: "bottomright", direction: "SouthEast" }, -]; - -/** - * Window handles for the borderless flyout: a move strip along the top and - * invisible resize grips on every edge and corner. The native frame left - * around the borderless WebView2 is only a few pixels wide, so both are driven - * explicitly with `startDragging` / `startResizeDragging`. Those calls enter a - * Win32 modal move/size loop which transiently steals focus from the WebView2 - * child — Windows fires a spurious `Focused(false)` the instant the press - * starts. The backend keeps the flyout open on a blur while a mouse button is - * held on it, so no gesture guard is armed here; arming one would also keep - * the next genuine outside click from dismissing the panel for up to 15s. - * - * Dragging the strip moves the flyout away from the tray and the backend - * remembers the spot; double-clicking it anchors the flyout to the tray again. - */ -function TrayWindowHandles({ moveHint }: { moveHint: string }) { - return ( - <> -
{ - if (e.button !== 0) return; - e.preventDefault(); - if (e.detail === 2) { - void resetFlyoutPosition().catch((err) => - console.error("[tray-move] resetFlyoutPosition failed:", err), - ); - return; - } - void getCurrentWindow() - .startDragging() - .catch((err) => console.error("[tray-move] startDragging failed:", err)); - }} - > - -
- {RESIZE_GRIPS.map(({ edge, direction }) => ( -
{ - if (e.button !== 0) return; - e.preventDefault(); - void getCurrentWindow() - .startResizeDragging(direction) - .catch((err) => console.error("[tray-resize] startResizeDragging failed:", err)); - }} - /> - ))} - - ); -} - function OverviewSpendSummary({ providerIds, period, diff --git a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css index 25c444888b..275af2cd36 100644 --- a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css +++ b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css @@ -155,10 +155,11 @@ border-radius: 0; padding: 0; } -.menu-surface--tray .menu-stack__sep { +.menu-surface--tray .menu-stack__sep, +.menu-surface--tray .provider-grid__divider { height: 1px; - margin: 4px var(--surface-inset); - background: var(--panel-border); + margin: 5px 14px; + background: var(--mac-separator); } .menu-surface--tray .provider-grid { padding: 8px var(--surface-inset); @@ -167,15 +168,3 @@ .menu-surface--tray .provider-grid__item { min-height: 32px; } -.menu-surface--tray .menu-surface__footer-row { - min-height: 28px; - font-size: 12px; - padding-top: 5px; - padding-bottom: 5px; -} -.context-actions { - padding: 0 var(--surface-inset); -} -.context-actions__btn { - min-height: 28px; -} diff --git a/rust/src/locale.rs b/rust/src/locale.rs index eeafc73f0f..75b3767cf2 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -445,7 +445,11 @@ locale_keys! { // Main popup - Bottom menu (Actions) MenuSettings, MenuAbout, + MenuAboutVersion, MenuQuit, + TrayMenuSwitchAccount, + TrayMenuUsageDashboard, + TrayMenuStatusPage, // Main popup - Status strings StatusJustUpdated, @@ -964,7 +968,6 @@ locale_keys! { DisplayModeMinimal, TrayPanelAlwaysOnTopLabel, TrayPanelAlwaysOnTopHelper, - TrayMoveHandleHint, WindowMinimize, WindowRestore, WindowClose, @@ -1062,7 +1065,6 @@ locale_keys! { PanelAllProvidersShort, PanelShowAllProviders, PanelShowFewerProviders, - PanelZoom, PanelMenu, PanelCopied, PanelToday, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 4d60d79956..bc3c4f2f2c 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -212,7 +212,11 @@ UpdateFailed = Update failed ButtonOpenProviderSettings = Open provider settings MenuSettings = Settings... MenuAbout = About CodexBar +MenuAboutVersion = About CodexBar (v{ "{}" }) MenuQuit = Quit +TrayMenuSwitchAccount = Switch Account... +TrayMenuUsageDashboard = Usage Dashboard +TrayMenuStatusPage = Status Page StatusJustUpdated = Just updated StatusUnableToGetUsage = Unable to get usage ActionRefresh = Refresh @@ -560,7 +564,6 @@ DisplayModeCompact = Compact DisplayModeMinimal = Minimal TrayPanelAlwaysOnTopLabel = Always on top TrayPanelAlwaysOnTopHelper = Keep the tray panel above other windows, even when it loses focus. -TrayMoveHandleHint = Drag to move. Double-click to put it back by the tray. WindowMinimize = Minimize WindowRestore = Restore WindowClose = Close @@ -650,7 +653,6 @@ PanelAllProviders = All providers PanelAllProvidersShort = All PanelShowAllProviders = Show all providers PanelShowFewerProviders = Show fewer providers -PanelZoom = Zoom PanelMenu = Menu PanelCopied = Copied PanelToday = Today diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index af7de2d65b..c0cb13867f 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -185,7 +185,11 @@ UpdateFailed = Actualización fallida ButtonOpenProviderSettings = Abrir configuración del proveedor MenuSettings = Configuración... MenuAbout = Acerca de CodexBar +MenuAboutVersion = Acerca de CodexBar (v{ "{}" }) MenuQuit = Salir +TrayMenuSwitchAccount = Cambiar cuenta... +TrayMenuUsageDashboard = Panel de uso +TrayMenuStatusPage = Página de estado StatusJustUpdated = Recién actualizado StatusUnableToGetUsage = No se pudo obtener el uso ActionRefresh = Actualizar @@ -578,7 +582,6 @@ DisplayModeCompact = Compacto DisplayModeMinimal = Mínimo TrayPanelAlwaysOnTopLabel = Siempre visible encima TrayPanelAlwaysOnTopHelper = Mantén el panel de bandeja sobre otras ventanas, incluso cuando pierda el foco. -TrayMoveHandleHint = Arrastra para mover. Haz doble clic para devolverlo junto a la bandeja. WindowMinimize = Minimizar WindowRestore = Restaurar WindowClose = Cerrar @@ -668,7 +671,6 @@ PanelAllProviders = Todos los proveedores PanelAllProvidersShort = Todos PanelShowAllProviders = Mostrar todos los proveedores PanelShowFewerProviders = Mostrar menos proveedores -PanelZoom = Zoom PanelMenu = Menú PanelCopied = Copiado PanelToday = Hoy diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 4eee267303..bf4d3504e9 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -185,7 +185,11 @@ UpdateFailed = 更新失敗 ButtonOpenProviderSettings = プロバイダー設定を開く MenuSettings = 設定... MenuAbout = CodexBar について +MenuAboutVersion = CodexBar について (v{ "{}" }) MenuQuit = 終了 +TrayMenuSwitchAccount = アカウントを切り替え… +TrayMenuUsageDashboard = 使用量ダッシュボード +TrayMenuStatusPage = ステータスページ StatusJustUpdated = 更新済み StatusUnableToGetUsage = 使用量を取得できません ActionRefresh = 更新 @@ -533,7 +537,6 @@ DisplayModeCompact = コンパクト DisplayModeMinimal = 最小 TrayPanelAlwaysOnTopLabel = 常に手前に表示 TrayPanelAlwaysOnTopHelper = フォーカスを失っても、トレイパネルを他のウィンドウより前面に表示します。 -TrayMoveHandleHint = ドラッグで移動。ダブルクリックでトレイの横に戻します。 WindowMinimize = 最小化 WindowRestore = 元に戻す WindowClose = 閉じる @@ -623,7 +626,6 @@ PanelAllProviders = すべてのプロバイダー PanelAllProvidersShort = すべて PanelShowAllProviders = すべてのプロバイダーを表示 PanelShowFewerProviders = 表示を減らす -PanelZoom = ズーム PanelMenu = メニュー PanelCopied = コピーしました PanelToday = 今日 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 5dcb6cdd23..ee48a147ea 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -185,7 +185,11 @@ UpdateFailed = 업데이트 실패 ButtonOpenProviderSettings = 제공업체 설정 열기 MenuSettings = 설정... MenuAbout = CodexBar 정보 +MenuAboutVersion = CodexBar 정보 (v{ "{}" }) MenuQuit = 종료 +TrayMenuSwitchAccount = 계정 전환... +TrayMenuUsageDashboard = 사용량 대시보드 +TrayMenuStatusPage = 상태 페이지 StatusJustUpdated = 방금 업데이트됨 StatusUnableToGetUsage = 사용량을 가져올 수 없음 ActionRefresh = 새로고침 @@ -578,7 +582,6 @@ DisplayModeCompact = 압축 DisplayModeMinimal = 최소 TrayPanelAlwaysOnTopLabel = 항상 위에 표시 TrayPanelAlwaysOnTopHelper = 포커스를 잃어도 트레이 패널을 다른 창보다 앞에 유지합니다. -TrayMoveHandleHint = 드래그하여 이동합니다. 두 번 클릭하면 트레이 옆으로 돌아갑니다. WindowMinimize = 최소화 WindowRestore = 이전 크기로 복원 WindowClose = 닫기 @@ -668,7 +671,6 @@ PanelAllProviders = 모든 제공자 PanelAllProvidersShort = 전체 PanelShowAllProviders = 모든 제공자 표시 PanelShowFewerProviders = 적은 제공자 표시 -PanelZoom = 확대/축소 PanelMenu = 메뉴 PanelCopied = 복사됨 PanelToday = 오늘 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index ef6f9509f1..ea8e5cf0e0 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -208,7 +208,11 @@ UpdateFailed = Falha na atualização ButtonOpenProviderSettings = Abrir configurações do provedor MenuSettings = Configurações... MenuAbout = Sobre o CodexBar +MenuAboutVersion = Sobre o CodexBar (v{ "{}" }) MenuQuit = Sair +TrayMenuSwitchAccount = Trocar de conta... +TrayMenuUsageDashboard = Painel de uso +TrayMenuStatusPage = Página de status StatusJustUpdated = Atualizado agora StatusUnableToGetUsage = Não foi possível obter o uso ActionRefresh = Atualizar @@ -531,7 +535,6 @@ DisplayModeCompact = Compacto DisplayModeMinimal = Mínimo TrayPanelAlwaysOnTopLabel = Sempre no topo TrayPanelAlwaysOnTopHelper = Mantenha o painel da bandeja acima das outras janelas, mesmo quando ele perder o foco. -TrayMoveHandleHint = Arraste para mover. Clique duas vezes para voltar ao lado da bandeja. WindowMinimize = Minimizar WindowRestore = Restaurar WindowClose = Fechar @@ -621,7 +624,6 @@ PanelAllProviders = Todos os provedores PanelAllProvidersShort = Todos PanelShowAllProviders = Mostrar todos os provedores PanelShowFewerProviders = Mostrar menos provedores -PanelZoom = Zoom PanelMenu = Menu PanelCopied = Copiado PanelToday = Hoje diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 7a00337d21..7796355475 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -212,7 +212,11 @@ UpdateFailed = Обновление не выполнено ButtonOpenProviderSettings = Открыть настройки провайдера MenuSettings = Настройки... MenuAbout = О CodexBar +MenuAboutVersion = О CodexBar (v{ "{}" }) MenuQuit = Выйти +TrayMenuSwitchAccount = Сменить аккаунт... +TrayMenuUsageDashboard = Панель управления использованием +TrayMenuStatusPage = Страница статуса StatusJustUpdated = Только что обновлено StatusUnableToGetUsage = Не удалось получить использование ActionRefresh = Обновить @@ -560,7 +564,6 @@ DisplayModeCompact = Компактный DisplayModeMinimal = Минимальный TrayPanelAlwaysOnTopLabel = Поверх всех окон TrayPanelAlwaysOnTopHelper = Панель в области уведомлений остается поверх других окон даже после потери фокуса. -TrayMoveHandleHint = Перетащите, чтобы переместить. Двойной щелчок вернёт панель к области уведомлений. WindowMinimize = Свернуть WindowRestore = Восстановить WindowClose = Закрыть @@ -650,7 +653,6 @@ PanelAllProviders = Все провайдеры PanelAllProvidersShort = Все PanelShowAllProviders = Показать всех провайдеров PanelShowFewerProviders = Показать меньше провайдеров -PanelZoom = Увеличить PanelMenu = Меню PanelCopied = Скопировано PanelToday = Сегодня diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 569c6c0b00..7f64280fa8 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -145,7 +145,6 @@ fn test_japanese_tray_panel_locale_values_are_translated() { (LocaleKey::StateError, "エラー"), (LocaleKey::PanelAllProviders, "すべてのプロバイダー"), (LocaleKey::PanelAllProvidersShort, "すべて"), - (LocaleKey::PanelZoom, "ズーム"), (LocaleKey::PanelMenu, "メニュー"), (LocaleKey::PanelToday, "今日"), (LocaleKey::PanelLatestTokens, "最新トークン"), @@ -181,7 +180,6 @@ fn test_chinese_tray_panel_locale_values_are_translated() { let cases = [ (LocaleKey::PanelAllProviders, "所有提供者"), (LocaleKey::PanelAllProvidersShort, "全部"), - (LocaleKey::PanelZoom, "缩放"), (LocaleKey::PanelMenu, "菜单"), (LocaleKey::PanelToday, "今日"), (LocaleKey::PanelLatestTokens, "最新令牌"), @@ -215,7 +213,6 @@ fn test_korean_tray_panel_locale_values_are_translated() { let cases = [ (LocaleKey::PanelAllProviders, "모든 제공자"), (LocaleKey::PanelAllProvidersShort, "전체"), - (LocaleKey::PanelZoom, "확대/축소"), (LocaleKey::PanelMenu, "메뉴"), (LocaleKey::PanelToday, "오늘"), (LocaleKey::PanelLatestTokens, "최신 토큰"), @@ -249,7 +246,6 @@ fn test_spanish_tray_panel_locale_values_are_translated() { let cases = [ (LocaleKey::PanelAllProviders, "Todos los proveedores"), (LocaleKey::PanelAllProvidersShort, "Todos"), - (LocaleKey::PanelZoom, "Zoom"), (LocaleKey::PanelMenu, "Menú"), (LocaleKey::PanelToday, "Hoy"), (LocaleKey::PanelLatestTokens, "Últimos tokens"), diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 41ec3e2c98..31810dd288 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -212,7 +212,11 @@ UpdateFailed = Güncelleme başarısız ButtonOpenProviderSettings = Sağlayıcı ayarlarını aç MenuSettings = Ayarlar... MenuAbout = CodexBar hakkında +MenuAboutVersion = CodexBar hakkında (v{ "{}" }) MenuQuit = Çık +TrayMenuSwitchAccount = Hesap değiştir... +TrayMenuUsageDashboard = Kullanım panosu +TrayMenuStatusPage = Durum sayfası StatusJustUpdated = Az önce güncellendi StatusUnableToGetUsage = Kullanım bilgisi alınamıyor ActionRefresh = Yenile @@ -560,7 +564,6 @@ DisplayModeCompact = Kompakt DisplayModeMinimal = En az TrayPanelAlwaysOnTopLabel = Her zaman üstte TrayPanelAlwaysOnTopHelper = Odak kaybolduğunda bile tepsi panelini diğer pencerelerin üzerinde tutar. -TrayMoveHandleHint = Taşımak için sürükleyin. Tepsinin yanına geri döndürmek için çift tıklayın. WindowMinimize = Simge durumuna küçült WindowRestore = Geri yükle WindowClose = Kapat @@ -650,7 +653,6 @@ PanelAllProviders = Tüm sağlayıcılar PanelAllProvidersShort = Tümü PanelShowAllProviders = Tüm sağlayıcıları göster PanelShowFewerProviders = Daha az sağlayıcı göster -PanelZoom = Yakınlaştırma PanelMenu = Menü PanelCopied = Kopyalandı PanelToday = Bugün diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 525fd917c6..dcd5b0c82a 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -212,7 +212,11 @@ UpdateFailed = Не вдалося оновити ButtonOpenProviderSettings = Відкрити налаштування провайдера MenuSettings = Налаштування... MenuAbout = Про CodexBar +MenuAboutVersion = Про CodexBar (v{ "{}" }) MenuQuit = Вийти +TrayMenuSwitchAccount = Перемкнути обліковий запис... +TrayMenuUsageDashboard = Панель використання +TrayMenuStatusPage = Сторінка стану StatusJustUpdated = Щойно оновлено StatusUnableToGetUsage = Не вдалося отримати дані про використання ActionRefresh = Оновити @@ -560,7 +564,6 @@ DisplayModeCompact = Компактний DisplayModeMinimal = Мінімальний TrayPanelAlwaysOnTopLabel = Поверх усіх вікон TrayPanelAlwaysOnTopHelper = Тримати панель трею поверх інших вікон, навіть коли вона втрачає фокус. -TrayMoveHandleHint = Перетягніть, щоб перемістити. Двічі клацніть, щоб повернути панель до трею. WindowMinimize = Згорнути WindowRestore = Відновити WindowClose = Закрити @@ -650,7 +653,6 @@ PanelAllProviders = Усі провайдери PanelAllProvidersShort = Усі PanelShowAllProviders = Показати всіх провайдерів PanelShowFewerProviders = Показати менше провайдерів -PanelZoom = Масштаб PanelMenu = Меню PanelCopied = Скопійовано PanelToday = Сьогодні diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index f5e05665fc..9936510a7c 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -185,7 +185,11 @@ UpdateFailed = 更新失败 ButtonOpenProviderSettings = 打开服务商设置 MenuSettings = 设置... MenuAbout = 关于 CodexBar +MenuAboutVersion = 关于 CodexBar (v{ "{}" }) MenuQuit = 退出 +TrayMenuSwitchAccount = 切换账号... +TrayMenuUsageDashboard = 用量仪表盘 +TrayMenuStatusPage = 状态页面 StatusJustUpdated = 刚刚更新 StatusUnableToGetUsage = 无法获取用量 ActionRefresh = 刷新 @@ -533,7 +537,6 @@ DisplayModeCompact = 紧凑 DisplayModeMinimal = 最简 TrayPanelAlwaysOnTopLabel = 始终置顶 TrayPanelAlwaysOnTopHelper = 即使失去焦点,也让托盘面板保持在其他窗口之上。 -TrayMoveHandleHint = 拖动以移动。双击可放回托盘旁。 WindowMinimize = 最小化 WindowRestore = 还原 WindowClose = 关闭 @@ -623,7 +626,6 @@ PanelAllProviders = 所有提供者 PanelAllProvidersShort = 全部 PanelShowAllProviders = 显示所有提供者 PanelShowFewerProviders = 显示较少提供者 -PanelZoom = 缩放 PanelMenu = 菜单 PanelCopied = 已复制 PanelToday = 今日 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index e9faab8be6..ba0201928f 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -185,7 +185,11 @@ UpdateFailed = 更新失敗 ButtonOpenProviderSettings = 開啟提供者設定 MenuSettings = 設定... MenuAbout = 關於 CodexBar +MenuAboutVersion = 關於 CodexBar (v{ "{}" }) MenuQuit = 退出 +TrayMenuSwitchAccount = 切換賬號... +TrayMenuUsageDashboard = 用量儀表板 +TrayMenuStatusPage = 狀態頁面 StatusJustUpdated = 剛剛更新 StatusUnableToGetUsage = 無法獲取用量 ActionRefresh = 重新整理 @@ -533,7 +537,6 @@ DisplayModeCompact = 緊湊 DisplayModeMinimal = 最簡 TrayPanelAlwaysOnTopLabel = 永遠置頂 TrayPanelAlwaysOnTopHelper = 即使失去焦點,也讓系統匣面板保持在其他視窗上方。 -TrayMoveHandleHint = 拖曳以移動。按兩下可放回系統匣旁。 WindowMinimize = 最小化 WindowRestore = 還原 WindowClose = 關閉 @@ -623,7 +626,6 @@ PanelAllProviders = 所有提供者 PanelAllProvidersShort = 全部 PanelShowAllProviders = 顯示所有提供者 PanelShowFewerProviders = 顯示較少提供者 -PanelZoom = 縮放 PanelMenu = 選單 PanelCopied = 已複製 PanelToday = 今日 From c569b0663c35a37c8079f98fbf078cbc44ac29fe Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:40:14 +0700 Subject: [PATCH 02/10] Draw the tray flyout as a fixed light frame DWM gives the flyout Windows 11 rounded corners and a #8A8B8E border, and the window erases to the panel fill, so it never flashes dark. It has no thick frame and opens next to the tray every time. The set_flyout_size, reset_flyout_position and flyout_stored_size commands, the remembered placement and the stored size are removed. get_system_accent_color reads the Windows accent for the menu selection. --- .../src-tauri/src/commands/surface.rs | 35 +- .../src-tauri/src/commands/system.rs | 41 ++ .../src-tauri/src/geometry_store.rs | 208 +------- apps/desktop-tauri/src-tauri/src/main.rs | 8 +- apps/desktop-tauri/src-tauri/src/shell/dwm.rs | 126 ++++- .../src-tauri/src/shell/flyout_placement.rs | 478 ------------------ .../src-tauri/src/shell/flyout_window.rs | 154 ++---- apps/desktop-tauri/src-tauri/src/shell/mod.rs | 1 - .../src-tauri/src/shell/position.rs | 8 +- .../src-tauri/src/shell/tests.rs | 7 +- .../src-tauri/src/shell/window.rs | 3 +- apps/desktop-tauri/src-tauri/src/state.rs | 4 +- apps/desktop-tauri/src-tauri/src/surface.rs | 22 +- .../src-tauri/src/tray_bridge.rs | 4 +- 14 files changed, 234 insertions(+), 865 deletions(-) delete mode 100644 apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/surface.rs b/apps/desktop-tauri/src-tauri/src/commands/surface.rs index 00b268abdf..45c54a47d4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/surface.rs @@ -28,8 +28,8 @@ pub fn dismiss_tray_panel(app: tauri::AppHandle) -> Result<(), String> { crate::shell::flyout_window::hide(&app) } -/// Arm the gesture blur guard before a resize-grip drag or drag-reorder -/// gesture starts its Win32/OLE modal loop, so the transient +/// Arm the gesture blur guard before a drag-reorder gesture starts its +/// OLE modal loop, so the transient /// `Focused(false)` that loop produces doesn't auto-hide the flyout. #[tauri::command] pub fn begin_flyout_gesture(app: tauri::AppHandle) -> Result<(), String> { @@ -116,37 +116,6 @@ pub fn close_settings_window( crate::shell::settings_window::dismiss(&app, &window) } -/// Persist a user-chosen size (logical px) for the "Pop Out Dashboard" flyout -/// window. Only the size is stored (via a size-only `StoredSize` entry — no -/// fabricated `x`/`y`); the position is tracked separately, only once the -/// user drags the flyout. The frontend calls this on genuine user drag-resizes, not on its own -/// auto-fit resizes, so auto-fit sizes never freeze the panel. -#[tauri::command] -pub fn set_flyout_size(width: f64, height: f64) -> Result<(), String> { - let width = (width.round() as i64).clamp(1, i64::from(u32::MAX)) as u32; - let height = (height.round() as i64).clamp(1, i64::from(u32::MAX)) as u32; - crate::shell::flyout_window::save_stored_size(width, height); - Ok(()) -} - -/// Forget where the user dragged the flyout and anchor it to the tray again -/// (double-click on the panel's move handle). -#[tauri::command] -pub fn reset_flyout_position(app: tauri::AppHandle) -> Result<(), String> { - crate::shell::flyout_window::reset_position(&app) -} - -/// Return the remembered flyout size, if the user has manually resized it. -/// The frontend uses this to decide whether to auto-fit (no stored size) or -/// honor the user's size (stored) on open. Transparently migrates a -/// pre-existing size stored under the legacy `SurfaceMode::TrayPanel` -/// shared-window geometry key (from before the flyout became its own -/// window), so upgrading users don't lose their remembered size. -#[tauri::command] -pub fn flyout_stored_size() -> Result, String> { - Ok(crate::shell::flyout_window::stored_size()) -} - #[tauri::command] pub fn get_current_surface_state( state: tauri::State<'_, Mutex>, diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 4355089961..4da4512bb0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -170,6 +170,41 @@ pub fn get_work_area_rect(app: tauri::AppHandle) -> Result // ── Misc UX ──────────────────────────────────────────────────────────── +/// The Windows accent color as `#rrggbb`. The tray panel paints its menu +/// selection with it, the way macOS menus use the system accent. +#[tauri::command] +pub fn get_system_accent_color() -> Option { + system_accent_abgr().map(abgr_to_hex) +} + +#[cfg(windows)] +fn system_accent_abgr() -> Option { + use winreg::RegKey; + use winreg::enums::HKEY_CURRENT_USER; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + [ + ( + r"Software\Microsoft\Windows\CurrentVersion\Explorer\Accent", + "AccentColorMenu", + ), + (r"Software\Microsoft\Windows\DWM", "AccentColor"), + ] + .into_iter() + .find_map(|(path, name)| hkcu.open_subkey(path).ok()?.get_value::(name).ok()) +} + +#[cfg(not(windows))] +fn system_accent_abgr() -> Option { + None +} + +/// Windows stores accent colors as 0xAABBGGRR. +fn abgr_to_hex(value: u32) -> String { + let [r, g, b, _] = value.to_le_bytes(); + format!("#{r:02x}{g:02x}{b:02x}") +} + #[tauri::command] pub fn play_notification_sound( event: codexbar::sound::NotificationSoundEvent, @@ -425,6 +460,12 @@ fn copilot_label_matches_login(account: &TokenAccount, login: Option<&str>) -> b mod tests { use super::*; + #[test] + fn accent_registry_dword_reads_as_abgr() { + assert_eq!(abgr_to_hex(0xffd4_7800), "#0078d4"); + assert_eq!(abgr_to_hex(0xff00_b9ff), "#ffb900"); + } + #[test] fn dashboard_url_resolves_from_codex_provider_metadata() { assert_eq!( diff --git a/apps/desktop-tauri/src-tauri/src/geometry_store.rs b/apps/desktop-tauri/src-tauri/src/geometry_store.rs index c5945c2801..2a3bcd3d65 100644 --- a/apps/desktop-tauri/src-tauri/src/geometry_store.rs +++ b/apps/desktop-tauri/src-tauri/src/geometry_store.rs @@ -1,13 +1,8 @@ //! Persistent window-geometry store for the Tauri desktop shell. //! //! Remembers position (and size where applicable) for detached user surfaces: -//! PopOut and Settings. -//! -//! The flyout window is anchored to the tray until the user drags it; only -//! then is its position stored (`shell::flyout_placement`). Its SIZE is -//! remembered via the size-only [`StoredSize`] entries below, kept separate -//! from [`StoredGeometry`] so a resize of a tray-anchored flyout never carries -//! fabricated `x`/`y` coordinates. +//! PopOut, Settings and the float bar. The tray-panel flyout remembers +//! nothing: it is a fixed panel anchored to the tray on every open. use std::fs; use std::path::PathBuf; @@ -35,16 +30,6 @@ pub struct StoredGeometry { pub height: Option, } -/// A size-only persisted entry, for windows that are always re-anchored (never -/// remember position) so storing `x`/`y` would be fabricated data. Used by the -/// detached flyout window, which is anchored above the tray on every open — -/// only its user-chosen width/height is meaningful to remember. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct StoredSize { - pub width: u32, - pub height: u32, -} - /// All persisted geometries keyed by surface mode string (`settings`, ...). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GeometryFile { @@ -52,12 +37,6 @@ pub struct GeometryFile { pub version: u32, #[serde(default)] pub entries: std::collections::BTreeMap, - /// Size-only entries (no position), keyed by an arbitrary label (e.g. the - /// `"flyout"` window). Kept in a separate map — rather than reusing - /// `entries` with a fabricated `x: 0, y: 0` — so the on-disk shape can't - /// be misread as a remembered position. - #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub size_entries: std::collections::BTreeMap, } fn geometry_path() -> Option { @@ -70,16 +49,11 @@ fn geometry_path() -> Option { /// Surface modes eligible for geometry persistence. /// /// - `Hidden`: never remembered. -/// - `TrayPanel`: remembered for SIZE — the "Pop Out Dashboard" flyout is always -/// re-anchored above the tray icon (its position comes from -/// `default_surface_position`, which ignores the stored x/y), but its -/// width/height persist so a user resize sticks across opens. +/// - `TrayPanel`: never remembered; the flyout is a fixed panel anchored to +/// the tray on every open. /// - `PopOut` / `Settings`: user-movable, position + size remembered. pub fn should_remember(mode: SurfaceMode) -> bool { - matches!( - mode, - SurfaceMode::TrayPanel | SurfaceMode::PopOut | SurfaceMode::Settings - ) + matches!(mode, SurfaceMode::PopOut | SurfaceMode::Settings) } fn load_file() -> GeometryFile { @@ -154,66 +128,6 @@ pub fn save_entry(key: &str, geometry: StoredGeometry) { } } -/// Forget the geometry stored under an arbitrary key. No-op (and no write) -/// when nothing is stored there. -pub fn remove_entry(key: &str) { - let mut file = load_file(); - if file.entries.remove(key).is_none() { - return; - } - if let Err(err) = save_file(&file) { - tracing::warn!(target: "codexbar::geometry", %err, "failed to persist geometry"); - } -} - -/// Legacy key the flyout size was stored under before it became a dedicated -/// window: the old `SurfaceMode::TrayPanel` shared-window geometry entry. -const LEGACY_FLYOUT_SIZE_KEY: &str = "trayPanel"; - -/// Resolve a size-only entry from an in-memory [`GeometryFile`]: prefer the -/// new `size_entries` map, falling back to migrating a pre-existing -/// `"trayPanel"` [`StoredGeometry`] width/height (from before the flyout was -/// split into its own window) so upgrading users keep their remembered size -/// instead of it silently resetting to the default. Pure/side-effect-free so -/// it can be unit-tested without touching disk; [`load_size`] is the -/// disk-backed wrapper that also persists the migrated value. -fn resolve_size(file: &GeometryFile, key: &str) -> Option { - if let Some(size) = file.size_entries.get(key).copied() { - return Some(size); - } - if key != LEGACY_FLYOUT_SIZE_KEY { - let legacy = file.entries.get(LEGACY_FLYOUT_SIZE_KEY)?; - let (width, height) = (legacy.width?, legacy.height?); - return Some(StoredSize { width, height }); - } - None -} - -/// Look up a remembered size-only entry (e.g. the flyout window's -/// user-chosen width/height). See [`resolve_size`] for the migration -/// fallback; a migrated value is re-persisted under the new key so this -/// lookup path is only taken once (the legacy entry is left in place — -/// harmless, since nothing reads `entries["trayPanel"]` as a position -/// anymore). -pub fn load_size(key: &str) -> Option { - let file = load_file(); - let size = resolve_size(&file, key)?; - if !file.size_entries.contains_key(key) { - save_size(key, size); - } - Some(size) -} - -/// Persist a size-only entry under an arbitrary key. -pub fn save_size(key: &str, size: StoredSize) { - let mut file = load_file(); - file.version = GEOMETRY_VERSION; - file.size_entries.insert(key.to_string(), size); - if let Err(err) = save_file(&file) { - tracing::warn!(target: "codexbar::geometry", %err, "failed to persist size"); - } -} - #[cfg(test)] mod tests { use super::*; @@ -225,9 +139,18 @@ mod tests { } #[test] - fn tray_panel_is_remembered_for_size() { - // The flyout persists its size (position is always re-anchored). - assert!(should_remember(SurfaceMode::TrayPanel)); + fn tray_panel_is_not_remembered() { + assert!(!should_remember(SurfaceMode::TrayPanel)); + } + + #[test] + fn old_flyout_size_entries_are_dropped_on_the_next_save() { + let json = r#"{"version":1,"entries":{"settings":{"x":10,"y":20}},"size_entries":{"flyoutLogical":{"width":700,"height":700}}}"#; + let file: GeometryFile = serde_json::from_str(json).unwrap(); + assert_eq!( + serde_json::to_string(&file).unwrap(), + r#"{"version":1,"entries":{"settings":{"x":10,"y":20}}}"# + ); } #[test] @@ -309,101 +232,4 @@ mod tests { assert_eq!(entry.width, None); assert_eq!(entry.height, None); } - - #[test] - fn stored_size_round_trips_without_position_fields() { - let mut f = GeometryFile::default(); - f.size_entries.insert( - "flyout".into(), - StoredSize { - width: 400, - height: 820, - }, - ); - let json = serde_json::to_string(&f).unwrap(); - // The size-only entry must never carry x/y — that's the whole point - // of keeping it out of `entries: BTreeMap`. - assert!(!json.contains("\"x\"")); - assert!(!json.contains("\"y\"")); - let parsed: GeometryFile = serde_json::from_str(&json).unwrap(); - let entry = parsed.size_entries.get("flyout").unwrap(); - assert_eq!(entry.width, 400); - assert_eq!(entry.height, 820); - } - - #[test] - fn resolve_size_prefers_new_key_over_legacy() { - let mut file = GeometryFile::default(); - file.size_entries.insert( - "flyout".into(), - StoredSize { - width: 500, - height: 900, - }, - ); - file.entries.insert( - LEGACY_FLYOUT_SIZE_KEY.into(), - StoredGeometry { - x: 0, - y: 0, - width: Some(640), - height: Some(720), - }, - ); - - let resolved = resolve_size(&file, "flyout").expect("size present"); - assert_eq!(resolved.width, 500); - assert_eq!(resolved.height, 900); - } - - #[test] - fn resolve_size_migrates_legacy_tray_panel_geometry_when_no_new_entry() { - // Simulates an upgrading user: pre-refactor size lived under the - // `SurfaceMode::TrayPanel` shared-window geometry key. - let mut file = GeometryFile::default(); - file.entries.insert( - LEGACY_FLYOUT_SIZE_KEY.into(), - StoredGeometry { - x: 0, - y: 0, - width: Some(640), - height: Some(720), - }, - ); - - let resolved = resolve_size(&file, "flyout").expect("legacy size migrates"); - assert_eq!(resolved.width, 640); - assert_eq!(resolved.height, 720); - } - - #[test] - fn resolve_size_ignores_legacy_entry_missing_width_or_height() { - let mut file = GeometryFile::default(); - file.entries.insert( - LEGACY_FLYOUT_SIZE_KEY.into(), - StoredGeometry { - x: 0, - y: 0, - width: Some(640), - height: None, - }, - ); - - assert!(resolve_size(&file, "flyout").is_none()); - } - - #[test] - fn resolve_size_returns_none_when_nothing_stored() { - let file = GeometryFile::default(); - assert!(resolve_size(&file, "flyout").is_none()); - } - - #[test] - fn resolve_size_for_legacy_key_itself_does_not_self_migrate() { - // Looking up the legacy key directly should only consult - // `size_entries` (the `key != LEGACY_FLYOUT_SIZE_KEY` guard) — no - // infinite fallback to itself. - let file = GeometryFile::default(); - assert!(resolve_size(&file, LEGACY_FLYOUT_SIZE_KEY).is_none()); - } } diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index d3f9cbd4d9..a874c9f406 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -212,9 +212,6 @@ fn main() { commands::open_settings_window, commands::open_flyout_window, commands::close_settings_window, - commands::set_flyout_size, - commands::flyout_stored_size, - commands::reset_flyout_position, commands::get_current_surface_state, commands::refresh_providers, commands::refresh_providers_if_stale, @@ -304,6 +301,7 @@ fn main() { commands::register_global_shortcut, commands::unregister_global_shortcut, commands::get_work_area_rect, + commands::get_system_accent_color, commands::play_notification_sound, commands::open_external_url, commands::reanchor_tray_panel, @@ -401,8 +399,8 @@ fn main() { { return; } - // Gesture guard: ignore blur while a resize-grip drag or - // HTML5 drag-reorder is running its Win32/OLE modal loop. + // Gesture guard: ignore blur while an HTML5 drag-reorder + // is running its OLE modal loop. // Windows produces a spurious Focused(false) the instant // such a loop starts even though the user never left the // window; see AppState::begin_gesture_blur_guard. diff --git a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs index 8a79e311c0..88714ed110 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs @@ -168,25 +168,74 @@ unsafe extern "system" fn borderless_subclass_proc( } } +/// The native chrome a borderless window gets from DWM. +#[cfg(windows)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Chrome { + Dark, + /// Keeps `WS_THICKFRAME` so the native resize affordance still works. + DarkResizable, + /// The tray flyout: a fixed light panel with Windows 11 rounded corners. + /// Its erase color comes from the builder's `background_color`, which + /// tao paints on `WM_ERASEBKGND`. + LightPanel, +} + +#[cfg(windows)] +const DWMWCP_ROUND: u32 = 2; + +/// Win32 COLORREF (`0x00BBGGRR`). +#[cfg(windows)] +const fn colorref(r: u8, g: u8, b: u8) -> u32 { + ((b as u32) << 16) | ((g as u32) << 8) | r as u32 +} + +#[cfg(windows)] +impl Chrome { + fn keeps_resize_frame(self) -> bool { + self == Self::DarkResizable + } + + fn paints_dark_brush(self) -> bool { + self != Self::LightPanel + } + + /// `DWMWA_WINDOW_CORNER_PREFERENCE`, when the window asks for one. + fn corner_preference(self) -> Option { + (self == Self::LightPanel).then_some(DWMWCP_ROUND) + } + + /// `DWMWA_BORDER_COLOR`. DWM draws its border along the rounded corner, + /// where the panel's CSS hairline is clipped, so both use the measured + /// Mac hairline `#8A8B8E`. Without it the dark-mode border would ring + /// the light panel. + fn border_color(self) -> Option { + (self == Self::LightPanel).then_some(colorref(0x8A, 0x8B, 0x8E)) + } +} + /// Eliminate the DWM caption bar by subclassing the window to zero the /// non-client area. Safe to call on multiple windows — each gets its /// own subclass via `SetWindowSubclass`. -/// -/// When `resizable` is true, `WS_THICKFRAME` is preserved so the native -/// resize affordance still works. #[cfg(windows)] pub fn force_dark_caption(win: &tauri::WebviewWindow) { - force_dark_caption_inner(win, false); + apply_chrome(win, Chrome::Dark); } /// Same as [`force_dark_caption`] but keeps the resize frame. #[cfg(windows)] pub fn force_dark_caption_resizable(win: &tauri::WebviewWindow) { - force_dark_caption_inner(win, true); + apply_chrome(win, Chrome::DarkResizable); } +/// Borderless light panel with rounded corners, for the tray flyout only. #[cfg(windows)] -fn force_dark_caption_inner(win: &tauri::WebviewWindow, keep_resize: bool) { +pub fn light_panel_chrome(win: &tauri::WebviewWindow) { + apply_chrome(win, Chrome::LightPanel); +} + +#[cfg(windows)] +fn apply_chrome(win: &tauri::WebviewWindow, chrome: Chrome) { use raw_window_handle::HasWindowHandle; let Ok(handle) = win.window_handle() else { @@ -224,6 +273,28 @@ fn force_dark_caption_inner(win: &tauri::WebviewWindow, keep_resize: bool) { ); tracing::info!("dwm: dark_mode={r1:#x} caption_color={r2:#x}"); + const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 33; + const DWMWA_BORDER_COLOR: u32 = 34; + // Windows 10 rejects both attributes and keeps square corners. + if let Some(corner) = chrome.corner_preference() { + let r = DwmSetWindowAttribute( + hwnd, + DWMWA_WINDOW_CORNER_PREFERENCE, + &raw const corner as *const c_void, + 4, + ); + tracing::info!("dwm: corner_preference={r:#x}"); + } + if let Some(border) = chrome.border_color() { + let r = DwmSetWindowAttribute( + hwnd, + DWMWA_BORDER_COLOR, + &raw const border as *const c_void, + 4, + ); + tracing::info!("dwm: border_color={r:#x}"); + } + // Extend DWM frame fully into client area let margins = Margins { left: -1, @@ -240,9 +311,11 @@ fn force_dark_caption_inner(win: &tauri::WebviewWindow, keep_resize: bool) { // Set background brush to dark (reuse a single GDI brush) const GCL_HBRBACKGROUND: i32 = -10; - let brush = *DARK_BRUSH.get_or_init(|| CreateSolidBrush(0x001C1C1E)); - if brush != 0 { - SetWindowLongPtrW(hwnd, GCL_HBRBACKGROUND, brush); + if chrome.paints_dark_brush() { + let brush = *DARK_BRUSH.get_or_init(|| CreateSolidBrush(0x001C1C1E)); + if brush != 0 { + SetWindowLongPtrW(hwnd, GCL_HBRBACKGROUND, brush); + } } // Remove WS_CAPTION; only strip WS_THICKFRAME for non-resizable windows @@ -250,6 +323,7 @@ fn force_dark_caption_inner(win: &tauri::WebviewWindow, keep_resize: bool) { const WS_CAPTION: isize = 0x00C00000; const WS_THICKFRAME: isize = 0x00040000; let style = GetWindowLongPtrW(hwnd, GWL_STYLE); + let keep_resize = chrome.keeps_resize_frame(); let new_style = if keep_resize { style & !WS_CAPTION } else { @@ -288,3 +362,37 @@ pub fn force_dark_caption(_win: &tauri::WebviewWindow) {} #[cfg(not(windows))] pub fn force_dark_caption_resizable(_win: &tauri::WebviewWindow) {} + +#[cfg(not(windows))] +pub fn light_panel_chrome(_win: &tauri::WebviewWindow) {} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + fn colorref_is_bgr() { + assert_eq!(colorref(0xDE, 0xDE, 0xE2), 0x00E2_DEDE); + assert_eq!(colorref(0x8A, 0x8B, 0x8E), 0x008E_8B8A); + } + + #[test] + fn only_the_light_panel_rounds_and_recolors_its_border() { + assert_eq!(Chrome::LightPanel.corner_preference(), Some(2)); + assert_eq!(Chrome::LightPanel.border_color(), Some(0x008E_8B8A)); + assert_eq!(Chrome::Dark.corner_preference(), None); + assert_eq!(Chrome::Dark.border_color(), None); + assert_eq!(Chrome::DarkResizable.corner_preference(), None); + assert_eq!(Chrome::DarkResizable.border_color(), None); + } + + #[test] + fn the_light_panel_is_fixed_size_and_skips_the_dark_brush() { + assert!(!Chrome::LightPanel.keeps_resize_frame()); + assert!(!Chrome::LightPanel.paints_dark_brush()); + assert!(!Chrome::Dark.keeps_resize_frame()); + assert!(Chrome::Dark.paints_dark_brush()); + assert!(Chrome::DarkResizable.keeps_resize_frame()); + assert!(Chrome::DarkResizable.paints_dark_brush()); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs deleted file mode 100644 index 30df73a666..0000000000 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! Where the user put the tray-panel flyout. -//! -//! The flyout opens anchored to the tray icon. Once the user drags it -//! somewhere else, that spot is remembered, and every later open and every -//! content-driven resize keeps the panel there instead of snapping it back to -//! the tray. Double-clicking the panel's move handle forgets the spot. -//! -//! A spot is recorded only when a Win32 move/size loop ends -//! (`WM_EXITSIZEMOVE`). Windows runs that loop for user drags of the window -//! or its frame, never for `SetWindowPos`, so the flyout's own re-anchoring -//! can't be mistaken for a user move. - -use crate::geometry_store::{self, StoredGeometry}; -use crate::window_positioner::{self, PanelSize, Rect}; - -/// Geometry-store key for the remembered flyout position. Lives in the -/// position `entries` map, separate from the size-only `"flyout"` entry. -const FLYOUT_POSITION_KEY: &str = "flyout"; - -/// The position the user dragged the flyout to, if any (physical px). -pub fn stored_position() -> Option<(i32, i32)> { - geometry_store::load_entry(FLYOUT_POSITION_KEY).map(|geometry| (geometry.x, geometry.y)) -} - -fn save_position(x: i32, y: i32) { - geometry_store::save_entry( - FLYOUT_POSITION_KEY, - StoredGeometry { - x, - y, - width: None, - height: None, - }, - ); -} - -/// Forget the user's spot so the flyout anchors to the tray again. -pub fn clear_position() { - geometry_store::remove_entry(FLYOUT_POSITION_KEY); -} - -/// The remembered position, clamped into the work area the panel overlaps -/// most (or the nearest one when a monitor layout change left it on none), so -/// the panel can't end up off-screen. `None` when the user never moved the -/// flyout. -pub fn placed_position(window: &tauri::WebviewWindow) -> Option<(i32, i32)> { - let (x, y) = stored_position()?; - let outer = window.outer_size().ok()?; - let panel = Rect { - x, - y, - width: outer.width, - height: outer.height, - }; - let work_areas: Vec = window - .available_monitors() - .ok()? - .iter() - .map(super::geometry::monitor_work_area_rect) - .collect(); - let work_area = best_work_area(&work_areas, &panel)?; - // The size is already physical, so clamp with a 1.0 scale. - Some(window_positioner::clamp_position_to_work_area( - x, - y, - &work_area, - &PanelSize { - width: outer.width, - height: outer.height, - }, - 1.0, - )) -} - -/// The work area `panel` overlaps most; with no overlap, the one whose center -/// is nearest. -fn best_work_area(work_areas: &[Rect], panel: &Rect) -> Option { - work_areas.iter().copied().max_by_key(|area| { - ( - overlap_area(area, panel), - std::cmp::Reverse(center_distance(area, panel)), - ) - }) -} - -fn overlap_area(a: &Rect, b: &Rect) -> i64 { - let width = (right(a).min(right(b)) - i64::from(a.x).max(i64::from(b.x))).max(0); - let height = (bottom(a).min(bottom(b)) - i64::from(a.y).max(i64::from(b.y))).max(0); - width * height -} - -fn center_distance(a: &Rect, b: &Rect) -> i64 { - // Doubled centers keep the math in integers. - let dx = (i64::from(a.x) + right(a)) - (i64::from(b.x) + right(b)); - let dy = (i64::from(a.y) + bottom(a)) - (i64::from(b.y) + bottom(b)); - dx * dx + dy * dy -} - -fn right(rect: &Rect) -> i64 { - i64::from(rect.x) + i64::from(rect.width) -} - -fn bottom(rect: &Rect) -> i64 { - i64::from(rect.y) + i64::from(rect.height) -} - -/// Whether a mouse button is held on the flyout itself. -/// -/// Windows moves focus off the WebView the moment a move or resize starts on -/// the window frame, so the flyout sees a blur before the gesture even -/// begins. A blur while the user is pressing on the panel itself is that -/// gesture, not a click somewhere else. The window under the cursor is -/// checked, not the panel's bounds, so a click on another topmost window -/// covering the panel still dismisses it. -#[cfg(windows)] -pub fn pointer_pressed_inside(window: &tauri::Window) -> bool { - const GA_ROOT: u32 = 2; - if !mouse_button_down() { - return false; - } - let Some(flyout) = super::activation::root_hwnd(window) else { - return false; - }; - let mut cursor = Win32Point::default(); - // SAFETY: GetCursorPos writes the caller-owned point; WindowFromPoint and - // GetAncestor only read the window tree. - unsafe { - if GetCursorPos(&mut cursor) == 0 { - return false; - } - let under_cursor = WindowFromPoint(cursor); - under_cursor != 0 && GetAncestor(under_cursor, GA_ROOT) == flyout - } -} - -#[cfg(not(windows))] -pub fn pointer_pressed_inside(_window: &tauri::Window) -> bool { - false -} - -/// Outer window bounds in physical px, as `GetWindowRect` reports them. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct WindowBounds { - left: i32, - top: i32, - right: i32, - bottom: i32, -} - -/// One user move/size loop in progress. -#[derive(Debug, Clone, Copy)] -struct SizeMoveLoop { - start: WindowBounds, - /// The user dragged an edge (`WM_SIZING`) rather than the whole window. - /// Compared bounds can't tell: a move onto a monitor with another DPI - /// rescales the window too. - resizing: bool, -} - -/// The spot to remember after one user move/size loop, if any. -/// -/// Dragging the whole window places the flyout. A resize from the top or left -/// edge also moves the window; that only updates the spot once the user has -/// placed the flyout, so resizing a tray-anchored panel keeps it tray-anchored. -fn placement_after_size_move( - size_move: SizeMoveLoop, - end: WindowBounds, - already_placed: bool, -) -> Option<(i32, i32)> { - let start = size_move.start; - let moved = (start.left, start.top) != (end.left, end.top); - (moved && (already_placed || !size_move.resizing)).then_some((end.left, end.top)) -} - -/// Record user drags of `window` from now on. The subclass must be installed -/// on the thread that owns the window, so this hops to the main thread. -#[cfg(windows)] -pub fn track_user_moves(window: &tauri::WebviewWindow) { - let Some(hwnd) = super::activation::root_hwnd(window) else { - tracing::warn!("flyout_placement: no window handle; user moves won't be remembered"); - return; - }; - let scheduled = window.run_on_main_thread(move || { - // SAFETY: `hwnd` is the live top-level flyout window and this runs on - // its owning thread. The subclass proc forwards every message. - let installed = - unsafe { SetWindowSubclass(hwnd, size_move_subclass_proc, SIZE_MOVE_SUBCLASS_ID, 0) }; - if installed == 0 { - tracing::warn!("flyout_placement: SetWindowSubclass failed"); - } - }); - if let Err(error) = scheduled { - tracing::warn!(%error, "flyout_placement: couldn't schedule move tracking"); - } -} - -#[cfg(not(windows))] -pub fn track_user_moves(_window: &tauri::WebviewWindow) {} - -/// Whether the user is dragging the flyout or one of its edges right now. -#[cfg(windows)] -pub fn user_move_in_progress() -> bool { - SIZE_MOVE.lock().is_ok_and(|size_move| size_move.is_some()) -} - -#[cfg(not(windows))] -pub fn user_move_in_progress() -> bool { - false -} - -#[cfg(windows)] -const SIZE_MOVE_SUBCLASS_ID: usize = 0xC0DE_F1E0; -#[cfg(windows)] -const WM_ENTERSIZEMOVE: u32 = 0x0231; -#[cfg(windows)] -const WM_EXITSIZEMOVE: u32 = 0x0232; -#[cfg(windows)] -const WM_SIZING: u32 = 0x0214; -#[cfg(windows)] -const WM_NCDESTROY: u32 = 0x0082; - -/// The move/size loop in progress. There is one flyout window, so one slot -/// is enough. -#[cfg(windows)] -static SIZE_MOVE: std::sync::Mutex> = std::sync::Mutex::new(None); - -#[cfg(windows)] -unsafe extern "system" fn size_move_subclass_proc( - hwnd: isize, - msg: u32, - wparam: usize, - lparam: isize, - _id: usize, - _data: usize, -) -> isize { - match msg { - WM_ENTERSIZEMOVE => { - if let Ok(mut size_move) = SIZE_MOVE.lock() { - *size_move = window_bounds(hwnd).map(|start| SizeMoveLoop { - start, - resizing: false, - }); - } - } - WM_SIZING => { - if let Ok(mut size_move) = SIZE_MOVE.lock() - && let Some(size_move) = size_move.as_mut() - { - size_move.resizing = true; - } - } - WM_NCDESTROY => { - if let Ok(mut size_move) = SIZE_MOVE.lock() { - *size_move = None; - } - // SAFETY: removes this subclass from the window being destroyed, - // on its owning thread. - unsafe { - RemoveWindowSubclass(hwnd, size_move_subclass_proc, SIZE_MOVE_SUBCLASS_ID); - } - } - WM_EXITSIZEMOVE => { - let size_move = SIZE_MOVE - .lock() - .ok() - .and_then(|mut size_move| size_move.take()); - if let (Some(size_move), Some(end)) = (size_move, window_bounds(hwnd)) - && let Some((x, y)) = - placement_after_size_move(size_move, end, stored_position().is_some()) - { - save_position(x, y); - } - } - _ => {} - } - // SAFETY: forwards the message this subclass received, unchanged. - unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) } -} - -#[cfg(windows)] -fn window_bounds(hwnd: isize) -> Option { - let mut rect = Win32Rect::default(); - // SAFETY: `hwnd` is the live window the subclass is attached to and - // `rect` is a caller-owned out-parameter. - if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 { - return None; - } - Some(WindowBounds { - left: rect.left, - top: rect.top, - right: rect.right, - bottom: rect.bottom, - }) -} - -#[cfg(windows)] -fn mouse_button_down() -> bool { - // Physical buttons: with swapped buttons the primary one is VK_RBUTTON, - // so check both. - const VK_LBUTTON: i32 = 0x01; - const VK_RBUTTON: i32 = 0x02; - // SAFETY: GetAsyncKeyState only reads global input state. A negative - // result means the key is down. - [VK_LBUTTON, VK_RBUTTON] - .into_iter() - .any(|key| unsafe { GetAsyncKeyState(key) } < 0) -} - -#[cfg(windows)] -#[repr(C)] -#[derive(Default)] -struct Win32Point { - x: i32, - y: i32, -} - -#[cfg(windows)] -#[repr(C)] -#[derive(Default)] -struct Win32Rect { - left: i32, - top: i32, - right: i32, - bottom: i32, -} - -#[cfg(windows)] -#[link(name = "user32")] -// SAFETY: FFI declarations for user32 calls; every call site passes the live -// flyout window handle or caller-owned out-parameters. -unsafe extern "system" { - fn GetAsyncKeyState(key: i32) -> i16; - fn GetWindowRect(hwnd: isize, rect: *mut Win32Rect) -> i32; - fn GetCursorPos(point: *mut Win32Point) -> i32; - fn WindowFromPoint(point: Win32Point) -> isize; - fn GetAncestor(hwnd: isize, flags: u32) -> isize; -} - -#[cfg(windows)] -#[link(name = "comctl32")] -// SAFETY: FFI declarations for the comctl32 subclass API; called with the -// live flyout window handle and a `'static` subclass procedure. -unsafe extern "system" { - fn SetWindowSubclass( - hwnd: isize, - proc: unsafe extern "system" fn(isize, u32, usize, isize, usize, usize) -> isize, - id: usize, - data: usize, - ) -> i32; - fn DefSubclassProc(hwnd: isize, msg: u32, wparam: usize, lparam: isize) -> isize; - fn RemoveWindowSubclass( - hwnd: isize, - proc: unsafe extern "system" fn(isize, u32, usize, isize, usize, usize) -> isize, - id: usize, - ) -> i32; -} - -#[cfg(test)] -mod tests { - use super::*; - - fn bounds(left: i32, top: i32, width: i32, height: i32) -> WindowBounds { - WindowBounds { - left, - top, - right: left + width, - bottom: top + height, - } - } - - fn moved(start: WindowBounds) -> SizeMoveLoop { - SizeMoveLoop { - start, - resizing: false, - } - } - - fn resized(start: WindowBounds) -> SizeMoveLoop { - SizeMoveLoop { - start, - resizing: true, - } - } - - #[test] - fn dragging_the_window_places_the_flyout() { - let start = bounds(1936, 511, 300, 873); - let end = bounds(400, 200, 300, 873); - assert_eq!( - placement_after_size_move(moved(start), end, false), - Some((400, 200)) - ); - } - - #[test] - fn dragging_onto_a_monitor_with_another_dpi_still_places_the_flyout() { - // Windows rescales the window on the way (100% -> 225%). - let start = bounds(1808, 611, 340, 773); - let end = bounds(600, -1500, 765, 1739); - assert_eq!( - placement_after_size_move(moved(start), end, false), - Some((600, -1500)) - ); - } - - #[test] - fn resizing_a_tray_anchored_flyout_from_the_top_left_keeps_it_tray_anchored() { - let start = bounds(1936, 511, 300, 873); - let end = bounds(1836, 411, 400, 973); - assert_eq!(placement_after_size_move(resized(start), end, false), None); - } - - #[test] - fn resizing_a_placed_flyout_from_the_top_left_follows_its_new_corner() { - let start = bounds(400, 200, 300, 873); - let end = bounds(300, 100, 400, 973); - assert_eq!( - placement_after_size_move(resized(start), end, true), - Some((300, 100)) - ); - } - - #[test] - fn resizing_from_the_bottom_right_never_records_a_spot() { - let start = bounds(400, 200, 300, 873); - let end = bounds(400, 200, 360, 900); - assert_eq!(placement_after_size_move(resized(start), end, true), None); - assert_eq!(placement_after_size_move(resized(start), end, false), None); - } - - #[test] - fn a_drag_that_ends_where_it_started_records_nothing() { - let start = bounds(400, 200, 300, 873); - assert_eq!(placement_after_size_move(moved(start), start, false), None); - } - - fn area(x: i32, y: i32, width: u32, height: u32) -> Rect { - Rect { - x, - y, - width, - height, - } - } - - // A stacked layout: primary monitor below, a taller one above. - fn stacked_work_areas() -> Vec { - vec![area(0, 0, 2560, 1392), area(0, -2560, 3840, 2452)] - } - - #[test] - fn a_panel_straddling_two_monitors_belongs_to_the_one_it_overlaps_most() { - let panel = area(800, -900, 400, 1000); - let chosen = best_work_area(&stacked_work_areas(), &panel).expect("work area"); - assert_eq!((chosen.x, chosen.y), (0, -2560)); - } - - #[test] - fn a_panel_whose_corner_hangs_off_screen_stays_on_its_monitor() { - // The top-left corner is left of every monitor. - let panel = area(-40, 300, 400, 900); - let chosen = best_work_area(&stacked_work_areas(), &panel).expect("work area"); - assert_eq!((chosen.x, chosen.y), (0, 0)); - } - - #[test] - fn a_panel_on_a_monitor_that_is_gone_moves_to_the_nearest_one() { - // It was on a monitor right of the primary that has been unplugged. - let panel = area(3000, 200, 400, 900); - let chosen = best_work_area(&stacked_work_areas(), &panel).expect("work area"); - assert_eq!((chosen.x, chosen.y), (0, 0)); - } - - #[test] - fn no_monitors_means_no_placement() { - assert!(best_work_area(&[], &area(0, 0, 400, 900)).is_none()); - } -} diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs index 1b177f799a..fc0ce9e3ab 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs @@ -1,17 +1,19 @@ -//! Detached "Pop Out Dashboard" flyout window: a resizable, tray-anchored -//! panel with optional always-on-top behavior that auto-hides on click-outside. +//! Detached "Pop Out Dashboard" flyout window: a fixed-width light panel, +//! anchored to the tray, with optional always-on-top behavior that auto-hides +//! on click-outside. //! //! Runs as an auxiliary Tauri window labeled `flyout`, independent of the //! `main` window's surface state machine. It is the only dashboard layout: //! tray left-click, "Pop Out Dashboard", the global shortcut, app launch and //! single-instance relaunch all open it. The legacy PopOut layout on `main` -//! is retired. It opens next to the tray until the user drags it elsewhere; -//! `super::flyout_placement` remembers that spot. +//! is retired. Like the Mac menu, it always opens next to the tray (or the +//! launch cursor) and can't be moved or resized; the frontend sets its height +//! to fit the content. //! //! Structurally modeled on `crate::floatbar` (self-contained module owning //! its window + a `handle_window_event` hook dispatched from `main.rs` //! before the `main`-window-only handling); the window itself is built with -//! `settings_window.rs`'s builder recipe (async open, manual DWM dark-caption +//! `settings_window.rs`'s builder recipe (async open, manual DWM chrome //! pass, `WebviewUrl::App` with a `?window=` query marker). use std::sync::Mutex; @@ -22,24 +24,14 @@ use codexbar::settings::Settings; use tauri::{AppHandle, Manager, PhysicalPosition, WebviewUrl}; use super::activation::{self, Activation}; -use crate::geometry_store::{self, StoredSize}; use crate::state::AppState; use crate::surface::SurfaceMode; pub const FLYOUT_LABEL: &str = "flyout"; -/// Geometry-store key for the flyout's remembered SIZE in logical px (the -/// position is stored separately, and only after the user drags the flyout). -/// Kept as its own key (distinct from the legacy -/// `SurfaceMode::TrayPanel::as_str()` `"trayPanel"` key) — -/// `geometry_store::load_size` migrates a pre-existing `"trayPanel"` entry -/// into this key on first read, so upgrading users keep their remembered -/// flyout size. -/// -/// The earlier `"flyout"` size entry held physical px, which only fit the -/// monitor it was taken on; it is ignored, so a user who resized under it -/// gets the legacy `"trayPanel"` size, or else the auto-fit size, once. -const FLYOUT_SIZE_KEY: &str = "flyoutLogical"; +/// The panel fill, `--mac-panel-bg` in `styles.css`. The window paints it +/// before the page loads and on every erase, so the panel never flashes dark. +const PANEL_FILL: tauri::utils::config::Color = tauri::utils::config::Color(0xDE, 0xDE, 0xE2, 0xFF); /// Same window used to close a same-click blur-dismiss/reopen race as the /// pre-split tray panel handling (formerly `shell::transition::handle_tray_panel_click`, @@ -55,27 +47,11 @@ const RECENTLY_SHOWN_GRACE: Duration = Duration::from_millis(500); /// when it loses focus, for automation flows that need it to stay visible. static KEEP_OPEN_ON_BLUR: AtomicBool = AtomicBool::new(false); -/// Set by a DPI change of the flyout; the next `Resized` (the rescale itself) -/// re-clamps a user-placed flyout at its new size. -static RECLAMP_AFTER_RESCALE: AtomicBool = AtomicBool::new(false); - /// Keep the flyout open on focus loss for the rest of this process. pub fn keep_open_on_blur() { KEEP_OPEN_ON_BLUR.store(true, Ordering::Relaxed); } -/// Read the remembered flyout size, if any (migrating a legacy -/// `"trayPanel"`-keyed size on first read — see `geometry_store::load_size`). -pub fn stored_size() -> Option<(u32, u32)> { - geometry_store::load_size(FLYOUT_SIZE_KEY).map(|size| (size.width, size.height)) -} - -/// Persist a user-chosen flyout size in logical px. Size-only — no -/// fabricated position. -pub fn save_stored_size(width: u32, height: u32) { - geometry_store::save_size(FLYOUT_SIZE_KEY, StoredSize { width, height }); -} - /// Whether the flyout window currently exists and is visible. Canonical /// replacement for the pre-split `surface_machine.current() == TrayPanel` /// check, now that the flyout is not a state of the shared machine. @@ -148,24 +124,23 @@ fn open_with_anchor( // from, rather than duplicating these values as independent constants // that could silently drift from `surface.rs`. let props = SurfaceMode::TrayPanel.window_properties(); - let (width, height) = stored_size() - .map(|(w, h)| (w as f64, h as f64)) - .unwrap_or((props.width, props.height)); - let url = WebviewUrl::App("index.html?window=flyout".into()); let mut builder = tauri::WebviewWindowBuilder::new(app, FLYOUT_LABEL, url) .title("CodexBar") - .inner_size(width, height) + .inner_size(props.width, props.height) .decorations(props.decorations) .shadow(false) .resizable(props.resizable) - // No maximize box: dragging the move strip to a screen edge must not - // snap or maximize the panel. + // No maximize box, so Windows never snaps or maximizes the panel. .maximizable(false) .always_on_top(settings.tray_panel_always_on_top) .skip_taskbar(props.skip_taskbar) + // WebView2 shares `prefers-color-scheme` across the process, so the + // flyout stays pinned dark like the other windows; the panel's CSS + // draws it light regardless. .theme(Some(tauri::Theme::Dark)) + .background_color(PANEL_FILL) // CRITICAL: dynamically-built windows default to drag-drop ENABLED, // which intercepts the HTML5 draggable events the provider grid's // drag-reorder (ProviderGrid.tsx) relies on — see `main`'s @@ -183,13 +158,9 @@ fn open_with_anchor( let win = builder.build().map_err(|e| e.to_string())?; apply_window_always_on_top(&win, settings.tray_panel_always_on_top)?; - // Force DWM caption dark; keep WS_THICKFRAME (resizable) like the - // Settings window. - super::dwm::force_dark_caption_resizable(&win); - super::flyout_placement::track_user_moves(&win); + super::dwm::light_panel_chrome(&win); - let user_placed = position.is_none() && super::flyout_placement::stored_position().is_some(); - if cursor.is_some() || user_placed { + if cursor.is_some() { reanchor(app)?; } else if let Some((x, y)) = position.or_else(|| super::position::default_surface_position(app, SurfaceMode::TrayPanel)) @@ -331,11 +302,6 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - { return true; } - // A press on the panel's own frame starts a native move/resize, - // which blurs the WebView before the gesture begins. - if super::flyout_placement::pointer_pressed_inside(window) { - return true; - } let Some(st) = app.try_state::>() else { return true; }; @@ -364,32 +330,6 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - } true } - // Size persistence is entirely frontend-driven (genuine user - // drag-resizes call `set_flyout_size`, auto-fit resizes never do) — - // mirrors `shell::position::remember_current_geometry_if_eligible` - // skipping TrayPanel for the same reason on the old shared window. - // User positions are recorded by `flyout_placement` when the native - // move/size loop ends, not per event. - tauri::WindowEvent::Moved(_) => true, - tauri::WindowEvent::ScaleFactorChanged { .. } => { - // The window is rescaled right after this event; clamp on that - // `Resized`, when the new size is in place. - RECLAMP_AFTER_RESCALE.store(true, Ordering::Relaxed); - true - } - tauri::WindowEvent::Resized(_) => { - // Landing on a monitor with another DPI rescales a placed flyout - // after it was clamped at the old size, which can push it past the - // work area. Not while the user is still dragging it. - if RECLAMP_AFTER_RESCALE.swap(false, Ordering::Relaxed) - && super::flyout_placement::stored_position().is_some() - && !super::flyout_placement::user_move_in_progress() - { - // Best-effort; a failure leaves the panel where Windows put it. - let _reclamp = reanchor(app); - } - true - } tauri::WindowEvent::CloseRequested { api, .. } => { // Hide-not-close on a native close request (Alt+F4-equivalent from // a screen reader): the flyout survives so it can be reopened without @@ -403,18 +343,11 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - } } -/// Forget where the user dragged the flyout and anchor it to the tray again. -pub fn reset_position(app: &AppHandle) -> Result<(), String> { - super::flyout_placement::clear_position(); - reanchor(app) -} - -/// Reposition the flyout at the user's spot, or else its captured cursor or -/// system-tray anchor, -/// using the window's CURRENT logical size (after a -/// frontend-driven resize). Canonical anchor-math implementation for the -/// flyout window; the `reanchor_tray_panel` Tauri command -/// (`commands/system.rs`) is a thin retarget onto this function. +/// Reposition the flyout at its captured cursor or system-tray anchor, using +/// the window's CURRENT logical size (after a frontend-driven resize). +/// Canonical anchor-math implementation for the flyout window; the +/// `reanchor_tray_panel` Tauri command (`commands/system.rs`) is a thin +/// retarget onto this function. pub fn reanchor(app: &AppHandle) -> Result<(), String> { use crate::window_positioner::{PanelSize, Rect}; @@ -422,20 +355,6 @@ pub fn reanchor(app: &AppHandle) -> Result<(), String> { .get_webview_window(FLYOUT_LABEL) .ok_or_else(|| "flyout window unavailable".to_string())?; - // Never move the window out from under the user's drag; the loop's end - // records where it landed. - if super::flyout_placement::user_move_in_progress() { - return Ok(()); - } - - // A flyout the user dragged elsewhere stays there; only its on-screen - // clamp follows the current size. - if let Some((x, y)) = super::flyout_placement::placed_position(&window) { - // Best-effort, like the anchored set_position below. - let _set_placed = window.set_position(PhysicalPosition::new(x, y)); - return Ok(()); - } - let scale = window.scale_factor().unwrap_or(1.0).max(1.0); let outer = window.outer_size().map_err(|e| e.to_string())?; @@ -528,18 +447,13 @@ mod tests { } #[test] - fn flyout_size_key_is_distinct_from_legacy_tray_panel_key() { - // The whole point of the migration in geometry_store::load_size is - // that this key differs from the legacy SurfaceMode::TrayPanel key - // ("trayPanel") — otherwise there'd be nothing to migrate FROM. - assert_ne!(FLYOUT_SIZE_KEY, SurfaceMode::TrayPanel.as_str()); - } - - #[test] - fn flyout_size_key_skips_the_old_physical_px_entry() { - // "flyout" sizes were physical px; reading them as logical would open - // the panel scale-factor times too large on a scaled display. - assert_ne!(FLYOUT_SIZE_KEY, "flyout"); + fn panel_fill_matches_the_css_panel_background() { + let css = include_str!("../../../src/styles.css"); + assert!(css.contains("--mac-panel-bg: #dedee2;")); + assert_eq!( + (PANEL_FILL.0, PANEL_FILL.1, PANEL_FILL.2, PANEL_FILL.3), + (0xDE, 0xDE, 0xE2, 0xFF) + ); } #[test] @@ -551,11 +465,11 @@ mod tests { // relationship depends on, so a change to `surface.rs` shows up here // instead of silently drifting from what the flyout actually builds. let props = SurfaceMode::TrayPanel.window_properties(); - assert_eq!(props.width, 328.0); + assert_eq!(props.width, 310.0); assert_eq!(props.height, 776.0); - assert_eq!(props.min_width, Some(300.0)); - assert_eq!(props.min_height, Some(360.0)); - assert!(props.resizable); + assert_eq!(props.min_width, None); + assert_eq!(props.min_height, None); + assert!(!props.resizable); assert!(!props.always_on_top); assert!(props.skip_taskbar); assert!(!props.decorations); diff --git a/apps/desktop-tauri/src-tauri/src/shell/mod.rs b/apps/desktop-tauri/src-tauri/src/shell/mod.rs index e7dd22ed16..3741d5446d 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/mod.rs @@ -8,7 +8,6 @@ use crate::surface_target::SurfaceTarget; pub mod activation; pub(crate) mod dwm; -mod flyout_placement; pub mod flyout_window; mod geometry; mod position; diff --git a/apps/desktop-tauri/src-tauri/src/shell/position.rs b/apps/desktop-tauri/src-tauri/src/shell/position.rs index f5e8379950..57de0f925b 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/position.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/position.rs @@ -232,13 +232,7 @@ pub fn remember_current_geometry_if_eligible(window: &tauri::Window) { let guard = st.lock().unwrap(); guard.surface_machine.current() }; - // The TrayPanel flyout persists its size explicitly from the frontend (only - // on genuine user drag-resizes, never on its own auto-fit resizes), so skip - // the automatic capture here — otherwise an auto-fit resize would be saved - // and freeze the panel at that size. - if current_mode == SurfaceMode::TrayPanel - || !crate::geometry_store::should_remember(current_mode) - { + if !crate::geometry_store::should_remember(current_mode) { return; } diff --git a/apps/desktop-tauri/src-tauri/src/shell/tests.rs b/apps/desktop-tauri/src-tauri/src/shell/tests.rs index 80ddeb50cc..c7651ce69b 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/tests.rs @@ -939,10 +939,7 @@ fn popout_layout_size_uses_remembered_logical_geometry() { } #[test] -fn tray_panel_layout_uses_remembered_size() { - // The "Pop Out Dashboard" flyout now honors the user's remembered SIZE. - // (Position is still re-anchored above the tray via default_surface_position, - // which ignores the stored x/y — only the size is taken from geometry.) +fn tray_panel_layout_ignores_remembered_size() { let props = SurfaceMode::TrayPanel.window_properties(); let stored = crate::geometry_store::StoredGeometry { x: 0, @@ -953,5 +950,5 @@ fn tray_panel_layout_uses_remembered_size() { let size = logical_size_from_geometry(SurfaceMode::TrayPanel, &props, Some(stored)); - assert_eq!(size, (640.0, 720.0)); + assert_eq!(size, (310.0, 776.0)); } diff --git a/apps/desktop-tauri/src-tauri/src/shell/window.rs b/apps/desktop-tauri/src-tauri/src/shell/window.rs index 470e8c0187..8ddf6e876f 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/window.rs @@ -66,8 +66,7 @@ pub fn apply_window_layout( if props.visible { // The flyout ("Pop Out Dashboard") window is sized entirely by the - // frontend (content auto-fit, or the user's remembered size applied - // on open via `flyout_window::open_or_focus` + `set_flyout_size`) — + // frontend (content auto-fit at a fixed width) — // it is its own dedicated window now, never `main`, so this function // (which only ever runs against `main`'s window; see callers in // `shell/transition.rs`) needs no special-case for it. `main`'s diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 07191d57c4..2a9f39c1f2 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -277,8 +277,8 @@ impl AppState { } /// Arm the gesture blur guard for 15s. Called when the frontend reports - /// a resize-grip press or a drag-reorder mousedown is about to start a - /// Win32/OLE modal loop that will transiently blur the window. + /// a drag-reorder mousedown is about to start an OLE modal loop that will + /// transiently blur the window. pub fn begin_gesture_blur_guard(&mut self, now: std::time::Instant) { self.gesture_blur_guard = Some((now, now + std::time::Duration::from_secs(15))); } diff --git a/apps/desktop-tauri/src-tauri/src/surface.rs b/apps/desktop-tauri/src-tauri/src/surface.rs index 5ce867660a..bb413a4e35 100644 --- a/apps/desktop-tauri/src-tauri/src/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/surface.rs @@ -50,14 +50,16 @@ impl SurfaceMode { // tray icon, auto-hides on click-outside (blur), and never shows in // the taskbar. Its optional always-on-top setting is applied by the // dedicated flyout window, which can read persisted settings. + // Fixed 310px wide like the Mac menu; the frontend sizes the + // height to its content and scales both by "Panel scale". Self::TrayPanel => WindowProperties { visible: true, decorations: false, - resizable: true, - width: 328.0, + resizable: false, + width: 310.0, height: 776.0, - min_width: Some(300.0), - min_height: Some(360.0), + min_width: None, + min_height: None, always_on_top: false, blur_dismiss: true, skip_taskbar: true, @@ -268,21 +270,21 @@ mod tests { #[test] fn tray_panel_properties() { let props = SurfaceMode::TrayPanel.window_properties(); - assert_eq!(props.width, 328.0); + assert_eq!(props.width, 310.0); assert_eq!(props.height, 776.0); } #[test] - fn tray_panel_is_resizable_blur_dismiss_flyout() { + fn tray_panel_is_fixed_size_blur_dismiss_flyout() { let props = SurfaceMode::TrayPanel.window_properties(); - // "Pop Out Dashboard" flyout: resizable, anchored, auto-hide, no taskbar. - assert!(props.resizable); + // "Pop Out Dashboard" flyout: fixed size, anchored, auto-hide, no taskbar. + assert!(!props.resizable); assert!(props.blur_dismiss); assert!(!props.always_on_top); assert!(props.skip_taskbar); assert!(!props.decorations); - assert_eq!(props.min_width, Some(300.0)); - assert_eq!(props.min_height, Some(360.0)); + assert_eq!(props.min_width, None); + assert_eq!(props.min_height, None); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index ce7e8bf1b2..bca684acbf 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -601,9 +601,9 @@ mod tests { // SurfaceMode::TrayPanel is retained purely as a data key (geometry // key / window_properties source / panel-size reference) for the - // flyout window's builder — the properties themselves are unchanged. + // flyout window's builder. The panel is a fixed-width menu. let props = SurfaceMode::TrayPanel.window_properties(); - assert!(props.resizable && props.blur_dismiss && props.skip_taskbar); + assert!(!props.resizable && props.blur_dismiss && props.skip_taskbar); } #[test] From 8016acf9e1d34e20ef9260777bb34602c8375e1c Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:40:25 +0700 Subject: [PATCH 03/10] Add Panel scale to Settings > Menu The tray panel's zoom moves from the panel itself to a 100% to 200% slider in Settings > Menu. It saves the existing trayScalePercent setting when the slider is released, so saved scales keep working. --- .../src/floatbar/SettingsSection.tsx | 26 +------------ .../desktop-tauri/src/hooks/useDraftNumber.ts | 26 +++++++++++++ apps/desktop-tauri/src/i18n/keys.ts | 2 + .../settings/tabs/DisplayTab.test.tsx | 39 ++++++++++++++++--- .../src/surfaces/settings/tabs/DisplayTab.tsx | 29 ++++++++++++++ docs/ARCHITECTURE.md | 2 +- rust/src/locale.rs | 2 + rust/src/locale/en-US.ftl | 2 + rust/src/locale/es-MX.ftl | 2 + rust/src/locale/ja-JP.ftl | 2 + rust/src/locale/ko-KR.ftl | 2 + rust/src/locale/pt-BR.ftl | 2 + rust/src/locale/ru-RU.ftl | 2 + rust/src/locale/tr-TR.ftl | 2 + rust/src/locale/uk-UA.ftl | 2 + rust/src/locale/zh-CN.ftl | 2 + rust/src/locale/zh-TW.ftl | 2 + 17 files changed, 115 insertions(+), 31 deletions(-) create mode 100644 apps/desktop-tauri/src/hooks/useDraftNumber.ts diff --git a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx index 7960659006..c171bd9bf6 100644 --- a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx +++ b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx @@ -1,5 +1,5 @@ -import { useCallback, useState } from "react"; import { Field, Select, Toggle } from "../components/FormControls"; +import { useDraftNumber } from "../hooks/useDraftNumber"; import { useLocale } from "../hooks/useLocale"; import type { FloatBarOrientation, @@ -14,30 +14,6 @@ interface Props { set: (patch: SettingsUpdate) => void; } -function useDraftNumber(value: number) { - const [draft, setDraft] = useState(value); - const [prev, setPrev] = useState(value); - if (value !== prev) { - setPrev(value); - setDraft(value); - } - - const commit = useCallback( - (next: number, onCommit: (value: number) => void) => { - // Dedupe against the committed prop value, which is the persisted - // source of truth. The parent's save is fire-and-forget, so we can't - // observe success/failure here — comparing to `value` (rather than an - // optimistically-advanced marker) means a failed save leaves the prop - // unchanged and a re-commit of the same number still fires the retry. - if (next === value) return; - onCommit(next); - }, - [value], - ); - - return { draft, setDraft, commit }; -} - /** * Settings UI block for the floating capacity bar. Rendered as one row * in the Display tab — kept in this module so the Display tab only diff --git a/apps/desktop-tauri/src/hooks/useDraftNumber.ts b/apps/desktop-tauri/src/hooks/useDraftNumber.ts new file mode 100644 index 0000000000..983d08aabb --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useDraftNumber.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from "react"; + +/** Local slider value that follows the saved setting and commits on release. */ +export function useDraftNumber(value: number) { + const [draft, setDraft] = useState(value); + const [prev, setPrev] = useState(value); + if (value !== prev) { + setPrev(value); + setDraft(value); + } + + const commit = useCallback( + (next: number, onCommit: (value: number) => void) => { + // Dedupe against the committed prop value, which is the persisted + // source of truth. The parent's save is fire-and-forget, so we can't + // observe success/failure here — comparing to `value` (rather than an + // optimistically-advanced marker) means a failed save leaves the prop + // unchanged and a re-commit of the same number still fires the retry. + if (next === value) return; + onCommit(next); + }, + [value], + ); + + return { draft, setDraft, commit }; +} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index d51a6988bd..647f1bb135 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -698,6 +698,8 @@ export const ALL_LOCALE_KEYS = [ "DisplayModeMinimal", "TrayPanelAlwaysOnTopLabel", "TrayPanelAlwaysOnTopHelper", + "PanelScaleLabel", + "PanelScaleHelper", "WindowMinimize", "WindowRestore", "WindowClose", diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx index 8c57e3a31a..679ded5d32 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; vi.mock("../../../hooks/useLocale", () => ({ @@ -48,12 +48,41 @@ function renderTab( } describe("DisplayTab menu settings", () => { - it("no longer offers the retired PopOut window scale", () => { - // Window scale only zoomed the retired PopOut layout. The tray panel - // has its own Zoom slider in its footer (trayScalePercent). + it("offers the tray Panel scale as its only slider, with no PopOut window scale", () => { const { container } = renderTab(vi.fn()); - expect(container.querySelector('input[type="range"]')).toBeNull(); + expect( + Array.from(container.querySelectorAll('input[type="range"]')).map((input) => + input.getAttribute("aria-label"), + ), + ).toEqual(["PanelScaleLabel"]); + }); + + it("shows the saved Panel scale and saves the new one when the slider is released", async () => { + const set = vi.fn(); + // Settles the tab's tray-visibility read before the slider re-renders it. + await act(async () => { + render( + , + ); + }); + + const slider = screen.getByRole("slider", { name: "PanelScaleLabel" }); + expect(slider).toHaveValue("150"); + expect(screen.getByText("PanelScaleLabel (150%)")).toBeInTheDocument(); + + fireEvent.change(slider, { target: { value: "125" } }); + expect(screen.getByText("PanelScaleLabel (125%)")).toBeInTheDocument(); + expect(set).not.toHaveBeenCalled(); + fireEvent.pointerUp(slider); + + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith({ trayScalePercent: 125 }); }); it("updates the exhausted reset display preference", () => { diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx index 51f2607e42..79c6ed0aed 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useDraftNumber } from "../../../hooks/useDraftNumber"; import { useLocale } from "../../../hooks/useLocale"; import { Field, Select, Toggle } from "../../../components/FormControls"; import type { @@ -12,6 +13,12 @@ import type { TabProps } from "../settingsTabs"; import FloatBarSettingsSection from "../../../floatbar/SettingsSection"; import SwitcherShortcutsSection from "../SwitcherShortcutsSection"; import { getTrayVisibilityStatus } from "../../../lib/tauri"; +import { + clampTrayScalePercent, + TRAY_SCALE_MAX, + TRAY_SCALE_MIN, + TRAY_SCALE_STEP, +} from "../../../lib/trayScale"; export default function DisplayTab({ mode = "menu", @@ -25,6 +32,10 @@ export default function DisplayTab({ }) { const { t } = useLocale(); const [trayVisibility, setTrayVisibility] = useState(null); + const panelScale = useDraftNumber(clampTrayScalePercent(settings.trayScalePercent)); + const commitPanelScale = () => { + panelScale.commit(panelScale.draft, (value) => set({ trayScalePercent: value })); + }; useEffect(() => { getTrayVisibilityStatus() @@ -259,6 +270,24 @@ export default function DisplayTab({ onChange={(v) => set({ showPace: v })} /> + + panelScale.setDraft(Number(e.target.value))} + onPointerUp={commitPanelScale} + onTouchEnd={commitPanelScale} + onBlur={commitPanelScale} + onKeyUp={commitPanelScale} + aria-label={t("PanelScaleLabel")} + /> +
} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2c20b4ca68..294221c390 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,7 +38,7 @@ Cargo workspace (root `Cargo.toml`): members `rust`, `apps/desktop-tauri/src-tau ## Surfaces (desktop) -- **Tray panel** — left-click tray; blur-dismiss (suppressed in proof mode, and while a mouse button is held on the panel so a frame move/resize doesn't close it). Opens next to the tray until the user drags its top strip elsewhere; `shell/flyout_placement.rs` remembers that spot (double-click the strip to forget it). Resizable from every edge and corner. +- **Tray panel** — left-click tray; blur-dismiss (suppressed in proof mode and while a provider tab is dragged to reorder). A light, Mac-style panel 310 px wide (times the **Panel scale** setting in Settings > Menu) whose height fits its content. It always opens next to the tray and can't be moved or resized. - **Tray panel flyout** — larger dashboard window opened from the tray menu ("Pop Out Dashboard"); the tray panel itself is the only dashboard layout. diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 75b3767cf2..fd5ef31d07 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -968,6 +968,8 @@ locale_keys! { DisplayModeMinimal, TrayPanelAlwaysOnTopLabel, TrayPanelAlwaysOnTopHelper, + PanelScaleLabel, + PanelScaleHelper, WindowMinimize, WindowRestore, WindowClose, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index bc3c4f2f2c..94d30118c9 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -564,6 +564,8 @@ DisplayModeCompact = Compact DisplayModeMinimal = Minimal TrayPanelAlwaysOnTopLabel = Always on top TrayPanelAlwaysOnTopHelper = Keep the tray panel above other windows, even when it loses focus. +PanelScaleLabel = Panel scale +PanelScaleHelper = Scales the tray panel width, text, and spacing. WindowMinimize = Minimize WindowRestore = Restore WindowClose = Close diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index c0cb13867f..01da040375 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -582,6 +582,8 @@ DisplayModeCompact = Compacto DisplayModeMinimal = Mínimo TrayPanelAlwaysOnTopLabel = Siempre visible encima TrayPanelAlwaysOnTopHelper = Mantén el panel de bandeja sobre otras ventanas, incluso cuando pierda el foco. +PanelScaleLabel = Escala del panel +PanelScaleHelper = Ajusta el ancho, el texto y el espaciado del panel de la bandeja. WindowMinimize = Minimizar WindowRestore = Restaurar WindowClose = Cerrar diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index bf4d3504e9..ad7f714bcb 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -537,6 +537,8 @@ DisplayModeCompact = コンパクト DisplayModeMinimal = 最小 TrayPanelAlwaysOnTopLabel = 常に手前に表示 TrayPanelAlwaysOnTopHelper = フォーカスを失っても、トレイパネルを他のウィンドウより前面に表示します。 +PanelScaleLabel = パネルの拡大率 +PanelScaleHelper = トレイパネルの幅、文字、余白を拡大します。 WindowMinimize = 最小化 WindowRestore = 元に戻す WindowClose = 閉じる diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index ee48a147ea..794ad768e4 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -582,6 +582,8 @@ DisplayModeCompact = 압축 DisplayModeMinimal = 최소 TrayPanelAlwaysOnTopLabel = 항상 위에 표시 TrayPanelAlwaysOnTopHelper = 포커스를 잃어도 트레이 패널을 다른 창보다 앞에 유지합니다. +PanelScaleLabel = 패널 배율 +PanelScaleHelper = 트레이 패널의 너비, 글자, 간격을 조정합니다. WindowMinimize = 최소화 WindowRestore = 이전 크기로 복원 WindowClose = 닫기 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index ea8e5cf0e0..b72df9bfba 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -535,6 +535,8 @@ DisplayModeCompact = Compacto DisplayModeMinimal = Mínimo TrayPanelAlwaysOnTopLabel = Sempre no topo TrayPanelAlwaysOnTopHelper = Mantenha o painel da bandeja acima das outras janelas, mesmo quando ele perder o foco. +PanelScaleLabel = Escala do painel +PanelScaleHelper = Ajusta a largura, o texto e o espaçamento do painel da bandeja. WindowMinimize = Minimizar WindowRestore = Restaurar WindowClose = Fechar diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 7796355475..1034d439a2 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -564,6 +564,8 @@ DisplayModeCompact = Компактный DisplayModeMinimal = Минимальный TrayPanelAlwaysOnTopLabel = Поверх всех окон TrayPanelAlwaysOnTopHelper = Панель в области уведомлений остается поверх других окон даже после потери фокуса. +PanelScaleLabel = Масштаб панели +PanelScaleHelper = Масштабирует ширину, текст и отступы панели в трее. WindowMinimize = Свернуть WindowRestore = Восстановить WindowClose = Закрыть diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 31810dd288..93fb051a15 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -564,6 +564,8 @@ DisplayModeCompact = Kompakt DisplayModeMinimal = En az TrayPanelAlwaysOnTopLabel = Her zaman üstte TrayPanelAlwaysOnTopHelper = Odak kaybolduğunda bile tepsi panelini diğer pencerelerin üzerinde tutar. +PanelScaleLabel = Panel ölçeği +PanelScaleHelper = Tepsi panelinin genişliğini, metnini ve boşluklarını ölçekler. WindowMinimize = Simge durumuna küçült WindowRestore = Geri yükle WindowClose = Kapat diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index dcd5b0c82a..185a41b80c 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -564,6 +564,8 @@ DisplayModeCompact = Компактний DisplayModeMinimal = Мінімальний TrayPanelAlwaysOnTopLabel = Поверх усіх вікон TrayPanelAlwaysOnTopHelper = Тримати панель трею поверх інших вікон, навіть коли вона втрачає фокус. +PanelScaleLabel = Масштаб панелі +PanelScaleHelper = Масштабує ширину, текст і відступи панелі в треї. WindowMinimize = Згорнути WindowRestore = Відновити WindowClose = Закрити diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 9936510a7c..176946e679 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -537,6 +537,8 @@ DisplayModeCompact = 紧凑 DisplayModeMinimal = 最简 TrayPanelAlwaysOnTopLabel = 始终置顶 TrayPanelAlwaysOnTopHelper = 即使失去焦点,也让托盘面板保持在其他窗口之上。 +PanelScaleLabel = 面板缩放 +PanelScaleHelper = 缩放托盘面板的宽度、文字和间距。 WindowMinimize = 最小化 WindowRestore = 还原 WindowClose = 关闭 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index ba0201928f..b10ee45da6 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -537,6 +537,8 @@ DisplayModeCompact = 緊湊 DisplayModeMinimal = 最簡 TrayPanelAlwaysOnTopLabel = 永遠置頂 TrayPanelAlwaysOnTopHelper = 即使失去焦點,也讓系統匣面板保持在其他視窗上方。 +PanelScaleLabel = 面板縮放 +PanelScaleHelper = 縮放系統匣面板的寬度、文字和間距。 WindowMinimize = 最小化 WindowRestore = 還原 WindowClose = 關閉 From 49d799ba8d8217812fbe9b92a00960f6ff5d71b3 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:07:45 +0700 Subject: [PATCH 04/10] Draw tray captions in the Mac secondary text color Muted tray text used the tertiary color, which macOS keeps for shortcuts. Captions such as the spend summary note read at about 1.8:1 on the panel fill. They now use the secondary color. --- apps/desktop-tauri/src/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 1b7b7b47f7..01fbf89525 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -3467,7 +3467,7 @@ html:has(.menu-surface--tray) { --menu-popover-bg: var(--mac-panel-bg); --text-primary: var(--mac-text); --text-secondary: var(--mac-text-2); - --text-muted: var(--mac-text-3); + --text-muted: var(--mac-text-2); --panel-border: var(--mac-divider); --usage-bar-track: var(--mac-track); From 5d94b67e77c17bd5d60499dfbd013a557b19a7a5 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:08:06 +0700 Subject: [PATCH 05/10] Follow accent changes and simplify the tray chrome - Re-read the Windows accent each time the panel takes focus. - Use black selection text when white falls below 4.5:1 on the accent. - Drop the white plate behind the selected card. - Hoist --mac-panel-bg to :root and derive the fill test from PANEL_FILL. - Keep the dark erase brush call for every window, as on main. - Drop the unused flyout minimum size. --- apps/desktop-tauri/src-tauri/src/shell/dwm.rs | 17 +++-------- .../src-tauri/src/shell/flyout_window.rs | 15 ++++------ .../src/hooks/useTrayPanelController.ts | 28 ++++++++++++++----- .../src/lib/menuSelection.test.ts | 11 ++++++-- apps/desktop-tauri/src/lib/menuSelection.ts | 5 ++-- apps/desktop-tauri/src/styles.css | 9 +++--- .../src/surfaces/TrayPanel.test.tsx | 22 +++++++++++++++ 7 files changed, 69 insertions(+), 38 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs index 88714ed110..9b6ea667dc 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs @@ -196,10 +196,6 @@ impl Chrome { self == Self::DarkResizable } - fn paints_dark_brush(self) -> bool { - self != Self::LightPanel - } - /// `DWMWA_WINDOW_CORNER_PREFERENCE`, when the window asks for one. fn corner_preference(self) -> Option { (self == Self::LightPanel).then_some(DWMWCP_ROUND) @@ -311,11 +307,9 @@ fn apply_chrome(win: &tauri::WebviewWindow, chrome: Chrome) { // Set background brush to dark (reuse a single GDI brush) const GCL_HBRBACKGROUND: i32 = -10; - if chrome.paints_dark_brush() { - let brush = *DARK_BRUSH.get_or_init(|| CreateSolidBrush(0x001C1C1E)); - if brush != 0 { - SetWindowLongPtrW(hwnd, GCL_HBRBACKGROUND, brush); - } + let brush = *DARK_BRUSH.get_or_init(|| CreateSolidBrush(0x001C1C1E)); + if brush != 0 { + SetWindowLongPtrW(hwnd, GCL_HBRBACKGROUND, brush); } // Remove WS_CAPTION; only strip WS_THICKFRAME for non-resizable windows @@ -387,12 +381,9 @@ mod tests { } #[test] - fn the_light_panel_is_fixed_size_and_skips_the_dark_brush() { + fn only_the_resizable_dark_chrome_keeps_the_resize_frame() { assert!(!Chrome::LightPanel.keeps_resize_frame()); - assert!(!Chrome::LightPanel.paints_dark_brush()); assert!(!Chrome::Dark.keeps_resize_frame()); - assert!(Chrome::Dark.paints_dark_brush()); assert!(Chrome::DarkResizable.keeps_resize_frame()); - assert!(Chrome::DarkResizable.paints_dark_brush()); } } diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs index fc0ce9e3ab..6ec518e806 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs @@ -126,7 +126,7 @@ fn open_with_anchor( let props = SurfaceMode::TrayPanel.window_properties(); let url = WebviewUrl::App("index.html?window=flyout".into()); - let mut builder = tauri::WebviewWindowBuilder::new(app, FLYOUT_LABEL, url) + let builder = tauri::WebviewWindowBuilder::new(app, FLYOUT_LABEL, url) .title("CodexBar") .inner_size(props.width, props.height) .decorations(props.decorations) @@ -152,9 +152,6 @@ fn open_with_anchor( // focus. .focused(false) .visible(false); - if let (Some(min_w), Some(min_h)) = (props.min_width, props.min_height) { - builder = builder.min_inner_size(min_w, min_h); - } let win = builder.build().map_err(|e| e.to_string())?; apply_window_always_on_top(&win, settings.tray_panel_always_on_top)?; @@ -449,17 +446,15 @@ mod tests { #[test] fn panel_fill_matches_the_css_panel_background() { let css = include_str!("../../../src/styles.css"); - assert!(css.contains("--mac-panel-bg: #dedee2;")); - assert_eq!( - (PANEL_FILL.0, PANEL_FILL.1, PANEL_FILL.2, PANEL_FILL.3), - (0xDE, 0xDE, 0xE2, 0xFF) - ); + let tauri::utils::config::Color(r, g, b, a) = PANEL_FILL; + assert_eq!(a, 0xFF); + assert!(css.contains(&format!("--mac-panel-bg: #{r:02x}{g:02x}{b:02x};"))); } #[test] fn tray_panel_window_properties_still_the_single_source_for_flyout_shape() { // `open_or_focus`'s builder reads size/decorations/resizable/ - // skip_taskbar/min-size from + // skip_taskbar from // `SurfaceMode::TrayPanel.window_properties()` directly (not // independent duplicated constants) — this pins down the values that // relationship depends on, so a change to `surface.rs` shows up here diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index 64c15de620..66e79b5997 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -67,7 +67,6 @@ export function useTrayPanelController(state: BootstrapState) { const trayScale = clampTrayScalePercent(settings.trayScalePercent) / 100; const [appVersion, setAppVersion] = useState(null); - const [accentColor, setAccentColor] = useState(null); useEffect(() => { let cancelled = false; void getAppInfo().then( @@ -76,17 +75,32 @@ export function useTrayPanelController(state: BootstrapState) { }, () => {}, ); - void getSystemAccentColor().then( - (color) => { - if (!cancelled) setAccentColor(color); - }, - () => {}, - ); return () => { cancelled = true; }; }, []); + // The hidden panel window lives for the whole app run, so a focus re-read + // is what picks up an accent changed in Windows settings. + const [accentColor, setAccentColor] = useState(null); + useEffect(() => { + let cancelled = false; + const readAccent = () => { + void getSystemAccentColor().then( + (color) => { + if (!cancelled) setAccentColor(color); + }, + () => {}, + ); + }; + readAccent(); + window.addEventListener("focus", readAccent); + return () => { + cancelled = true; + window.removeEventListener("focus", readAccent); + }; + }, []); + const sorted = useMemo( () => orderProviderSnapshots( diff --git a/apps/desktop-tauri/src/lib/menuSelection.test.ts b/apps/desktop-tauri/src/lib/menuSelection.test.ts index 0e2d2b5792..34bd3c309d 100644 --- a/apps/desktop-tauri/src/lib/menuSelection.test.ts +++ b/apps/desktop-tauri/src/lib/menuSelection.test.ts @@ -9,10 +9,17 @@ describe("accentSelectionStyle", () => { }); }); - it("switches to dark text on a light accent", () => { + it("switches to black text on a light accent", () => { expect(accentSelectionStyle("#ffb900")).toEqual({ "--mac-selection-bg": "#ffb900", - "--mac-selection-text": "rgba(0, 0, 0, 0.85)", + "--mac-selection-text": "#000", + }); + }); + + it("uses solid black on a mid gray accent, where 85% black would fall below 4.5:1", () => { + expect(accentSelectionStyle("#7a7a7a")).toEqual({ + "--mac-selection-bg": "#7a7a7a", + "--mac-selection-text": "#000", }); }); diff --git a/apps/desktop-tauri/src/lib/menuSelection.ts b/apps/desktop-tauri/src/lib/menuSelection.ts index 381f184bdb..ab430ef33d 100644 --- a/apps/desktop-tauri/src/lib/menuSelection.ts +++ b/apps/desktop-tauri/src/lib/menuSelection.ts @@ -10,7 +10,8 @@ function linearChannel(byte: number): number { /** * Menu selection colors from the Windows accent, the way macOS menus use the - * system accent. White text stays only where it keeps 4.5:1 contrast. + * system accent. White text stays only where it keeps 4.5:1 contrast. Where + * white fails, black clears 4.67:1 on any accent. */ export function accentSelectionStyle(accent: string | null): CSSProperties { const match = accent ? HEX_COLOR.exec(accent) : null; @@ -20,6 +21,6 @@ export function accentSelectionStyle(accent: string | null): CSSProperties { const whiteContrast = 1.05 / (luminance + 0.05); return { "--mac-selection-bg": accent, - "--mac-selection-text": whiteContrast >= 4.5 ? "#fff" : "rgba(0, 0, 0, 0.85)", + "--mac-selection-text": whiteContrast >= 4.5 ? "#fff" : "#000", } as CSSProperties; } diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 01fbf89525..173ff27a6e 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -1049,10 +1049,13 @@ html { fades the content in smoothly. The page behind the panel carries the panel fill (--mac-panel-bg, and PANEL_FILL in shell/flyout_window.rs) so no other color shows while the panel is hidden. */ +:root { + --mac-panel-bg: #dedee2; +} html:has(.tray-panel-reveal), body:has(.tray-panel-reveal), .tray-panel-reveal { - background: #dedee2; + background: var(--mac-panel-bg); } .tray-panel-reveal { display: flex; @@ -3396,7 +3399,6 @@ html:has(.menu-surface--tray) { --mac-text-menu: 400 13px/16px var(--mac-font); --mac-text-shortcut: 400 13px/16px var(--mac-font); - --mac-panel-bg: #dedee2; --mac-panel-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.25); --mac-panel-border: rgba(0, 0, 0, 0.38); --mac-text: rgba(0, 0, 0, 0.85); @@ -3534,9 +3536,8 @@ html:has(.menu-surface--tray) { box-sizing: border-box; overflow: visible; } -/* Selected card — subtle highlight like macOS active provider */ +/* Selected card: no highlight plate, like the macOS menu card. */ .menu-surface--tray .menu-stack__item--selected { - background: rgba(255, 255, 255, 0.06); border: none; border-radius: 6px; padding: 4px 4px; diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index a144310725..3b5e717586 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -932,6 +932,28 @@ describe("TrayPanel provider grid", () => { expect(surface.style.zoom).toBe("1.5"); }); + it("follows a new Windows accent the next time the panel takes focus", async () => { + tauriMocks.getSystemAccentColor.mockResolvedValue("#0078d4"); + const { container } = renderTrayPanel([provider("claude", "Claude", 35)]); + const selectionBg = () => + container + .querySelector(".menu-surface--tray") + ?.style.getPropertyValue("--mac-selection-bg"); + await waitFor(() => expect(selectionBg()).toBe("#0078d4")); + + tauriMocks.getSystemAccentColor.mockResolvedValue("#ffb900"); + act(() => { + window.dispatchEvent(new Event("focus")); + }); + + await waitFor(() => expect(selectionBg()).toBe("#ffb900")); + expect( + container + .querySelector(".menu-surface--tray")! + .style.getPropertyValue("--mac-selection-text"), + ).toBe("#000"); + }); + it("reveals the tray panel if the native resize pass fails", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); windowMocks.getCurrentWindow.mockReturnValue({ From ad0f94f01515cecce448ee09ca9bae5f89b919b0 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:33:58 +0700 Subject: [PATCH 06/10] Report failed tray panel resizes applySize swallowed every setSize and innerSize error, so the resize pass's own catch never saw a failed native resize. The error now reaches that catch, which logs a warning and still reveals the panel. --- apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts | 10 +++------- apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx | 1 + 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts index 730b913283..f3231b098c 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts @@ -60,13 +60,9 @@ export function useTrayPanelLayout({ const applySize = useCallback(async (size: LogicalSize): Promise => { const win = getCurrentWindow(); - try { - await win.setSize(size); - const actual = await win.innerSize(); - lastSizeRef.current = { width: actual.width, height: actual.height }; - } catch { - /* ignore */ - } + await win.setSize(size); + const actual = await win.innerSize(); + lastSizeRef.current = { width: actual.width, height: actual.height }; }, []); const requestLayout = useCallback(() => { diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 3b5e717586..86fbb2f366 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -967,6 +967,7 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(container.querySelector(".tray-panel-reveal--ready")).not.toBeNull(); }); + expect(warn).toHaveBeenCalledWith("CodexBar tray panel resize failed", expect.any(Error)); warn.mockRestore(); }); From c9823ffe4b0d8c581862b0fda1b40790de96f9dc Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:33:58 +0700 Subject: [PATCH 07/10] Trim tray panel comments - Drop comments that restate the code or describe Mac parity. - Name the panel hairline color and the 1 px height slack. - Rename linearChannel to srgbByteToLinear. --- .../src-tauri/src/commands/surface.rs | 3 -- .../src-tauri/src/commands/system.rs | 2 -- .../src-tauri/src/geometry_store.rs | 6 +--- apps/desktop-tauri/src-tauri/src/main.rs | 5 --- apps/desktop-tauri/src-tauri/src/shell/dwm.rs | 15 +++----- .../src-tauri/src/shell/flyout_window.rs | 34 +++---------------- .../src-tauri/src/shell/window.rs | 10 ------ apps/desktop-tauri/src-tauri/src/state.rs | 3 -- apps/desktop-tauri/src-tauri/src/surface.rs | 3 -- .../src-tauri/src/tray_bridge.rs | 3 -- .../src/components/MenuSurface.tsx | 9 ----- .../desktop-tauri/src/hooks/useDraftNumber.ts | 6 ---- .../src/hooks/useTrayPanelController.ts | 8 ----- .../hooks/useTrayPanelLayout.sizing.test.tsx | 6 ---- .../src/hooks/useTrayPanelLayout.ts | 31 ++--------------- apps/desktop-tauri/src/lib/menuSelection.ts | 10 ++---- apps/desktop-tauri/src/lib/tauri.ts | 1 - apps/desktop-tauri/src/lib/trayScale.ts | 2 -- apps/desktop-tauri/src/lib/traySizing.ts | 1 - apps/desktop-tauri/src/styles.css | 19 +---------- .../src/surfaces/TrayPanel.test.tsx | 16 --------- apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 3 -- .../settings/tabs/DisplayTab.test.tsx | 1 - 23 files changed, 16 insertions(+), 181 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/surface.rs b/apps/desktop-tauri/src-tauri/src/commands/surface.rs index 45c54a47d4..2bcc47a0fe 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/surface.rs @@ -28,9 +28,6 @@ pub fn dismiss_tray_panel(app: tauri::AppHandle) -> Result<(), String> { crate::shell::flyout_window::hide(&app) } -/// Arm the gesture blur guard before a drag-reorder gesture starts its -/// OLE modal loop, so the transient -/// `Focused(false)` that loop produces doesn't auto-hide the flyout. #[tauri::command] pub fn begin_flyout_gesture(app: tauri::AppHandle) -> Result<(), String> { let state = app diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 4da4512bb0..26d6a20f9d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -170,8 +170,6 @@ pub fn get_work_area_rect(app: tauri::AppHandle) -> Result // ── Misc UX ──────────────────────────────────────────────────────────── -/// The Windows accent color as `#rrggbb`. The tray panel paints its menu -/// selection with it, the way macOS menus use the system accent. #[tauri::command] pub fn get_system_accent_color() -> Option { system_accent_abgr().map(abgr_to_hex) diff --git a/apps/desktop-tauri/src-tauri/src/geometry_store.rs b/apps/desktop-tauri/src-tauri/src/geometry_store.rs index 2a3bcd3d65..928970bfe7 100644 --- a/apps/desktop-tauri/src-tauri/src/geometry_store.rs +++ b/apps/desktop-tauri/src-tauri/src/geometry_store.rs @@ -1,8 +1,6 @@ //! Persistent window-geometry store for the Tauri desktop shell. //! -//! Remembers position (and size where applicable) for detached user surfaces: -//! PopOut, Settings and the float bar. The tray-panel flyout remembers -//! nothing: it is a fixed panel anchored to the tray on every open. +//! Remembers position (and size where applicable) for detached user surfaces. use std::fs; use std::path::PathBuf; @@ -49,8 +47,6 @@ fn geometry_path() -> Option { /// Surface modes eligible for geometry persistence. /// /// - `Hidden`: never remembered. -/// - `TrayPanel`: never remembered; the flyout is a fixed panel anchored to -/// the tray on every open. /// - `PopOut` / `Settings`: user-movable, position + size remembered. pub fn should_remember(mode: SurfaceMode) -> bool { matches!(mode, SurfaceMode::PopOut | SurfaceMode::Settings) diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index a874c9f406..145962c8c3 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -399,11 +399,6 @@ fn main() { { return; } - // Gesture guard: ignore blur while an HTML5 drag-reorder - // is running its OLE modal loop. - // Windows produces a spurious Focused(false) the instant - // such a loop starts even though the user never left the - // window; see AppState::begin_gesture_blur_guard. if let Some(st) = window.app_handle().try_state::>() && st .lock() diff --git a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs index 9b6ea667dc..7e0561c9fe 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs @@ -168,15 +168,13 @@ unsafe extern "system" fn borderless_subclass_proc( } } -/// The native chrome a borderless window gets from DWM. #[cfg(windows)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Chrome { Dark, /// Keeps `WS_THICKFRAME` so the native resize affordance still works. DarkResizable, - /// The tray flyout: a fixed light panel with Windows 11 rounded corners. - /// Its erase color comes from the builder's `background_color`, which + /// The erase color comes from the builder's `background_color`, which /// tao paints on `WM_ERASEBKGND`. LightPanel, } @@ -190,23 +188,21 @@ const fn colorref(r: u8, g: u8, b: u8) -> u32 { ((b as u32) << 16) | ((g as u32) << 8) | r as u32 } +#[cfg(windows)] +const PANEL_HAIRLINE: u32 = colorref(0x8A, 0x8B, 0x8E); + #[cfg(windows)] impl Chrome { fn keeps_resize_frame(self) -> bool { self == Self::DarkResizable } - /// `DWMWA_WINDOW_CORNER_PREFERENCE`, when the window asks for one. fn corner_preference(self) -> Option { (self == Self::LightPanel).then_some(DWMWCP_ROUND) } - /// `DWMWA_BORDER_COLOR`. DWM draws its border along the rounded corner, - /// where the panel's CSS hairline is clipped, so both use the measured - /// Mac hairline `#8A8B8E`. Without it the dark-mode border would ring - /// the light panel. fn border_color(self) -> Option { - (self == Self::LightPanel).then_some(colorref(0x8A, 0x8B, 0x8E)) + (self == Self::LightPanel).then_some(PANEL_HAIRLINE) } } @@ -224,7 +220,6 @@ pub fn force_dark_caption_resizable(win: &tauri::WebviewWindow) { apply_chrome(win, Chrome::DarkResizable); } -/// Borderless light panel with rounded corners, for the tray flyout only. #[cfg(windows)] pub fn light_panel_chrome(win: &tauri::WebviewWindow) { apply_chrome(win, Chrome::LightPanel); diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs index 6ec518e806..1ca79f05c2 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs @@ -1,20 +1,8 @@ -//! Detached "Pop Out Dashboard" flyout window: a fixed-width light panel, -//! anchored to the tray, with optional always-on-top behavior that auto-hides -//! on click-outside. -//! //! Runs as an auxiliary Tauri window labeled `flyout`, independent of the //! `main` window's surface state machine. It is the only dashboard layout: //! tray left-click, "Pop Out Dashboard", the global shortcut, app launch and //! single-instance relaunch all open it. The legacy PopOut layout on `main` -//! is retired. Like the Mac menu, it always opens next to the tray (or the -//! launch cursor) and can't be moved or resized; the frontend sets its height -//! to fit the content. -//! -//! Structurally modeled on `crate::floatbar` (self-contained module owning -//! its window + a `handle_window_event` hook dispatched from `main.rs` -//! before the `main`-window-only handling); the window itself is built with -//! `settings_window.rs`'s builder recipe (async open, manual DWM chrome -//! pass, `WebviewUrl::App` with a `?window=` query marker). +//! is retired. use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; @@ -29,8 +17,7 @@ use crate::surface::SurfaceMode; pub const FLYOUT_LABEL: &str = "flyout"; -/// The panel fill, `--mac-panel-bg` in `styles.css`. The window paints it -/// before the page loads and on every erase, so the panel never flashes dark. +/// The window paints this before the page loads and on every erase. const PANEL_FILL: tauri::utils::config::Color = tauri::utils::config::Color(0xDE, 0xDE, 0xE2, 0xFF); /// Same window used to close a same-click blur-dismiss/reopen race as the @@ -132,13 +119,11 @@ fn open_with_anchor( .decorations(props.decorations) .shadow(false) .resizable(props.resizable) - // No maximize box, so Windows never snaps or maximizes the panel. .maximizable(false) .always_on_top(settings.tray_panel_always_on_top) .skip_taskbar(props.skip_taskbar) - // WebView2 shares `prefers-color-scheme` across the process, so the - // flyout stays pinned dark like the other windows; the panel's CSS - // draws it light regardless. + // WebView2 shares `prefers-color-scheme` across the profile, so the + // flyout stays pinned dark like the other windows. .theme(Some(tauri::Theme::Dark)) .background_color(PANEL_FILL) // CRITICAL: dynamically-built windows default to drag-drop ENABLED, @@ -340,11 +325,6 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - } } -/// Reposition the flyout at its captured cursor or system-tray anchor, using -/// the window's CURRENT logical size (after a frontend-driven resize). -/// Canonical anchor-math implementation for the flyout window; the -/// `reanchor_tray_panel` Tauri command (`commands/system.rs`) is a thin -/// retarget onto this function. pub fn reanchor(app: &AppHandle) -> Result<(), String> { use crate::window_positioner::{PanelSize, Rect}; @@ -453,12 +433,6 @@ mod tests { #[test] fn tray_panel_window_properties_still_the_single_source_for_flyout_shape() { - // `open_or_focus`'s builder reads size/decorations/resizable/ - // skip_taskbar from - // `SurfaceMode::TrayPanel.window_properties()` directly (not - // independent duplicated constants) — this pins down the values that - // relationship depends on, so a change to `surface.rs` shows up here - // instead of silently drifting from what the flyout actually builds. let props = SurfaceMode::TrayPanel.window_properties(); assert_eq!(props.width, 310.0); assert_eq!(props.height, 776.0); diff --git a/apps/desktop-tauri/src-tauri/src/shell/window.rs b/apps/desktop-tauri/src-tauri/src/shell/window.rs index 8ddf6e876f..0ae1237def 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/window.rs @@ -65,16 +65,6 @@ pub fn apply_window_layout( } if props.visible { - // The flyout ("Pop Out Dashboard") window is sized entirely by the - // frontend (content auto-fit at a fixed width) — - // it is its own dedicated window now, never `main`, so this function - // (which only ever runs against `main`'s window; see callers in - // `shell/transition.rs`) needs no special-case for it. `main`'s - // surface machine only ever holds Hidden/PopOut/Settings, none of - // which race a frontend-owned size the way the old shared-window - // TrayPanel mode did — so the backend is the sole size-setter here, - // unconditionally, for every mode this function can be called with. - // // The canonical surface mode drives geometry restore — no brittle // shape-matching of WindowProperties. logical_size_from_geometry // falls back to the mode's default size for non-remembered modes. diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 2a9f39c1f2..76e0c926d4 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -276,9 +276,6 @@ impl AppState { self.flyout_reveal_pending.take() } - /// Arm the gesture blur guard for 15s. Called when the frontend reports - /// a drag-reorder mousedown is about to start an OLE modal loop that will - /// transiently blur the window. pub fn begin_gesture_blur_guard(&mut self, now: std::time::Instant) { self.gesture_blur_guard = Some((now, now + std::time::Duration::from_secs(15))); } diff --git a/apps/desktop-tauri/src-tauri/src/surface.rs b/apps/desktop-tauri/src-tauri/src/surface.rs index bb413a4e35..9395af04ba 100644 --- a/apps/desktop-tauri/src-tauri/src/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/surface.rs @@ -50,8 +50,6 @@ impl SurfaceMode { // tray icon, auto-hides on click-outside (blur), and never shows in // the taskbar. Its optional always-on-top setting is applied by the // dedicated flyout window, which can read persisted settings. - // Fixed 310px wide like the Mac menu; the frontend sizes the - // height to its content and scales both by "Panel scale". Self::TrayPanel => WindowProperties { visible: true, decorations: false, @@ -277,7 +275,6 @@ mod tests { #[test] fn tray_panel_is_fixed_size_blur_dismiss_flyout() { let props = SurfaceMode::TrayPanel.window_properties(); - // "Pop Out Dashboard" flyout: fixed size, anchored, auto-hide, no taskbar. assert!(!props.resizable); assert!(props.blur_dismiss); assert!(!props.always_on_top); diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index bca684acbf..c1a50e9ac1 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -599,9 +599,6 @@ mod tests { let action = resolve_menu_action("pop_out").expect("pop_out action"); assert!(matches!(action, MenuAction::OpenFlyout)); - // SurfaceMode::TrayPanel is retained purely as a data key (geometry - // key / window_properties source / panel-size reference) for the - // flyout window's builder. The panel is a fixed-width menu. let props = SurfaceMode::TrayPanel.window_properties(); assert!(!props.resizable && props.blur_dismiss && props.skip_taskbar); } diff --git a/apps/desktop-tauri/src/components/MenuSurface.tsx b/apps/desktop-tauri/src/components/MenuSurface.tsx index 9ec13766ab..256bb67ba8 100644 --- a/apps/desktop-tauri/src/components/MenuSurface.tsx +++ b/apps/desktop-tauri/src/components/MenuSurface.tsx @@ -32,9 +32,6 @@ function lineIcon(...paths: ReactNode[]) { ); } -/** 16px line icons after the SF Symbols the macOS menu uses - * (key, chart.xyaxis.line, waveform.path.ecg, gearshape, info.circle, - * xmark.rectangle). Refresh has no icon on the Mac. */ const ROW_ICONS: Record = { switchAccount: lineIcon( , @@ -67,8 +64,6 @@ const ROW_ICONS: Record = { interface MenuSurfaceProps { summary?: ReactNode; banner?: ReactNode; - /** Footer rows in groups. A separator is drawn before each non-empty - * group, like the separators between the macOS menu sections. */ footerGroups?: MenuFooterRow[][]; /** Inline style applied to the root `menu-surface` element (e.g. CSS * `zoom` for the tray flyout). */ @@ -79,10 +74,6 @@ interface MenuSurfaceProps { /** * Flush, compact container for the tray panel (`TrayPanel`), the only * dashboard layout. It renders in the tray-panel flyout window. - * - * Mirrors the upstream macOS `MenuContent`: a 310pt menu panel holding a - * stack of full provider cards (`MenuCard`), one per enabled provider, - * followed by the menu rows. */ export default function MenuSurface({ summary, diff --git a/apps/desktop-tauri/src/hooks/useDraftNumber.ts b/apps/desktop-tauri/src/hooks/useDraftNumber.ts index 983d08aabb..bc8406ce75 100644 --- a/apps/desktop-tauri/src/hooks/useDraftNumber.ts +++ b/apps/desktop-tauri/src/hooks/useDraftNumber.ts @@ -1,6 +1,5 @@ import { useCallback, useState } from "react"; -/** Local slider value that follows the saved setting and commits on release. */ export function useDraftNumber(value: number) { const [draft, setDraft] = useState(value); const [prev, setPrev] = useState(value); @@ -11,11 +10,6 @@ export function useDraftNumber(value: number) { const commit = useCallback( (next: number, onCommit: (value: number) => void) => { - // Dedupe against the committed prop value, which is the persisted - // source of truth. The parent's save is fire-and-forget, so we can't - // observe success/failure here — comparing to `value` (rather than an - // optimistically-advanced marker) means a failed save leaves the prop - // unchanged and a re-commit of the same number still fires the retry. if (next === value) return; onCommit(next); }, diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index 66e79b5997..1a69719eac 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -33,7 +33,6 @@ import { const TRAY_INITIAL_REFRESH_DELAY_MS = 250; const DENSE_OVERVIEW_THRESHOLD = 32; -/** Which of a provider's web pages the backend can open. */ interface ProviderLinks { providerId: string; dashboard: boolean; @@ -63,7 +62,6 @@ export function useTrayPanelController(state: BootstrapState) { const { t } = useLocale(); const surfaceTarget = useSurfaceTarget("trayPanel"); - // Settings > Menu > Panel scale, applied as CSS zoom on the panel. const trayScale = clampTrayScalePercent(settings.trayScalePercent) / 100; const [appVersion, setAppVersion] = useState(null); @@ -80,8 +78,6 @@ export function useTrayPanelController(state: BootstrapState) { }; }, []); - // The hidden panel window lives for the whole app run, so a focus re-read - // is what picks up an accent changed in Windows settings. const [accentColor, setAccentColor] = useState(null); useEffect(() => { let cancelled = false; @@ -169,9 +165,6 @@ export function useTrayPanelController(state: BootstrapState) { return [match]; }, [denseTrayProviders, sorted, selectedProviderId, gridExpanded]); - // The account and link rows act on the selected provider, or on the only - // one: macOS shows them on a provider tab, and with a single provider there - // is no overview tab. const actionProvider = selectedProviderId !== null ? (providersById.get(selectedProviderId) ?? null) @@ -198,7 +191,6 @@ export function useTrayPanelController(state: BootstrapState) { cancelled = true; }; }, [actionProviderId]); - // Links resolved for a previously selected provider never show. const links = providerLinks !== null && providerLinks.providerId === actionProviderId ? providerLinks diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx index 2ac0eadd54..21dc7b133d 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx @@ -81,8 +81,6 @@ function mountSurface(): void { surface = document.querySelector(".menu-surface--tray")!; } -/** Drive the auto-fit measure: jsdom rects are 0, so scrollHeight dominates → - * contentHeight = scrollHeight + 1 (measure pipeline, zoom=1). */ function setScrollHeight(px: number): void { Object.defineProperty(surface, "scrollHeight", { configurable: true, @@ -227,8 +225,6 @@ describe("useTrayPanelLayout sizing", () => { const lockedResizes = windowMocks.setSize.mock.calls.length; const lockedAnchors = tauriMocks.reanchorTrayPanel.mock.calls.length; - // Flip-down evidence (measure 542→543): detected, suppressed, and the DOM - // constraint stays the RETAINED height — never the smaller candidate. await nudgePass(result, 542, "549px"); expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors); @@ -309,8 +305,6 @@ describe("useTrayPanelLayout sizing", () => { it("sizes the window at 310 px times the Panel scale and leaves the spare height above the footer out", async () => { const body = surface.querySelector(".menu-surface__body")!; const footer = surface.querySelector(".menu-surface__footer")!; - // WebView2 at zoom 1.5: rects are rendered px, scrollHeight is local px, - // and the filled surface parks 201 px of spare height above the footer. surface.getBoundingClientRect = () => ({ top: 0, bottom: 800, height: 800 }) as DOMRect; body.getBoundingClientRect = () => diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts index f3231b098c..afbe9f7d9b 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts @@ -12,20 +12,18 @@ import { type TrayAutoFitState, } from "../lib/traySizing"; -/** The macOS 0.70 menu width (tray-card-spec.md section 1) at 100% Panel scale. */ const TRAY_BASE_WIDTH = 310; const TRAY_MAX_MEASURE_HEIGHT = 920; const TRAY_OVERVIEW_MIN_HEIGHT = 200; const TRAY_DETAIL_MIN_HEIGHT = 420; const TRAY_DENSE_OVERVIEW_HEIGHT = 776; +const TRAY_HEIGHT_SLACK = 1; export interface TrayPanelLayoutOptions { canMeasure: boolean; denseOverview: boolean; detailMode: boolean; layoutKey: string; - /** Panel scale as a factor: the CSS `zoom` TrayPanel puts on the surface. - * The window width and height follow it. Defaults to 1. */ zoom?: number; } @@ -34,10 +32,6 @@ export interface TrayPanelLayout { requestLayout: () => void; } -/** - * Fits the tray flyout window to its content: a fixed width of 310 px times - * the Panel scale, and a height measured from the rendered surface. - */ export function useTrayPanelLayout({ canMeasure, denseOverview, @@ -50,12 +44,8 @@ export function useTrayPanelLayout({ const layoutReadyRef = useRef(false); const resizeRunRef = useRef(0); const layoutTimerRef = useRef(undefined); - // The window's actual physical size after our last resize. Win32 may snap - // a requested size, and lib/traySizing compares candidates against it. const lastSizeRef = useRef<{ width: number; height: number } | null>(null); const programmaticInFlightRef = useRef(0); - // Sizing decision state (committed frame, one-frame history, learned - // oscillation pair). The #261 cycle detection lives in lib/traySizing. const sizingStateRef = useRef(EMPTY_AUTOFIT_STATE); const applySize = useCallback(async (size: LogicalSize): Promise => { @@ -82,10 +72,6 @@ export function useTrayPanelLayout({ const surface = document.querySelector(".menu-surface--tray"); if (!surface || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { - // Measuring temporarily removes the surface/body constraints, which - // resizes the observed elements. Do not feed that programmatic change - // back into another pass or the capped flyout flashes between its - // measured and committed layouts forever. if ( layoutReadyRef.current && programmaticInFlightRef.current > 0 @@ -94,8 +80,6 @@ export function useTrayPanelLayout({ } requestLayout(); }); - // The surface fills the window, so content changes show up on the body - // and the footer rather than on the surface itself. for (const part of [ surface, surface.querySelector(".menu-surface__body"), @@ -182,12 +166,8 @@ export function useTrayPanelLayout({ } }; - // Every resize this pass causes, and any that arrive shortly after, - // must not start another pass. The trailing delay absorbs late events. programmaticInFlightRef.current += 1; try { - // Text wraps at the window width, so the width has to be in place - // before the height is measured. const committed = sizingStateRef.current.committed; if (!layoutReadyRef.current || committed?.width !== width) { const height = @@ -208,9 +188,7 @@ export function useTrayPanelLayout({ // WebView2 reports bounding rects in rendered px but scrollHeight in // the zoomed surface's local px (measured on Edge 154), so the local - // value is scaled before the two are compared. The surface fills the - // window and parks spare height above the footer; that gap is not - // content. The extra 1 px absorbs DPI rounding of the window height. + // value is scaled before the two are compared. const surfaceRect = surface.getBoundingClientRect(); const bodyRect = body?.getBoundingClientRect(); const footerRect = footer?.getBoundingClientRect(); @@ -223,7 +201,7 @@ export function useTrayPanelLayout({ surfaceRect.bottom, ); const height = Math.min( - Math.max(Math.ceil(contentBottom - spare) + 1, minHeight), + Math.max(Math.ceil(contentBottom - spare) + TRAY_HEIGHT_SLACK, minHeight), maxHeight, ); @@ -239,8 +217,6 @@ export function useTrayPanelLayout({ expectedWidth: width, minHeight, maxHeight, - // WebView layout px ↔ Win32 physical px ratio; CSS zoom does not - // affect it. scaleFactor: window.devicePixelRatio, zoom, lastAppliedPhysicalHeight: lastSizeRef.current?.height ?? null, @@ -248,7 +224,6 @@ export function useTrayPanelLayout({ sizingStateRef.current, ); sizingStateRef.current = decision.state; - // A zoomed element's max-height renders at value × zoom. surface.style.maxHeight = `${decision.height / zoom}px`; committedHeight = true; diff --git a/apps/desktop-tauri/src/lib/menuSelection.ts b/apps/desktop-tauri/src/lib/menuSelection.ts index ab430ef33d..4e8d4586ca 100644 --- a/apps/desktop-tauri/src/lib/menuSelection.ts +++ b/apps/desktop-tauri/src/lib/menuSelection.ts @@ -2,21 +2,15 @@ import type { CSSProperties } from "react"; const HEX_COLOR = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i; -/** WCAG 2 linear value of one sRGB channel byte. */ -function linearChannel(byte: number): number { +function srgbByteToLinear(byte: number): number { const c = byte / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; } -/** - * Menu selection colors from the Windows accent, the way macOS menus use the - * system accent. White text stays only where it keeps 4.5:1 contrast. Where - * white fails, black clears 4.67:1 on any accent. - */ export function accentSelectionStyle(accent: string | null): CSSProperties { const match = accent ? HEX_COLOR.exec(accent) : null; if (!match) return {}; - const [r, g, b] = match.slice(1).map((hex) => linearChannel(parseInt(hex, 16))); + const [r, g, b] = match.slice(1).map((hex) => srgbByteToLinear(parseInt(hex, 16))); const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; const whiteContrast = 1.05 / (luminance + 0.05); return { diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index a843d6e9e0..67e5f1bf88 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -550,7 +550,6 @@ export function revealTrayPanelWindow(): Promise { return invoke("reveal_tray_panel_window"); } -/** The Windows accent color as `#rrggbb`, or null when Windows has none. */ export function getSystemAccentColor(): Promise { return invoke("get_system_accent_color"); } diff --git a/apps/desktop-tauri/src/lib/trayScale.ts b/apps/desktop-tauri/src/lib/trayScale.ts index 2936c3d2bb..ef8878157e 100644 --- a/apps/desktop-tauri/src/lib/trayScale.ts +++ b/apps/desktop-tauri/src/lib/trayScale.ts @@ -1,5 +1,3 @@ -/** Panel scale bounds in percent. The backend clamps `trayScalePercent` to - * the same range when it loads or saves settings. */ export const TRAY_SCALE_MIN = 100; export const TRAY_SCALE_MAX = 200; export const TRAY_SCALE_STEP = 5; diff --git a/apps/desktop-tauri/src/lib/traySizing.ts b/apps/desktop-tauri/src/lib/traySizing.ts index cf53edcc71..136800e523 100644 --- a/apps/desktop-tauri/src/lib/traySizing.ts +++ b/apps/desktop-tauri/src/lib/traySizing.ts @@ -74,7 +74,6 @@ export const EMPTY_AUTOFIT_STATE: TrayAutoFitState = { export interface TraySizingInput { /** Measured content height, logical px, post zoom-scale, pre clamp. */ measuredHeight: number; - /** Logical window width: 310 px times the Panel scale. */ expectedWidth: number; minHeight: number; maxHeight: number; diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 173ff27a6e..6f78479c4e 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -1046,9 +1046,7 @@ html { /* Hide the panel during the initial resize + reposition dance so the user never sees the window jump around. Once layout is measured and the window is anchored in its final position, .tray-panel-reveal--ready - fades the content in smoothly. The page behind the panel carries the - panel fill (--mac-panel-bg, and PANEL_FILL in shell/flyout_window.rs) so - no other color shows while the panel is hidden. */ + fades the content in smoothly. */ :root { --mac-panel-bg: #dedee2; } @@ -1067,9 +1065,6 @@ body:has(.tray-panel-reveal), opacity: 1; transition: opacity 80ms ease-out; } -/* The window keeps a height floor, so it can be taller than the content. - The panel fills the window and parks the spare height above the footer, - which keeps the footer rows next to the tray. */ .tray-panel-reveal .menu-surface--tray { flex-grow: 1; } @@ -3383,10 +3378,6 @@ html:has(.menu-surface--tray) { overflow: visible; } -/* Tray panel: a fixed light panel after the macOS 0.70 menu material - (tray-card-spec.md section 1) under every app theme. The wrapper sets - data-theme="light"; tokens that only the dark theme block defines get - tray values here so theme dark, light and auto render the same panel. */ .menu-surface--tray { --mac-font: "Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI", system-ui, sans-serif; --mac-text-headline: 600 13px/16px var(--mac-font); @@ -3412,7 +3403,6 @@ html:has(.menu-surface--tray) { --mac-pace-bad: #ff3b30; --mac-marker: rgba(0, 0, 0, 0.58); --mac-accent: #007aff; - /* TrayPanel overrides this inline with the Windows accent color. */ --mac-selection-bg: #0078d4; --mac-selection-text: #fff; @@ -3457,8 +3447,6 @@ html:has(.menu-surface--tray) { --mac-menu-row-h: 22px; --mac-row-pl: 15px; --mac-row-icon: 16px; - /* The Mac code says 4.5pt, but the 0.70 shots put footer labels 38px from - the panel edge, which a 6px gap after the 16px icon reproduces. */ --mac-row-icon-gap: 6px; --mac-shortcut-w: 44px; @@ -3536,14 +3524,11 @@ html:has(.menu-surface--tray) { box-sizing: border-box; overflow: visible; } -/* Selected card: no highlight plate, like the macOS menu card. */ .menu-surface--tray .menu-stack__item--selected { border: none; border-radius: 6px; padding: 4px 4px; } -/* The panel is light under every app theme. These base rules paint white - tints meant for a dark surface, so the tray gets dark tints instead. */ .menu-surface--tray .menu-card__copy-btn:hover { background: rgba(0, 0, 0, 0.06); } @@ -4529,8 +4514,6 @@ html:has(.menu-surface--tray) { font-size: 13px; } -/* Footer rows follow the macOS menu: 22px rows (24px for Refresh), a 16px - icon column, a 44px shortcut column, and an inset selection plate. */ .menu-surface__footer { display: flex; flex-direction: column; diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 86fbb2f366..19d69ad9a8 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -231,7 +231,6 @@ function renderTrayPanel( ); } -/** The pages `get_provider_detail` reports for a provider with both links. */ function providerLinks(id: string) { return { dashboardUrl: `https://${id}.example/usage`, @@ -338,12 +337,6 @@ describe("TrayPanel provider grid", () => { }); it("reveals regardless of the shared surface-mode snapshot (TrayPanel now runs in its own dedicated window)", async () => { - // TrayPanel is now hosted exclusively in the dedicated `flyout` OS - // window (see App.tsx's isFlyoutWindow() routing), so it must not depend - // on `main`'s surface-mode machine to know it's "open" — that machine - // can report something other than "trayPanel". Overriding the snapshot - // mock to another mode confirms the reveal gate is not wired to - // useSurfaceMode() at all. tauriMocks.getCurrentSurfaceState.mockResolvedValue({ mode: "settings", target: { kind: "settings", tab: "general" }, @@ -474,7 +467,6 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(container.querySelector(".tray-panel-reveal--ready")).not.toBeNull(); }); - // The overview of several providers has no provider to act on. expect(screen.queryByRole("button", { name: "Usage Dashboard" })).toBeNull(); expect(container.querySelectorAll(".menu-surface__footer-sep")).toHaveLength(1); @@ -881,10 +873,6 @@ describe("TrayPanel provider grid", () => { }); it("renders the default tray panel layout with no legacy window chrome", async () => { - // Pins the one dashboard layout: tray-variant surface, icon-first - // provider switcher, and the Refresh / Settings... / About / Quit rows - // with their Ctrl shortcuts. The retired PopOut layout had a "CodexBar" - // title bar with window controls. const { container } = renderTrayPanel([ provider("claude", "Claude", 35), provider("codex", "Codex", 20), @@ -1072,8 +1060,6 @@ describe("TrayPanel provider grid", () => { trayScalePercent: 150, }); - // Width 310 × 1.5 = 465. Height 505 × 1.5 = 757.5, rounded up to 758, - // plus 1 px for DPI rounding = 759. await waitFor(() => { expect(setSize).toHaveBeenCalledWith( expect.objectContaining({ width: 465, height: 759 }), @@ -1082,8 +1068,6 @@ describe("TrayPanel provider grid", () => { first.unmount(); setSize.mockClear(); - // Same zoom, taller content: 700 × 1.5 + 1 = 1051 exceeds the mocked - // work-area cap (900 - 16 = 884), so the clamp still wins. scrollHeight.mockReturnValue(700); renderTrayPanel([provider("codex", "Codex", 61)], { trayScalePercent: 150, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 013376b486..d9888e2ced 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -19,9 +19,6 @@ import { filterUsageSpendSummaryForOverview } from "../lib/usageSpendSharing"; * Tray popover surface — two modes like macOS CodexBar: * 1. Overview (default): provider grid + all cards stacked * 2. Detail: click a provider in grid → show only that provider's card - * - * The panel is a light menu under every app theme, like the macOS menu, so - * the wrappers pin `data-theme="light"`. */ export default function TrayPanel({ state }: { state: BootstrapState }) { const stayAwakeHeld = useStayAwakeStatus(); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx index 679ded5d32..8cff53314d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx @@ -60,7 +60,6 @@ describe("DisplayTab menu settings", () => { it("shows the saved Panel scale and saves the new one when the slider is released", async () => { const set = vi.fn(); - // Settles the tab's tray-visibility read before the slider re-renders it. await act(async () => { render( Date: Sat, 10 Oct 2026 20:18:52 +0700 Subject: [PATCH 08/10] Test that a failed Panel scale save reports and reverts --- apps/desktop-tauri/src/hooks/useSettings.test.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/desktop-tauri/src/hooks/useSettings.test.tsx b/apps/desktop-tauri/src/hooks/useSettings.test.tsx index aa79b350c1..dd9afb1664 100644 --- a/apps/desktop-tauri/src/hooks/useSettings.test.tsx +++ b/apps/desktop-tauri/src/hooks/useSettings.test.tsx @@ -222,6 +222,21 @@ describe("useSettings update", () => { expect(result.current.settings.windowScalePercent).toBe(150); }); + it("reports a failed Panel scale save and reverts to the saved value", async () => { + const onDisk = { trayScalePercent: 100 } as unknown as SettingsSnapshot; + tauriMocks.getSettingsSnapshot.mockResolvedValue(onDisk); + tauriMocks.updateSettings.mockRejectedValueOnce(new Error("disk busy")); + const { result } = renderHook(() => useSettings(onDisk)); + await act(async () => {}); + + await act(async () => { + await result.current.update({ trayScalePercent: 150 }); + }); + + expect(result.current.error).toBe("disk busy"); + expect(result.current.settings.trayScalePercent).toBe(100); + }); + it("keeps the resolved shortcut map until the response supplies it", async () => { tauriMocks.updateSettings.mockReturnValueOnce(new Promise(() => {})); const initial = { From a372eafbe4698f8a7738fac3b275bd24914efcb3 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:18:52 +0700 Subject: [PATCH 09/10] Drop stale resize and stored-size wording from flyout docs --- apps/desktop-tauri/src-tauri/src/commands/surface.rs | 5 ++--- apps/desktop-tauri/src-tauri/src/state.rs | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/surface.rs b/apps/desktop-tauri/src-tauri/src/commands/surface.rs index 2bcc47a0fe..65c5b8ff1b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/surface.rs @@ -75,9 +75,8 @@ pub async fn open_flyout_window(app: tauri::AppHandle) -> Result<(), String> { } /// Reveal the flyout window after the frontend's first layout pass. Called by -/// `useTrayPanelLayout` once content has been measured/auto-fit (or the -/// remembered fixed size re-applied), so Windows never shows a pre-measure -/// blank/backing frame. +/// `useTrayPanelLayout` once content has been measured and auto-fit, so +/// Windows never shows a pre-measure blank/backing frame. /// /// No-ops when the flyout window doesn't exist or no one-shot reveal is pending. /// The window takes focus only as far as the pending reveal's activation diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 76e0c926d4..32e438e520 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -167,7 +167,7 @@ pub struct AppState { /// One-shot permission for frontend layout code to reveal a newly opened /// flyout, carrying whether the revealed window may take focus. pub flyout_reveal_pending: Option, - /// Active while a user gesture (resize drag, HTML5 drag-reorder) is + /// Active while a user gesture (the provider tab HTML5 drag-reorder) is /// running a Win32 modal loop that transiently steals focus from the /// WebView2 child. `(began, until)` — `until` is the hard expiry; /// `began` lets a genuine refocus clear the guard early once the From c9d3683b1a9728c16d466eed00c1ec31ff981cde Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:48:37 +0700 Subject: [PATCH 10/10] Keep tray auto-fit passes from restoring each other's overrides --- .../hooks/useTrayPanelLayout.sizing.test.tsx | 29 +++++++++++++++++++ .../src/hooks/useTrayPanelLayout.ts | 27 +++++++++++++---- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx index 21dc7b133d..f4578071b9 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx @@ -200,6 +200,35 @@ describe("useTrayPanelLayout sizing", () => { ); }); + it("leaves no measuring overrides behind when style feedback overlaps the first pass", async () => { + vi.stubGlobal("ResizeObserver", StyleFeedbackResizeObserver); + setScrollHeight(1_200); + const html = document.documentElement; + const body = surface.querySelector(".menu-surface__body")!; + const stack = surface.querySelector(".menu-stack")!; + + const { result } = renderHook(() => useTrayPanelLayout(hookProps())); + await waitFor(() => expect(result.current.layoutReady).toBe(true), { + timeout: 3000, + }); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 1_000)); + }); + + expect(feedbackObserverCallbacks).toBeGreaterThan(0); + expect(lastResize()).toEqual({ width: 310, height: 884 }); + expect(surface.style.maxHeight).toBe("884px"); + expect(surface.style.minHeight).toBe(""); + expect(surface.style.height).toBe(""); + expect(surface.style.overflow).toBe(""); + expect(body.style.overflow).toBe(""); + expect(body.style.flex).toBe(""); + expect(stack.style.overflow).toBe(""); + expect(document.body.style.overflow).toBe(""); + expect(document.body.style.minHeight).toBe(""); + expect(html.style.overflow).toBe(""); + }); + it("commits stable small changes, locks the reporter pair on the larger member, tracks retained height in the DOM", async () => { setScrollHeight(538); // → 539 logical → 674 physical const { result } = renderHook(() => useTrayPanelLayout(hookProps())); diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts index afbe9f7d9b..7267f83357 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts @@ -43,6 +43,7 @@ export function useTrayPanelLayout({ const [layoutRevision, setLayoutRevision] = useState(0); const layoutReadyRef = useRef(false); const resizeRunRef = useRef(0); + const activeResizeRef = useRef>(Promise.resolve()); const layoutTimerRef = useRef(undefined); const lastSizeRef = useRef<{ width: number; height: number } | null>(null); const programmaticInFlightRef = useRef(0); @@ -72,12 +73,9 @@ export function useTrayPanelLayout({ const surface = document.querySelector(".menu-surface--tray"); if (!surface || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { - if ( - layoutReadyRef.current && - programmaticInFlightRef.current > 0 - ) { - return; - } + // Measuring lifts the surface/body constraints, which resizes the + // observed parts. Feeding that back would restart the pass forever. + if (programmaticInFlightRef.current > 0) return; requestLayout(); }); for (const part of [ @@ -110,6 +108,23 @@ export function useTrayPanelLayout({ const resize = async () => { const run = ++resizeRunRef.current; + // A superseded pass can still be suspended with its measuring + // overrides applied; wait for it so `previous` below is never those. + const prior = activeResizeRef.current; + let finished!: () => void; + activeResizeRef.current = new Promise((resolve) => { + finished = resolve; + }); + try { + await prior; + if (run !== resizeRunRef.current) return; + await measure(run); + } finally { + finished(); + } + }; + + const measure = async (run: number) => { const surface = document.querySelector(".menu-surface--tray"); if (!surface) return; const html = document.documentElement;