diff --git a/packages/app/src/components/titlebar-padding.test.ts b/packages/app/src/components/titlebar-padding.test.ts new file mode 100644 index 000000000000..1c0993cd0f65 --- /dev/null +++ b/packages/app/src/components/titlebar-padding.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { macTitlebarLeftPadding } from "./titlebar-padding" + +describe("macOS titlebar padding", () => { + test("reserves traffic light space while windowed", () => { + expect(macTitlebarLeftPadding(1, false)).toBe("84px") + expect(macTitlebarLeftPadding(2, false)).toBe("42px") + }) + + test("does not reserve traffic light space in fullscreen", () => { + expect(macTitlebarLeftPadding(1, true)).toBe("0px") + }) +}) diff --git a/packages/app/src/components/titlebar-padding.ts b/packages/app/src/components/titlebar-padding.ts new file mode 100644 index 000000000000..733420105578 --- /dev/null +++ b/packages/app/src/components/titlebar-padding.ts @@ -0,0 +1,6 @@ +const macTrafficLightsWidth = 84 + +export function macTitlebarLeftPadding(zoom: number, fullscreen: boolean) { + if (fullscreen) return "0px" + return `${macTrafficLightsWidth / zoom}px` +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index aa2f220e4949..9cb2d6a6ce98 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -5,7 +5,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { useTheme } from "@opencode-ai/ui/theme/context" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" @@ -29,28 +28,8 @@ import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" import { normalizeSessionInfo } from "@/utils/session" +import { macTitlebarLeftPadding } from "./titlebar-padding" -type TauriDesktopWindow = { - startDragging?: () => Promise - toggleMaximize?: () => Promise -} - -type TauriThemeWindow = { - setTheme?: (theme?: "light" | "dark" | null) => Promise -} - -type TauriApi = { - window?: { - getCurrentWindow?: () => TauriDesktopWindow - } - webviewWindow?: { - getCurrentWebviewWindow?: () => TauriThemeWindow - } -} - -const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ -const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() -const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() const legacyTitlebarHeight = 40 const v2TitlebarHeight = 36 const minTitlebarZoom = 0.25 @@ -74,7 +53,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const command = useCommand() const language = useLanguage() const settings = useSettings() - const theme = useTheme() const server = useServer() const navigate = useNavigate() const location = useLocation() @@ -85,7 +63,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") - const electronWindows = createMemo(() => windows() && !tauriApi()) const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const web = createMemo(() => platform.platform === "web") const zoom = () => platform.webviewZoom?.() ?? 1 @@ -176,56 +153,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl }, ]) - const getWin = () => { - if (platform.platform !== "desktop") return - return currentDesktopWindow() - } - - createEffect(() => { - if (platform.platform !== "desktop") return - - const scheme = theme.colorScheme() - const value = scheme === "system" ? null : scheme - - const win = currentThemeWindow() - if (!win?.setTheme) return - - void win.setTheme(value).catch(() => undefined) - }) - - const interactive = (target: EventTarget | null) => { - if (!(target instanceof Element)) return false - - const selector = - "button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']" - - return !!target.closest(selector) - } - - const drag = (e: MouseEvent) => { - if (platform.platform !== "desktop") return - if (e.buttons !== 1) return - if (interactive(e.target)) return - - const win = getWin() - if (!win?.startDragging) return - - e.preventDefault() - void win.startDragging().catch(() => undefined) - } - - const maximize = (e: MouseEvent) => { - if (platform.platform !== "desktop") return - if (interactive(e.target)) return - if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return - - const win = getWin() - if (!win?.toggleMaximize) return - - e.preventDefault() - void win.toggleMaximize().catch(() => undefined) - } - return (
@@ -528,9 +451,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
- -
-
) }} @@ -550,7 +470,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
- {/*
*/}
- {!tauriApi() &&
} -
+
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index cf343bfa7e91..4c374961a3f6 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -97,6 +97,9 @@ type PlatformBase = { /** Webview zoom level (desktop only) */ webviewZoom?: Accessor + /** Whether the native desktop window is fullscreen */ + windowFullscreen?: Accessor + /** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */ getPinchZoomEnabled?(): Promise | boolean diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 4563b688acea..5ecd4cb8c45a 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -227,6 +227,11 @@ export function registerIpcHandlers(deps: Deps) { return win?.isFocused() ?? false }) + ipcMain.handle("get-window-fullscreen", (event: IpcMainInvokeEvent) => { + const win = BrowserWindow.fromWebContents(event.sender) + return win?.isFullScreen() ?? false + }) + ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) win?.focus() diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index 7a594b5302e9..516d05e634ad 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -219,6 +219,7 @@ export function createMainWindow(id: string = randomUUID()) { state.manage(win) registerWindow(win, id) + wireFullscreen(win) loadWindow(win, "index.html") wireZoom(win) @@ -472,6 +473,11 @@ function wireZoom(win: BrowserWindow) { }) } +function wireFullscreen(win: BrowserWindow) { + win.on("enter-full-screen", () => win.webContents.send("window-fullscreen-changed", true)) + win.on("leave-full-screen", () => win.webContents.send("window-fullscreen-changed", false)) +} + function clampZoom(value: number) { return Math.min(Math.max(value, minZoomLevel), maxZoomLevel) } diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 87eac1fa805e..1a0056dfe711 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -100,6 +100,12 @@ const api: ElectronAPI = { readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"), showNotification: (title, body) => ipcRenderer.send("show-notification", title, body), getWindowFocused: () => ipcRenderer.invoke("get-window-focused"), + getWindowFullscreen: () => ipcRenderer.invoke("get-window-fullscreen"), + onWindowFullscreenChanged: (cb) => { + const handler = (_: unknown, fullscreen: boolean) => cb(fullscreen) + ipcRenderer.on("window-fullscreen-changed", handler) + return () => ipcRenderer.removeListener("window-fullscreen-changed", handler) + }, setWindowFocus: () => ipcRenderer.invoke("set-window-focus"), showWindow: () => ipcRenderer.invoke("show-window"), relaunch: () => ipcRenderer.send("relaunch"), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 5fbace72c7ca..ea201af63edc 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -91,6 +91,8 @@ export type ElectronAPI = { readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null> showNotification: (title: string, body?: string) => void getWindowFocused: () => Promise + getWindowFullscreen: () => Promise + onWindowFullscreenChanged: (cb: (fullscreen: boolean) => void) => () => void setWindowFocus: () => Promise showWindow: () => Promise relaunch: () => void diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index b7d2d9a74dad..fa4c7f325e39 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -30,6 +30,10 @@ import "./styles.css" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme/context" +const [windowFullscreen, setWindowFullscreen] = createSignal(false) +window.api.onWindowFullscreenChanged(setWindowFullscreen) +void window.api.getWindowFullscreen().then(setWindowFullscreen) + const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error(t("error.dev.rootNotFound")) @@ -294,6 +298,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { webviewZoom, + windowFullscreen, + getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(), setPinchZoomEnabled,