From 58d72bea8715b8acf735703ab0d1c90172a84c58 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 1 Apr 2026 16:00:33 +0700 Subject: [PATCH] refactor: migrate to VS Code titlebar drag region pattern --- packages/app/src/app/_layout.tsx | 12 +- .../desktop/titlebar-drag-region.tsx | 228 +++++++++ .../src/components/headers/screen-header.tsx | 14 +- packages/app/src/components/left-sidebar.tsx | 18 +- packages/app/src/desktop/electron/window.ts | 14 +- packages/app/src/desktop/host.ts | 11 +- .../src/screens/agent/draft-agent-screen.tsx | 11 +- .../app/src/screens/open-project-screen.tsx | 41 +- .../app/src/screens/startup-splash-screen.tsx | 55 ++- .../desktop-window-drag-coordinates.test.ts | 28 -- .../utils/desktop-window-drag-coordinates.ts | 27 - packages/app/src/utils/desktop-window.test.ts | 40 -- packages/app/src/utils/desktop-window.ts | 117 +---- .../desktop/scripts/verify-electron-cdp.mjs | 464 ++++++++++++++---- packages/desktop/src/main.ts | 15 +- packages/desktop/src/preload.ts | 12 +- .../window/window-drag-coordinates.test.ts | 73 --- .../src/window/window-drag-coordinates.ts | 64 --- .../desktop/src/window/window-manager.test.ts | 141 +++++- packages/desktop/src/window/window-manager.ts | 188 ++++--- 20 files changed, 963 insertions(+), 610 deletions(-) create mode 100644 packages/app/src/components/desktop/titlebar-drag-region.tsx delete mode 100644 packages/app/src/utils/desktop-window-drag-coordinates.test.ts delete mode 100644 packages/app/src/utils/desktop-window-drag-coordinates.ts delete mode 100644 packages/app/src/utils/desktop-window.test.ts delete mode 100644 packages/desktop/src/window/window-drag-coordinates.test.ts delete mode 100644 packages/desktop/src/window/window-drag-coordinates.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 953dd493e..d75ad1550 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -68,7 +68,7 @@ import { ensureOsNotificationPermission, } from "@/utils/os-notifications"; import { getDesktopHost } from "@/desktop/host"; -import { setDesktopTitleBarTheme } from "@/desktop/electron/window"; +import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { buildNotificationRoute } from "@/utils/notification-routing"; import { buildHostRootRoute, @@ -476,6 +476,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { const { settings, isLoading: settingsLoading } = useAppSettings(); const { upsertConnectionFromOfferUrl } = useHostMutations(); const systemColorScheme = useColorScheme(); + const { theme } = useUnistyles(); const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme; // Apply theme setting on mount and when it changes @@ -494,10 +495,13 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { return; } - void setDesktopTitleBarTheme(resolvedTheme).catch((error) => { - console.warn("[DesktopWindow] Failed to update title bar theme", error); + void updateDesktopWindowControls({ + backgroundColor: theme.colors.surface0, + foregroundColor: theme.colors.foreground, + }).catch((error) => { + console.warn("[DesktopWindow] Failed to update window controls overlay", error); }); - }, [settingsLoading, resolvedTheme]); + }, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]); return ( diff --git a/packages/app/src/components/desktop/titlebar-drag-region.tsx b/packages/app/src/components/desktop/titlebar-drag-region.tsx new file mode 100644 index 000000000..d930f7ab3 --- /dev/null +++ b/packages/app/src/components/desktop/titlebar-drag-region.tsx @@ -0,0 +1,228 @@ +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; +import { Platform } from "react-native"; +import { getIsElectronRuntime } from "@/constants/layout"; +import { getDesktopWindow, updateDesktopWindowControls } from "@/desktop/electron/window"; + +const DEFAULT_OVERLAY_HEIGHT = 29; +const MAX_EFFECTIVE_TITLEBAR_HEIGHT = 48; + +/** + * VS Code-style titlebar drag region for Electron on Windows/Linux. + * + * Architecture copied from VS Code at commit daa0a70: + * - titlebarPart.ts:463-464 → dedicated absolute drag overlay element + * - titlebarpart.css:57-64 → drag region styles + * - titlebarpart.css:249-260 → top-edge resizer with no-drag, hidden in fullscreen + * - electron-browser/titlebarPart.ts:205-206 → resizer appended to root container + * + * This component renders two elements: + * 1. A full-size absolute drag overlay with -webkit-app-region: drag + * 2. A 4px top-edge resizer with -webkit-app-region: no-drag (hidden in fullscreen) + * + * Both are positioned absolute within the parent container. Interactive children + * of the parent sit above the drag overlay via normal stacking order. + * The global no-drag reset in index.html remains only as a defensive backstop. + */ + +function useIsFullscreen(): boolean { + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + if (Platform.OS !== "web" || !getIsElectronRuntime()) return; + + let disposed = false; + let cleanup: (() => void) | undefined; + + async function setup() { + const win = getDesktopWindow(); + if (!win) return; + + const fs = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false; + if (disposed) return; + setIsFullscreen(fs); + + if (typeof win.onResized !== "function") return; + + const unlisten = await win.onResized(async () => { + if (disposed) return; + const fullscreen = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false; + if (disposed) return; + setIsFullscreen(fullscreen); + }); + + cleanup = unlisten; + if (disposed) cleanup?.(); + } + + void setup(); + return () => { + disposed = true; + cleanup?.(); + }; + }, []); + + return isFullscreen; +} + +/** + * Renders the VS Code-style drag overlay and top-edge resizer. + * Only renders on Electron Windows/Linux (non-mac). Returns null otherwise. + * + * Usage: Place as a child of any positioned container that should be draggable. + * Interactive elements in the same container will sit above the drag overlay + * via normal DOM stacking order (later siblings paint over earlier ones). + * Place TitlebarDragRegion BEFORE interactive children in JSX. + */ +export function TitlebarDragRegion() { + if (Platform.OS !== "web" || !getIsElectronRuntime()) { + return null; + } + + return ; +} + +/** + * Explicit no-drag content layer above the drag overlay. + * Matches VS Code's pattern of z-index: 2500 + -webkit-app-region: no-drag + * on interactive containers within the titlebar (titlebarpart.css:141-143, + * 231-233, 264-268, 288-298, 382-401). + * + * Wrap interactive children of any TitlebarDragRegion parent in this component + * so the no-drag layering is structural, not dependent on the global CSS backstop. + * On non-Electron or mac, renders children directly with no wrapper. + */ +interface TitlebarNoDragContentProps { + children: ReactNode; + /** Layout direction for the no-drag wrapper. Default: "row" for header-style surfaces. */ + direction?: "row" | "column"; +} + +export function TitlebarNoDragContent({ children, direction = "row" }: TitlebarNoDragContentProps) { + if (Platform.OS !== "web" || !getIsElectronRuntime()) { + return <>{children}; + } + + return ( +
+ {children} +
+ ); +} + +function TitlebarDragRegionInner() { + const isFullscreen = useIsFullscreen(); + const dragRegionRef = useRef(null); + const lastReportedHeightRef = useRef(null); + + useEffect(() => { + const dragRegion = dragRegionRef.current; + const parent = dragRegion?.parentElement; + if (!dragRegion || !parent) { + return; + } + + let disposed = false; + let frameId: number | null = null; + + const reportHeight = () => { + if (disposed) { + return; + } + + const nextHeight = Math.min( + MAX_EFFECTIVE_TITLEBAR_HEIGHT, + Math.max(DEFAULT_OVERLAY_HEIGHT, Math.round(parent.getBoundingClientRect().height)), + ); + + if (lastReportedHeightRef.current === nextHeight) { + return; + } + + lastReportedHeightRef.current = nextHeight; + void updateDesktopWindowControls({ height: nextHeight }).catch((error) => { + console.warn("[DesktopWindow] Failed to update window controls height", error); + }); + }; + + const scheduleReport = () => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + frameId = requestAnimationFrame(() => { + frameId = null; + reportHeight(); + }); + }; + + scheduleReport(); + + const resizeObserver = + typeof ResizeObserver === "function" + ? new ResizeObserver(() => { + scheduleReport(); + }) + : null; + + resizeObserver?.observe(parent); + window.addEventListener("resize", scheduleReport); + + return () => { + disposed = true; + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + resizeObserver?.disconnect(); + window.removeEventListener("resize", scheduleReport); + }; + }, []); + + return ( + <> + {/* Drag overlay — equivalent to VS Code .titlebar-drag-region */} +
+ {/* Top-edge resizer — equivalent to VS Code .resizer */} + {!isFullscreen && ( +
+ )} + + ); +} diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index e3ec9d216..9aa7c2e0e 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -8,7 +8,8 @@ import { HEADER_TOP_PADDING_MOBILE, isCompactFormFactor, } from "@/constants/layout"; -import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window"; +import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region"; interface ScreenHeaderProps { left?: ReactNode; @@ -31,8 +32,6 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const baseHorizontalPadding = theme.spacing[2]; - const dragHandlers = useDesktopDragHandlers(); - return ( @@ -45,10 +44,12 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: }, borderless && styles.borderless, ]} - {...dragHandlers} > - {left} - {right} + + + {left} + {right} + @@ -61,6 +62,7 @@ const styles = StyleSheet.create((theme) => ({ }, inner: {}, row: { + position: "relative", height: { xs: HEADER_INNER_HEIGHT_MOBILE, md: HEADER_INNER_HEIGHT, diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 246a1bd1a..e62e6085e 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -35,7 +35,8 @@ import { type SidebarProjectEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; -import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window"; +import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region"; import { Combobox } from "@/components/ui/combobox"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { formatConnectionStatus } from "@/utils/daemons"; @@ -638,7 +639,6 @@ function DesktopSidebar({ handleViewMore, }: DesktopSidebarProps) { const newAgentKeys = useShortcutKeys("new-agent"); - const dragHandlers = useDesktopDragHandlers(); const padding = useWindowControlsPadding("sidebar"); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); @@ -689,11 +689,14 @@ function DesktopSidebar({ return ( - {padding.top > 0 ? : null} - - - - + {padding.top > 0 ? : null} + + + + + + + {isInitialLoad ? ( @@ -832,6 +835,7 @@ const styles = StyleSheet.create((theme) => ({ zIndex: 10, }, sidebarHeader: { + position: "relative", height: { xs: HEADER_INNER_HEIGHT_MOBILE, md: HEADER_INNER_HEIGHT, diff --git a/packages/app/src/desktop/electron/window.ts b/packages/app/src/desktop/electron/window.ts index 51873ca4a..2dc6c6a71 100644 --- a/packages/app/src/desktop/electron/window.ts +++ b/packages/app/src/desktop/electron/window.ts @@ -1,4 +1,8 @@ -import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host"; +import { + getDesktopHost, + type DesktopWindowBridge, + type DesktopWindowControlsOverlayUpdate, +} from "@/desktop/host"; export function getDesktopWindow(): DesktopWindowBridge | null { const getter = getDesktopHost()?.window?.getCurrentWindow; @@ -28,11 +32,13 @@ export async function isDesktopFullscreen(): Promise { return await win.isFullscreen(); } -export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise { +export async function updateDesktopWindowControls( + update: DesktopWindowControlsOverlayUpdate, +): Promise { const win = getDesktopWindow(); - if (!win || typeof win.setTitleBarTheme !== "function") { + if (!win || typeof win.updateWindowControls !== "function") { return; } - await win.setTitleBarTheme(theme); + await win.updateWindowControls(update); } diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 775957dd1..57bd496f6 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -44,14 +44,17 @@ export interface DesktopMenuBridge { }) => Promise; } +export interface DesktopWindowControlsOverlayUpdate { + height?: number; + backgroundColor?: string; + foregroundColor?: string; +} + export interface DesktopWindowBridge { label?: string; - startMove?: (screenX: number, screenY: number) => void; - moving?: (screenX: number, screenY: number) => void; - endMove?: () => void; toggleMaximize?: () => Promise; isFullscreen?: () => Promise; - setTitleBarTheme?: (theme: "light" | "dark") => Promise; + updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise; onResized?: ( handler: (event: TEvent) => void, ) => Promise<() => void> | (() => void); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index a38fe98a4..d2bd175c0 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -51,7 +51,7 @@ import type { } from "@server/server/agent/agent-sdk-types"; import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; -import { useDesktopDragHandlers } from "@/utils/desktop-window"; +import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; @@ -243,7 +243,6 @@ function DraftAgentScreenContent({ const activateExplorerTabForCheckout = usePanelStore( (state) => state.activateExplorerTabForCheckout, ); - const dragHandlers = useDesktopDragHandlers(); const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const draftIdRef = useRef(generateDraftId()); const draftAgentIdRef = useRef(generateDraftId()); @@ -999,8 +998,10 @@ function DraftAgentScreenContent({ const explorerServerId = draftExplorerCheckout?.serverId ?? null; const explorerIsGit = draftExplorerCheckout?.isGit ?? false; const mainContent = ( - - + + + + ) : null} + ); @@ -1292,6 +1294,7 @@ function DraftAgentScreenContent({ const styles = StyleSheet.create((theme) => ({ container: { + position: "relative", flex: 1, backgroundColor: theme.colors.surface0, }, diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index f401de1c8..ebada269e 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -9,7 +9,7 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; import { isCompactFormFactor } from "@/constants/layout"; -import { useDesktopDragHandlers } from "@/utils/desktop-window"; +import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region"; export function OpenProjectScreen({ serverId }: { serverId: string }) { const openAgentList = usePanelStore((s) => s.openAgentList); @@ -18,7 +18,6 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0); const isCompactLayout = isCompactFormFactor(); - const dragHandlers = useDesktopDragHandlers(); useEffect(() => { if (!isCompactLayout) { @@ -29,23 +28,26 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { return ( - - - - - - What shall we build today? - {hasHydrated && !hasProjects ? ( - - Add a project folder to start running agents on your codebase - - ) : null} - - - - + + + + + + + + What shall we build today? + {hasHydrated && !hasProjects ? ( + + Add a project folder to start running agents on your codebase + + ) : null} + + + + + ); @@ -58,6 +60,7 @@ const styles = StyleSheet.create((theme) => ({ userSelect: "none", }, content: { + position: "relative", flexGrow: 1, justifyContent: "center", alignItems: "center", diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index 321b5b6eb..511205aa1 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -11,7 +11,7 @@ import { getDesktopDaemonLogs, type DesktopDaemonLogs, } from "@/desktop/daemon/desktop-daemon"; -import { useDesktopDragHandlers } from "@/utils/desktop-window"; +import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region"; type StartupSplashScreenProps = { bootstrapState?: { @@ -26,6 +26,7 @@ const DOCS_URL = "https://paseo.sh/docs"; const styles = StyleSheet.create((theme) => ({ container: { + position: "relative", flex: 1, alignItems: "center", justifyContent: "center", @@ -140,7 +141,6 @@ const styles = StyleSheet.create((theme) => ({ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) { const { theme } = useUnistyles(); - const dragHandlers = useDesktopDragHandlers(); const [daemonLogs, setDaemonLogs] = useState(null); const [logsError, setLogsError] = useState(null); const [isLoadingLogs, setIsLoadingLogs] = useState(false); @@ -222,39 +222,47 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps if (isSimpleSplash) { return ( - - - Starting up… + + + + + Starting up… + ); } if (!isError) { return ( - - - - Welcome to Paseo - - {progressSteps.map((step) => ( - - {step.status === "complete" ? ( - - ) : ( - - )} - {step.label} - - ))} + + + + + + Welcome to Paseo + + {progressSteps.map((step) => ( + + {step.status === "complete" ? ( + + ) : ( + + )} + {step.label} + + ))} + - + ); } return ( - - + + + + Something went wrong @@ -313,6 +321,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps + ); } diff --git a/packages/app/src/utils/desktop-window-drag-coordinates.test.ts b/packages/app/src/utils/desktop-window-drag-coordinates.test.ts deleted file mode 100644 index b75c1b16f..000000000 --- a/packages/app/src/utils/desktop-window-drag-coordinates.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates"; - -describe("readFiniteScreenPoint", () => { - it("returns finite screen coordinates", () => { - expect(readFiniteScreenPoint({ screenX: 1280, screenY: 720 })).toEqual({ - screenX: 1280, - screenY: 720, - }); - }); - - it("rejects NaN screen coordinates", () => { - expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull(); - expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull(); - }); - - it("rejects infinite screen coordinates", () => { - expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull(); - expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull(); - }); - - it("rejects missing screen coordinates", () => { - expect(readFiniteScreenPoint(undefined)).toBeNull(); - expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull(); - expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull(); - }); -}); diff --git a/packages/app/src/utils/desktop-window-drag-coordinates.ts b/packages/app/src/utils/desktop-window-drag-coordinates.ts deleted file mode 100644 index 1c3282276..000000000 --- a/packages/app/src/utils/desktop-window-drag-coordinates.ts +++ /dev/null @@ -1,27 +0,0 @@ -export type DesktopWindowScreenPoint = { - screenX: number; - screenY: number; -}; - -type ScreenPointInput = - | { - screenX?: unknown; - screenY?: unknown; - } - | null - | undefined; - -function isFiniteCoordinate(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - -export function readFiniteScreenPoint(input: ScreenPointInput): DesktopWindowScreenPoint | null { - if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) { - return null; - } - - return { - screenX: input.screenX, - screenY: input.screenY, - }; -} diff --git a/packages/app/src/utils/desktop-window.test.ts b/packages/app/src/utils/desktop-window.test.ts deleted file mode 100644 index e27ef00c7..000000000 --- a/packages/app/src/utils/desktop-window.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { isInteractiveDesktopDragTarget } from "./desktop-window"; - -function createTarget(input: { matchesSelector: (selector: string) => boolean }): EventTarget { - return { - closest: (selector: string) => (input.matchesSelector(selector) ? ({} as Element) : null), - } as unknown as EventTarget; -} - -describe("isInteractiveDesktopDragTarget", () => { - it("treats focusable pressables as interactive drag exemptions", () => { - const target = createTarget({ - matchesSelector: (selector) => selector.includes("[tabindex]"), - }); - - expect(isInteractiveDesktopDragTarget(target)).toBe(true); - }); - - it("treats semantic button targets as interactive drag exemptions", () => { - const target = createTarget({ - matchesSelector: (selector) => selector.includes("[role='button']"), - }); - - expect(isInteractiveDesktopDragTarget(target)).toBe(true); - }); - - it("returns false for non-interactive targets", () => { - const target = createTarget({ - matchesSelector: () => false, - }); - - expect(isInteractiveDesktopDragTarget(target)).toBe(false); - }); - - it("returns false when the target does not support closest()", () => { - expect(isInteractiveDesktopDragTarget(null)).toBe(false); - expect(isInteractiveDesktopDragTarget({} as EventTarget)).toBe(false); - }); -}); diff --git a/packages/app/src/utils/desktop-window.ts b/packages/app/src/utils/desktop-window.ts index 25a81e064..e72f5dfdd 100644 --- a/packages/app/src/utils/desktop-window.ts +++ b/packages/app/src/utils/desktop-window.ts @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native"; +import { useEffect, useMemo, useState } from "react"; +import { Platform } from "react-native"; import { getIsElectronRuntimeMac, getIsElectronRuntime, @@ -9,120 +9,7 @@ import { DESKTOP_WINDOW_CONTROLS_HEIGHT, } from "@/constants/layout"; import { getDesktopWindow } from "@/desktop/electron/window"; -import { isElectronRuntime } from "@/desktop/host"; import { usePanelStore } from "@/stores/panel-store"; -import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates"; - -export async function toggleMaximize() { - const win = getDesktopWindow(); - if (win && typeof win.toggleMaximize === "function") { - try { - await win.toggleMaximize(); - } catch (error) { - console.warn("[DesktopWindow] toggleMaximize failed", error); - } - } -} - -// --------------------------------------------------------------------------- -// Manual window dragging via pointer events. -// Mirrors the Tauri implementation: single pointerdown handler with -// double-click-to-maximize via timing, closest() for interactive check, -// and pointer capture for move tracking. -// --------------------------------------------------------------------------- - -const INTERACTIVE_SELECTOR = - "button, a, input, textarea, select, " + - "[role='button'], [role='link'], [role='textbox'], [role='combobox'], " + - "[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " + - "[role='menuitem'], [tabindex], [contenteditable='true']"; - -const DOUBLE_CLICK_MS = 300; - -type DesktopDragViewProps = Pick< - ViewProps, - "onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel" ->; - -export function isInteractiveDesktopDragTarget(target: unknown): boolean { - const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null; - if (!candidate || typeof candidate.closest !== "function") { - return false; - } - - return Boolean(candidate.closest(INTERACTIVE_SELECTOR)); -} - -export function useDesktopDragHandlers(): DesktopDragViewProps { - const isDragging = useRef(false); - const lastPointerDownAt = useRef(0); - const isActive = Platform.OS === "web" && isElectronRuntime(); - - useEffect(() => { - if (!isActive) return; - function handleBlur() { - if (!isDragging.current) return; - isDragging.current = false; - getDesktopWindow()?.endMove?.(); - } - window.addEventListener("blur", handleBlur); - return () => window.removeEventListener("blur", handleBlur); - }, [isActive]); - - return useMemo((): DesktopDragViewProps => { - if (!isActive) return {}; - - function stopDrag(e: RNPointerEvent) { - if (!isDragging.current) return; - isDragging.current = false; - // On web, currentTarget is a DOM Element (typed as HostInstance in RN) - const el = e.currentTarget as unknown as Element | null; - if (el && "releasePointerCapture" in el) { - el.releasePointerCapture(e.nativeEvent.pointerId); - } - getDesktopWindow()?.endMove?.(); - } - - return { - onPointerDown: (e: RNPointerEvent) => { - if (e.nativeEvent.button !== 0) return; - - if (isInteractiveDesktopDragTarget(e.target)) return; - - e.preventDefault(); - - const now = Date.now(); - if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) { - lastPointerDownAt.current = 0; - void toggleMaximize(); - return; - } - lastPointerDownAt.current = now; - - const win = getDesktopWindow(); - if (!win?.startMove) return; - const screenPoint = readFiniteScreenPoint(e.nativeEvent); - if (!screenPoint) return; - - isDragging.current = true; - const el = e.currentTarget as unknown as Element; - el.setPointerCapture(e.nativeEvent.pointerId); - win.startMove(screenPoint.screenX, screenPoint.screenY); - }, - onPointerMove: (e: RNPointerEvent) => { - if (!isDragging.current) return; - const screenPoint = readFiniteScreenPoint(e.nativeEvent); - if (!screenPoint) { - stopDrag(e); - return; - } - getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY); - }, - onPointerUp: stopDrag, - onPointerCancel: stopDrag, - }; - }, [isActive]); -} type RawWindowControlsPadding = { left: number; diff --git a/packages/desktop/scripts/verify-electron-cdp.mjs b/packages/desktop/scripts/verify-electron-cdp.mjs index a6ae1954d..029c6b1ab 100644 --- a/packages/desktop/scripts/verify-electron-cdp.mjs +++ b/packages/desktop/scripts/verify-electron-cdp.mjs @@ -7,6 +7,24 @@ const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223"; const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification"; const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081"; const REQUIRED_DESKTOP_KEYS = ["invoke", "events", "window", "dialog", "notification", "opener"]; +const INTERACTIVE_SELECTOR = [ + "button", + "a", + "input", + "textarea", + "select", + "[role='button']", + "[role='link']", + "[role='textbox']", + "[role='combobox']", + "[role='tab']", + "[role='switch']", + "[role='checkbox']", + "[role='slider']", + "[role='menuitem']", + "[tabindex]", + "[contenteditable='true']", +].join(", "); function assert(condition, message) { if (!condition) { @@ -28,6 +46,313 @@ async function captureScreenshot(page, fileName) { return filePath; } +async function inspectTitlebarRegions(page) { + return page.evaluate((interactiveSelector) => { + const nodes = Array.from(document.querySelectorAll("*")); + const annotationId = "electron-verify-titlebar-style"; + const existingAnnotation = document.getElementById(annotationId); + existingAnnotation?.remove(); + + const annotationStyle = document.createElement("style"); + annotationStyle.id = annotationId; + annotationStyle.textContent = ` + [data-electron-verify-drag="true"] { + outline: 3px solid #ff4d4f !important; + outline-offset: -3px !important; + } + [data-electron-verify-resizer="true"] { + outline: 3px solid #52c41a !important; + outline-offset: -3px !important; + } + [data-electron-verify-interactive="true"] { + outline: 3px solid #1677ff !important; + outline-offset: -3px !important; + } + `; + document.head.appendChild(annotationStyle); + + function isVisible(element) { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return ( + rect.width > 0 && + rect.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + style.opacity !== "0" + ); + } + + function summarizeText(element) { + return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120); + } + + function readAppRegion(element) { + const style = window.getComputedStyle(element); + return style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none"; + } + + function rectInfo(element) { + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + } + + function summarizeElement(element) { + const style = window.getComputedStyle(element); + return { + tagName: element.tagName.toLowerCase(), + text: summarizeText(element), + appRegion: readAppRegion(element), + position: style.position, + zIndex: style.zIndex, + paddingLeft: Number.parseFloat(style.paddingLeft || "0"), + paddingTop: Number.parseFloat(style.paddingTop || "0"), + ...rectInfo(element), + }; + } + + function isNearTop(summary) { + return summary.top < 220; + } + + function isTopResizer(element, overlayRect) { + if (!(element instanceof HTMLElement) || !isVisible(element)) { + return false; + } + const summary = summarizeElement(element); + return ( + summary.appRegion === "no-drag" && + summary.position === "absolute" && + Math.abs(summary.height - 4) <= 1 && + Math.abs(summary.top - overlayRect.top) <= 2 && + Math.abs(summary.left - overlayRect.left) <= 2 && + Math.abs(summary.width - overlayRect.width) <= 2 + ); + } + + function summarizeInteractive(element) { + const summary = summarizeElement(element); + return { + ...summary, + testId: element.getAttribute("data-testid"), + role: element.getAttribute("role"), + }; + } + + const dragSummaries = []; + const suspiciousDragHosts = []; + + for (const node of nodes) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue; + } + const summary = summarizeElement(node); + if (summary.appRegion !== "drag") { + continue; + } + + const parent = node.parentElement instanceof HTMLElement ? node.parentElement : null; + const parentSummary = parent ? summarizeElement(parent) : null; + const interactiveDescendants = Array.from(node.querySelectorAll(interactiveSelector)) + .filter((child) => child instanceof HTMLElement) + .filter((child) => isVisible(child)) + .map((child) => summarizeInteractive(child)); + + const siblingResizers = parent + ? Array.from(parent.children) + .filter((child) => child !== node) + .filter((child) => child instanceof HTMLElement) + .filter((child) => isTopResizer(child, summary)) + .map((child) => summarizeElement(child)) + : []; + + const parentInteractive = parent + ? Array.from(parent.querySelectorAll(interactiveSelector)) + .filter((child) => child instanceof HTMLElement) + .filter((child) => isVisible(child)) + .map((child) => summarizeInteractive(child)) + : []; + const explicitNoDragInteractive = parentInteractive.filter( + (child) => child.appRegion === "no-drag", + ); + + const record = { + ...summary, + parent: parentSummary, + interactiveDescendants: interactiveDescendants.slice(0, 5), + siblingResizers: siblingResizers.slice(0, 3), + explicitNoDragInteractive: explicitNoDragInteractive.slice(0, 5), + parentInteractiveCount: parentInteractive.length, + }; + dragSummaries.push(record); + + const looksLikeHostShortcut = + isNearTop(summary) && + (summary.position !== "absolute" || + summary.text.length > 0 || + interactiveDescendants.length > 0 || + parentSummary?.appRegion === "drag"); + if (looksLikeHostShortcut) { + suspiciousDragHosts.push(record); + } + } + + const verifiedRegions = dragSummaries + .filter((entry) => isNearTop(entry)) + .filter((entry) => entry.position === "absolute") + .filter((entry) => entry.text.length === 0) + .filter((entry) => entry.parent?.appRegion !== "drag") + .filter((entry) => entry.siblingResizers.length > 0) + .sort( + (left, right) => + right.explicitNoDragInteractive.length - left.explicitNoDragInteractive.length || + left.top - right.top || + right.width - left.width, + ); + + const candidate = verifiedRegions[0] ?? null; + if (candidate) { + const matchingDragNode = nodes.find((node) => { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + return false; + } + const summary = summarizeElement(node); + return ( + summary.appRegion === "drag" && + Math.abs(summary.top - candidate.top) <= 1 && + Math.abs(summary.left - candidate.left) <= 1 && + Math.abs(summary.width - candidate.width) <= 1 && + Math.abs(summary.height - candidate.height) <= 1 + ); + }); + if (matchingDragNode instanceof HTMLElement) { + matchingDragNode.setAttribute("data-electron-verify-drag", "true"); + const parent = matchingDragNode.parentElement; + if (parent instanceof HTMLElement) { + for (const child of parent.children) { + if (child instanceof HTMLElement && isTopResizer(child, candidate)) { + child.setAttribute("data-electron-verify-resizer", "true"); + } + } + const interactiveChildren = Array.from(parent.querySelectorAll(interactiveSelector)) + .filter((child) => child instanceof HTMLElement) + .filter((child) => isVisible(child)) + .filter((child) => summarizeElement(child).appRegion === "no-drag") + .slice(0, 3); + for (const child of interactiveChildren) { + child.setAttribute("data-electron-verify-interactive", "true"); + } + } + } + } + + return { + interactiveSelector, + dragRegionCount: dragSummaries.length, + verifiedRegionCount: verifiedRegions.length, + candidate, + suspiciousDragHosts: suspiciousDragHosts.slice(0, 10), + dragRegions: dragSummaries.slice(0, 10), + }; + }, INTERACTIVE_SELECTOR); +} + +async function inspectFullscreenResizer(page) { + const session = await page.context().newCDPSession(page); + let windowId = null; + let initialBounds = null; + let fullscreenEntered = false; + + try { + const windowInfo = await session.send("Browser.getWindowForTarget"); + windowId = windowInfo.windowId; + initialBounds = await session.send("Browser.getWindowBounds", { windowId }); + await session.send("Browser.setWindowBounds", { + windowId, + bounds: { windowState: "fullscreen" }, + }); + fullscreenEntered = true; + await page.waitForTimeout(1000); + + const details = await page.evaluate(async () => { + const bridge = window.paseoDesktop?.window; + const bridgeFullscreen = + typeof bridge?.isFullscreen === "function" ? await bridge.isFullscreen() : null; + const visibleNoDragResizers = Array.from(document.querySelectorAll("*")) + .filter((node) => node instanceof HTMLElement) + .filter((node) => { + const element = node; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const appRegion = + style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none"; + return ( + rect.width > 0 && + rect.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + style.opacity !== "0" && + appRegion === "no-drag" && + style.position === "absolute" && + Math.abs(rect.height - 4) <= 1 && + rect.top < 220 + ); + }) + .map((node) => { + const rect = node.getBoundingClientRect(); + return { + tagName: node.tagName.toLowerCase(), + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + }); + + return { + bridgeFullscreen, + visibleNoDragResizers, + }; + }); + + return { + supported: true, + enteredFullscreen: fullscreenEntered, + initialBounds, + ...details, + passed: + details.bridgeFullscreen === true && + Array.isArray(details.visibleNoDragResizers) && + details.visibleNoDragResizers.length === 0, + }; + } catch (error) { + return { + supported: false, + error: String(error), + initialBounds, + }; + } finally { + const previousWindowState = initialBounds?.bounds?.windowState ?? "normal"; + if (windowId !== null && fullscreenEntered) { + try { + await session.send("Browser.setWindowBounds", { + windowId, + bounds: { windowState: previousWindowState }, + }); + await page.waitForTimeout(500); + } catch { + // Best-effort restore only. + } + } + await session.detach().catch(() => undefined); + } +} + async function findAppPage(browser) { for (let attempt = 0; attempt < 30; attempt += 1) { for (const context of browser.contexts()) { @@ -132,115 +457,20 @@ async function main() { await page.waitForTimeout(500); } - const dragRegionCheck = await page.evaluate(() => { - const nodes = Array.from(document.querySelectorAll("*")); - const annotationId = "electron-verify-drag-style"; - const existingAnnotation = document.getElementById(annotationId); - existingAnnotation?.remove(); - - const annotationStyle = document.createElement("style"); - annotationStyle.id = annotationId; - annotationStyle.textContent = ` - [data-electron-verify-drag="true"] { - outline: 3px solid #ff4d4f !important; - outline-offset: -3px !important; - } - `; - document.head.appendChild(annotationStyle); - - function isVisible(element) { - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - return ( - rect.width > 0 && - rect.height > 0 && - style.display !== "none" && - style.visibility !== "hidden" && - style.opacity !== "0" - ); - } - - function summarizeText(element) { - return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120); - } - - const regions = []; - let candidate = null; - - for (const element of nodes) { - if (!(element instanceof HTMLElement)) { - continue; - } - const style = window.getComputedStyle(element); - const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region"); - if (appRegion !== "drag" || !isVisible(element)) { - continue; - } - - const rect = element.getBoundingClientRect(); - const text = summarizeText(element); - const info = { - tagName: element.tagName.toLowerCase(), - text, - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - paddingLeft: Number.parseFloat(style.paddingLeft || "0"), - paddingTop: Number.parseFloat(style.paddingTop || "0"), - }; - regions.push(info); - - const looksLikeHeader = - rect.top < 180 && - rect.height >= 40 && - (text.includes("Settings") || text.includes("Sessions")); - if (!candidate && looksLikeHeader) { - candidate = { ...info }; - element.setAttribute("data-electron-verify-drag", "true"); - } - } - - if (!candidate && regions.length > 0) { - candidate = regions.slice().sort((left, right) => left.top - right.top)[0]; - - for (const element of nodes) { - if (!(element instanceof HTMLElement)) { - continue; - } - const style = window.getComputedStyle(element); - const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region"); - if (appRegion !== "drag" || !isVisible(element)) { - continue; - } - const rect = element.getBoundingClientRect(); - if ( - Math.abs(rect.top - candidate.top) < 1 && - Math.abs(rect.left - candidate.left) < 1 && - Math.abs(rect.width - candidate.width) < 1 && - Math.abs(rect.height - candidate.height) < 1 - ) { - element.setAttribute("data-electron-verify-drag", "true"); - break; - } - } - } - - return { - count: regions.length, - candidate, - regions: regions.slice(0, 10), - }; - }); + const dragRegionCheck = await inspectTitlebarRegions(page); const dragScreenshot = await captureScreenshot(page, "03-drag-region.png"); const dragRegionPassed = - dragRegionCheck.count > 0 && + dragRegionCheck.dragRegionCount > 0 && + dragRegionCheck.verifiedRegionCount > 0 && Boolean(dragRegionCheck.candidate) && - dragRegionCheck.candidate.top < 180; + dragRegionCheck.candidate.top < 220 && + dragRegionCheck.candidate.parent?.appRegion !== "drag" && + dragRegionCheck.candidate.siblingResizers.length > 0 && + dragRegionCheck.suspiciousDragHosts.length === 0; results.push({ - check: "drag-regions", + check: "titlebar-drag-structure", pass: dragRegionPassed, details: dragRegionCheck, screenshot: dragScreenshot, @@ -248,7 +478,7 @@ async function main() { const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png"); const isMac = process.platform === "darwin"; - const observedPaddingLeft = dragRegionCheck.candidate?.paddingLeft ?? null; + const observedPaddingLeft = dragRegionCheck.candidate?.parent?.paddingLeft ?? null; const trafficLightPaddingPassed = !isMac ? true : typeof observedPaddingLeft === "number" && @@ -261,12 +491,36 @@ async function main() { details: { platform: process.platform, observedPaddingLeft, - expectedApproximatePaddingLeft: 78, + note: "Traffic-light padding is only validated structurally on macOS in this verifier.", candidate: dragRegionCheck.candidate, }, screenshot: trafficLightScreenshot, }); + const noDragInteractiveCheck = { + check: "interactive-no-drag-layering", + pass: + Boolean(dragRegionCheck.candidate) && + Array.isArray(dragRegionCheck.candidate.explicitNoDragInteractive) && + dragRegionCheck.candidate.explicitNoDragInteractive.length > 0, + details: { + candidate: dragRegionCheck.candidate, + explicitNoDragInteractive: + dragRegionCheck.candidate?.explicitNoDragInteractive ?? [], + }, + screenshot: dragScreenshot, + }; + results.push(noDragInteractiveCheck); + + const fullscreenDetails = await inspectFullscreenResizer(page); + const fullscreenScreenshot = await captureScreenshot(page, "04-fullscreen-resizer.png"); + results.push({ + check: "fullscreen-resizer", + pass: fullscreenDetails.supported ? fullscreenDetails.passed : true, + details: fullscreenDetails, + screenshot: fullscreenScreenshot, + }); + const daemonManagementVisible = await Promise.all([ page.getByText("Built-in daemon", { exact: true }).isVisible(), page.getByText("Daemon management", { exact: true }).isVisible(), diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 59012d63b..f368c696c 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -13,7 +13,7 @@ import { import { closeAllTransportSessions } from "./daemon/local-transport.js"; import { registerWindowManager, - getTitleBarOverlayOptions, + getMainWindowChromeOptions, resolveSystemWindowTheme, setupWindowResizeEvents, setupDragDropPrevention, @@ -86,21 +86,18 @@ function applyAppIcon(): void { } async function createMainWindow(): Promise { - const isMac = process.platform === "darwin"; const iconPath = getWindowIconPath(); + const systemTheme = resolveSystemWindowTheme(); const mainWindow = new BrowserWindow({ width: 1200, height: 800, show: false, ...(iconPath ? { icon: iconPath } : {}), - titleBarStyle: "hidden", - ...(isMac - ? { trafficLightPosition: { x: 16, y: 14 } } - : { - titleBarOverlay: getTitleBarOverlayOptions(resolveSystemWindowTheme()), - autoHideMenuBar: true, - }), + ...getMainWindowChromeOptions({ + platform: process.platform, + theme: systemTheme, + }), webPreferences: { preload: getPreloadPath(), contextIsolation: true, diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index a1a6650d8..728b641b4 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -19,15 +19,13 @@ contextBridge.exposeInMainWorld("paseoDesktop", { }, window: { getCurrentWindow: () => ({ - startMove: (screenX: number, screenY: number) => - ipcRenderer.send("paseo:window:startMove", { screenX, screenY }), - moving: (screenX: number, screenY: number) => - ipcRenderer.send("paseo:window:moving", { screenX, screenY }), - endMove: () => ipcRenderer.send("paseo:window:endMove"), toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"), isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"), - setTitleBarTheme: (theme: "light" | "dark") => - ipcRenderer.invoke("paseo:window:setTitleBarTheme", theme), + updateWindowControls: (update: { + height?: number; + backgroundColor?: string; + foregroundColor?: string; + }) => ipcRenderer.invoke("paseo:window:updateWindowControls", update), onResized: (handler: EventHandler): (() => void) => { const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => { handler(payload); diff --git a/packages/desktop/src/window/window-drag-coordinates.test.ts b/packages/desktop/src/window/window-drag-coordinates.test.ts deleted file mode 100644 index 314fa4eb0..000000000 --- a/packages/desktop/src/window/window-drag-coordinates.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - createWindowMoveState, - readWindowMovePayload, - resolveWindowMovePosition, -} from "./window-drag-coordinates"; - -describe("window-drag-coordinates", () => { - describe("readWindowMovePayload", () => { - it("returns finite screen coordinates", () => { - expect(readWindowMovePayload({ screenX: 1500, screenY: 900 })).toEqual({ - screenX: 1500, - screenY: 900, - }); - }); - - it("rejects non-finite screen coordinates", () => { - expect(readWindowMovePayload({ screenX: Number.NaN, screenY: 900 })).toBeNull(); - expect( - readWindowMovePayload({ screenX: 1500, screenY: Number.POSITIVE_INFINITY }), - ).toBeNull(); - }); - }); - - describe("createWindowMoveState", () => { - it("derives finite offsets from the window position", () => { - expect( - createWindowMoveState({ - payload: { screenX: 1500, screenY: 900 }, - windowX: 1200, - windowY: 700, - }), - ).toEqual({ - offsetX: 300, - offsetY: 200, - }); - }); - - it("rejects non-finite offsets", () => { - expect( - createWindowMoveState({ - payload: { screenX: 1500, screenY: 900 }, - windowX: Number.NaN, - windowY: 700, - }), - ).toBeNull(); - }); - }); - - describe("resolveWindowMovePosition", () => { - it("rounds the next window position", () => { - expect( - resolveWindowMovePosition({ - payload: { screenX: 1501.4, screenY: 902.6 }, - state: { offsetX: 300, offsetY: 200 }, - }), - ).toEqual({ - x: 1201, - y: 703, - }); - }); - - it("rejects non-finite next positions", () => { - expect( - resolveWindowMovePosition({ - payload: { screenX: 1500, screenY: 900 }, - state: { offsetX: Number.NaN, offsetY: 200 }, - }), - ).toBeNull(); - }); - }); -}); diff --git a/packages/desktop/src/window/window-drag-coordinates.ts b/packages/desktop/src/window/window-drag-coordinates.ts deleted file mode 100644 index 00bd9f59a..000000000 --- a/packages/desktop/src/window/window-drag-coordinates.ts +++ /dev/null @@ -1,64 +0,0 @@ -export type WindowMovePayload = { - screenX: number; - screenY: number; -}; - -export type WindowMoveState = { - offsetX: number; - offsetY: number; -}; - -export type WindowPosition = { - x: number; - y: number; -}; - -type WindowMovePayloadInput = - | { - screenX?: unknown; - screenY?: unknown; - } - | null - | undefined; - -function isFiniteCoordinate(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - -export function readWindowMovePayload(input: WindowMovePayloadInput): WindowMovePayload | null { - if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) { - return null; - } - - return { - screenX: input.screenX, - screenY: input.screenY, - }; -} - -export function createWindowMoveState(input: { - payload: WindowMovePayload; - windowX: number; - windowY: number; -}): WindowMoveState | null { - const offsetX = input.payload.screenX - input.windowX; - const offsetY = input.payload.screenY - input.windowY; - if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) { - return null; - } - - return { offsetX, offsetY }; -} - -export function resolveWindowMovePosition(input: { - payload: WindowMovePayload; - state: WindowMoveState; -}): WindowPosition | null { - const x = Math.round(input.payload.screenX - input.state.offsetX); - const y = Math.round(input.payload.screenY - input.state.offsetY); - if (!Number.isFinite(x) || !Number.isFinite(y)) { - return null; - } - - return { x, y }; -} diff --git a/packages/desktop/src/window/window-manager.test.ts b/packages/desktop/src/window/window-manager.test.ts index 41e227b62..269911abc 100644 --- a/packages/desktop/src/window/window-manager.test.ts +++ b/packages/desktop/src/window/window-manager.test.ts @@ -1,6 +1,15 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { getTitleBarOverlayOptions, readBadgeCount, readWindowTheme } from "./window-manager"; +import { + applyWindowControlsOverlayUpdate, + createWindowControlsOverlayState, + getMainWindowChromeOptions, + getTitleBarOverlayOptions, + readBadgeCount, + readWindowControlsOverlayUpdate, + readWindowTheme, + resolveRuntimeTitleBarOverlayOptions, +} from "./window-manager"; describe("window-manager", () => { describe("readBadgeCount", () => { @@ -39,7 +48,7 @@ describe("window-manager", () => { expect(getTitleBarOverlayOptions("light")).toEqual({ color: "#ffffff", symbolColor: "#09090b", - height: 48, + height: 29, }); }); @@ -47,7 +56,133 @@ describe("window-manager", () => { expect(getTitleBarOverlayOptions("dark")).toEqual({ color: "#18181c", symbolColor: "#e4e4e7", + height: 29, + }); + }); + }); + + describe("readWindowControlsOverlayUpdate", () => { + it("accepts partial runtime overlay updates", () => { + expect( + readWindowControlsOverlayUpdate({ + height: 48, + backgroundColor: "#18181c", + }), + ).toEqual({ height: 48, + backgroundColor: "#18181c", + }); + }); + + it("rejects empty and invalid payloads", () => { + expect(readWindowControlsOverlayUpdate(undefined)).toBeNull(); + expect(readWindowControlsOverlayUpdate({})).toBeNull(); + expect(readWindowControlsOverlayUpdate({ height: 0 })).toBeNull(); + expect(readWindowControlsOverlayUpdate({ backgroundColor: 12 })).toBeNull(); + }); + }); + + describe("resolveRuntimeTitleBarOverlayOptions", () => { + it("applies the VS Code height minus border adjustment", () => { + expect( + resolveRuntimeTitleBarOverlayOptions({ + height: 48, + backgroundColor: "#ffffff", + foregroundColor: "#09090b", + }), + ).toEqual({ + color: "#ffffff", + symbolColor: "#09090b", + height: 47, + }); + }); + }); + + describe("applyWindowControlsOverlayUpdate", () => { + it("merges cached colors with later runtime height updates", () => { + const setTitleBarOverlay = vi.fn(); + let state = createWindowControlsOverlayState("dark"); + + state = applyWindowControlsOverlayUpdate({ + win: { setTitleBarOverlay }, + current: state, + update: { + backgroundColor: "#18181c", + foregroundColor: "#e4e4e7", + }, + }); + + state = applyWindowControlsOverlayUpdate({ + win: { setTitleBarOverlay }, + current: state, + update: { height: 48 }, + }); + + expect(state).toEqual({ + height: 48, + backgroundColor: "#18181c", + foregroundColor: "#e4e4e7", + }); + expect(setTitleBarOverlay).toHaveBeenNthCalledWith(1, { + color: "#18181c", + symbolColor: "#e4e4e7", + height: 28, + }); + expect(setTitleBarOverlay).toHaveBeenNthCalledWith(2, { + color: "#18181c", + symbolColor: "#e4e4e7", + height: 47, + }); + }); + }); + + describe("getMainWindowChromeOptions", () => { + it("uses frameless hidden title bars with overlay on windows", () => { + expect( + getMainWindowChromeOptions({ + platform: "win32", + theme: "dark", + }), + ).toEqual({ + titleBarStyle: "hidden", + frame: false, + autoHideMenuBar: true, + titleBarOverlay: { + color: "#18181c", + symbolColor: "#e4e4e7", + height: 29, + }, + }); + }); + + it("uses frameless hidden title bars with overlay on linux", () => { + expect( + getMainWindowChromeOptions({ + platform: "linux", + theme: "light", + }), + ).toEqual({ + titleBarStyle: "hidden", + frame: false, + autoHideMenuBar: true, + titleBarOverlay: { + color: "#ffffff", + symbolColor: "#09090b", + height: 29, + }, + }); + }); + + it("keeps the mac traffic-light path separate", () => { + expect( + getMainWindowChromeOptions({ + platform: "darwin", + theme: "dark", + }), + ).toEqual({ + titleBarStyle: "hidden", + titleBarOverlay: true, + trafficLightPosition: { x: 16, y: 14 }, }); }); }); diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index 0eb3c3801..f7dee555c 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -1,10 +1,4 @@ import { app, BrowserWindow, ipcMain, nativeTheme } from "electron"; -import { - createWindowMoveState, - readWindowMovePayload, - resolveWindowMovePosition, - type WindowMoveState, -} from "./window-drag-coordinates.js"; export function readBadgeCount(input: unknown): number { if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) { @@ -15,6 +9,17 @@ export function readBadgeCount(input: unknown): number { } export type WindowTheme = "light" | "dark"; +export type WindowControlsOverlayUpdate = { + height?: number; + backgroundColor?: string; + foregroundColor?: string; +}; + +export type WindowControlsOverlayState = { + height: number; + backgroundColor?: string; + foregroundColor?: string; +}; export function readWindowTheme(input: unknown): WindowTheme | null { if (input === "light" || input === "dark") { @@ -28,71 +33,111 @@ export function resolveSystemWindowTheme(): WindowTheme { return nativeTheme.shouldUseDarkColors ? "dark" : "light"; } +export function createWindowControlsOverlayState(theme: WindowTheme): WindowControlsOverlayState { + const overlay = getTitleBarOverlayOptions(theme); + return { + height: overlay.height ?? 29, + backgroundColor: overlay.color, + foregroundColor: overlay.symbolColor, + }; +} + export function getTitleBarOverlayOptions(theme: WindowTheme): Electron.TitleBarOverlayOptions { if (theme === "dark") { - return { color: "#18181c", symbolColor: "#e4e4e7", height: 48 }; + return { color: "#18181c", symbolColor: "#e4e4e7", height: 29 }; } - return { color: "#ffffff", symbolColor: "#09090b", height: 48 }; + return { color: "#ffffff", symbolColor: "#09090b", height: 29 }; +} + +export function getMainWindowChromeOptions(input: { + platform: NodeJS.Platform; + theme: WindowTheme; +}): Pick< + Electron.BrowserWindowConstructorOptions, + "titleBarStyle" | "trafficLightPosition" | "frame" | "titleBarOverlay" | "autoHideMenuBar" +> { + if (input.platform === "darwin") { + return { + titleBarStyle: "hidden", + titleBarOverlay: true, + trafficLightPosition: { x: 16, y: 14 }, + }; + } + + return { + titleBarStyle: "hidden", + frame: false, + titleBarOverlay: getTitleBarOverlayOptions(input.theme), + autoHideMenuBar: true, + }; +} + +function readFiniteOverlayHeight(input: unknown): number | null { + if (typeof input !== "number" || !Number.isFinite(input)) { + return null; + } + + const rounded = Math.round(input); + return rounded >= 1 ? rounded : null; +} + +function readOverlayColor(input: unknown): string | null { + if (typeof input !== "string") { + return null; + } + + return input; +} + +export function readWindowControlsOverlayUpdate(input: unknown): WindowControlsOverlayUpdate | null { + if (!input || typeof input !== "object") { + return null; + } + + const candidate = input as Record; + const height = readFiniteOverlayHeight(candidate.height); + const backgroundColor = readOverlayColor(candidate.backgroundColor); + const foregroundColor = readOverlayColor(candidate.foregroundColor); + + if (height === null && backgroundColor === null && foregroundColor === null) { + return null; + } + + return { + ...(height !== null ? { height } : {}), + ...(backgroundColor !== null ? { backgroundColor } : {}), + ...(foregroundColor !== null ? { foregroundColor } : {}), + }; +} + +export function resolveRuntimeTitleBarOverlayOptions( + state: WindowControlsOverlayState, +): Electron.TitleBarOverlayOptions { + return { + color: state.backgroundColor?.trim() === "" ? undefined : state.backgroundColor, + symbolColor: state.foregroundColor?.trim() === "" ? undefined : state.foregroundColor, + height: Math.max(0, state.height - 1), + }; +} + +export function applyWindowControlsOverlayUpdate(input: { + win: Pick; + current: WindowControlsOverlayState; + update: WindowControlsOverlayUpdate; +}): WindowControlsOverlayState { + const next: WindowControlsOverlayState = { + height: input.update.height ?? input.current.height, + backgroundColor: input.update.backgroundColor ?? input.current.backgroundColor, + foregroundColor: input.update.foregroundColor ?? input.current.foregroundColor, + }; + + input.win.setTitleBarOverlay(resolveRuntimeTitleBarOverlayOptions(next)); + return next; } export function registerWindowManager(): void { - // --------------------------------------------------------------------------- - // Manual window dragging (replaces flaky CSS -webkit-app-region: drag). - // State is keyed per window id to support multiple windows. - // --------------------------------------------------------------------------- - const moveStates = new Map(); - - ipcMain.on("paseo:window:startMove", (event, payload: { screenX: number; screenY: number }) => { - const win = BrowserWindow.fromWebContents(event.sender); - if (!win) return; - const movePayload = readWindowMovePayload(payload); - if (!movePayload) { - moveStates.delete(win.id); - return; - } - const [winX, winY] = win.getPosition(); - const moveState = createWindowMoveState({ - payload: movePayload, - windowX: winX, - windowY: winY, - }); - if (!moveState) { - moveStates.delete(win.id); - return; - } - moveStates.set(win.id, moveState); - }); - - ipcMain.on("paseo:window:moving", (event, payload: { screenX: number; screenY: number }) => { - const win = BrowserWindow.fromWebContents(event.sender); - if (!win) return; - const state = moveStates.get(win.id); - if (!state) return; - const movePayload = readWindowMovePayload(payload); - if (!movePayload) { - moveStates.delete(win.id); - return; - } - const nextPosition = resolveWindowMovePosition({ - payload: movePayload, - state, - }); - if (!nextPosition) { - moveStates.delete(win.id); - return; - } - win.setPosition(nextPosition.x, nextPosition.y); - }); - - ipcMain.on("paseo:window:endMove", (event) => { - const win = BrowserWindow.fromWebContents(event.sender); - if (win) moveStates.delete(win.id); - }); - - app.on("browser-window-created", (_event, win) => { - win.on("closed", () => moveStates.delete(win.id)); - }); + const overlayStateByWindow = new WeakMap(); ipcMain.handle("paseo:window:toggleMaximize", (event) => { const win = BrowserWindow.fromWebContents(event.sender); @@ -124,18 +169,25 @@ export function registerWindowManager(): void { } }); - ipcMain.handle("paseo:window:setTitleBarTheme", (event, theme?: unknown) => { + ipcMain.handle("paseo:window:updateWindowControls", (event, update?: unknown) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win || process.platform === "darwin") { return; } - const nextTheme = readWindowTheme(theme); - if (!nextTheme) { + const nextUpdate = readWindowControlsOverlayUpdate(update); + if (!nextUpdate) { return; } - win.setTitleBarOverlay(getTitleBarOverlayOptions(nextTheme)); + const current = + overlayStateByWindow.get(win) ?? createWindowControlsOverlayState(resolveSystemWindowTheme()); + const nextState = applyWindowControlsOverlayUpdate({ + win, + current, + update: nextUpdate, + }); + overlayStateByWindow.set(win, nextState); }); }