diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 535bd72064..f0d1968493 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -193,6 +193,12 @@ export const SettingsGeneral: Component = () => { { initialValue: null as DisplayBackend | null }, ) + const [pinchZoom, { mutate: setPinchZoom }] = createResource( + () => (desktop() && platform.getPinchZoomEnabled ? true : false), + () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), + { initialValue: false }, + ) + onMount(() => { void theme.loadThemes() }) @@ -239,6 +245,13 @@ export const SettingsGeneral: Component = () => { }) } + const onPinchZoomChange = (checked: boolean) => { + setPinchZoom(checked) + const update = platform.setPinchZoomEnabled?.(checked) + if (!update) return + void update.catch(() => setPinchZoom(!checked)) + } + const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, { value: "light", label: language.t("theme.scheme.light") }, @@ -729,6 +742,48 @@ export const SettingsGeneral: Component = () => { ) + const DisplaySection = () => ( + +
+

{language.t("settings.general.section.display")}

+ + + +
+ +
+
+ + + + {language.t("settings.general.row.wayland.title")} + + + + + +
+ } + description={language.t("settings.general.row.wayland.description")} + > +
+ +
+ +
+ + + + ) + console.log(import.meta.env) return (
@@ -749,31 +804,7 @@ export const SettingsGeneral: Component = () => { - -
-

{language.t("settings.general.section.display")}

- - - - {language.t("settings.general.row.wayland.title")} - - - - - -
- } - description={language.t("settings.general.row.wayland.description")} - > -
- -
- - -
- + diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index b77006b7f1..4ffcedd7c8 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -22,6 +22,7 @@ import { iife } from "@opencode-ai/core/util/iife" import { base64Encode } from "@opencode-ai/core/util/encode" import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" +import { makeEventListener } from "@solid-primitives/event-listener" type TauriDesktopWindow = { startDragging?: () => Promise @@ -298,6 +299,34 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), ) + const currentSessionTab = () => { + if (!params.dir || !params.id) return + const href = makeSessionHref(params.dir, params.id) + if (!tabsStore.some((tab) => tab.href === href)) return + return href + } + + const closeCurrentSessionTab = () => { + const href = currentSessionTab() + if (!href) return false + tabsStoreActions.removeTab(href) + return true + } + + makeEventListener( + document, + "keydown", + (event) => { + if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return + if (event.key.toLowerCase() !== "w") return + if (!closeCurrentSessionTab()) return + + event.preventDefault() + event.stopPropagation() + }, + { capture: true }, + ) + const tabsEnriched = iife(() => { const base = mapArray( () => tabsStore, @@ -578,15 +607,15 @@ function TabNavItem(props: { const isActive = () => !!match() return (
- {props.title} + {props.title}
@@ -624,7 +653,7 @@ function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) { return ( -
+
+ /** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */ + getPinchZoomEnabled?(): Promise | boolean + + /** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */ + setPinchZoomEnabled?(enabled: boolean): Promise | void + /** Run a desktop-only menu action from the app chrome */ runDesktopMenuAction?(action: DesktopMenuAction): Promise | void diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c6b6152f2a..bc4bf07b1f 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -784,6 +784,8 @@ export const dict = { "settings.general.row.showSessionProgressBar.title": "Show session progress bar", "settings.general.row.showSessionProgressBar.description": "Display the animated progress bar at the top of the session when the agent is working", + "settings.general.row.pinchZoom.title": "Pinch to zoom", + "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom", "settings.general.row.wayland.title": "Use native Wayland", "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.", diff --git a/packages/desktop/src/main/constants.ts b/packages/desktop/src/main/constants.ts index 1e21661c1a..d127794e9c 100644 --- a/packages/desktop/src/main/constants.ts +++ b/packages/desktop/src/main/constants.ts @@ -7,4 +7,5 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod export const SETTINGS_STORE = "opencode.settings" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" export const WSL_ENABLED_KEY = "wslEnabled" +export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 21bc17294e..a1bdfa3ddf 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -14,7 +14,7 @@ import type { } from "../preload/types" import { runDesktopMenuAction } from "./desktop-menu-actions" import { getStore } from "./store" -import { setTitlebar, updateTitlebar } from "./windows" +import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows" const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined @@ -202,6 +202,10 @@ export function registerIpcHandlers(deps: Deps) { if (!win) return updateTitlebar(win) }) + ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled()) + ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => { + setPinchZoomEnabled(enabled) + }) ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index 052e1ee502..3852970cb2 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -3,7 +3,9 @@ import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } f import { dirname, isAbsolute, join, relative, resolve } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" import type { TitlebarTheme } from "../preload/types" +import { PINCH_ZOOM_ENABLED_KEY } from "./constants" import { exportDebugLogs, write as writeLog } from "./logging" +import { getStore } from "./store" import { createUnresponsiveSampler } from "./unresponsive" const root = dirname(fileURLToPath(import.meta.url)) @@ -33,7 +35,10 @@ let relaunchHandler = () => { app.exit(0) } const titlebarThemes = new WeakMap>() +const pinchZoomEnabled = new WeakMap() const titlebarHeight = 40 +const maxZoomLevel = 10 +const minZoomLevel = 0.2 export function setRelaunchHandler(handler: () => void) { relaunchHandler = handler @@ -79,6 +84,20 @@ export function updateTitlebar(win: BrowserWindow) { win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor())) } +export function setPinchZoomEnabled(enabled: boolean) { + getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled) + for (const win of BrowserWindow.getAllWindows()) { + pinchZoomEnabled.set(win, enabled) + win.webContents.send("pinch-zoom-enabled-changed", enabled) + if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1) + updateZoom(win) + } +} + +export function getPinchZoomEnabled() { + return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true +} + export function setDockIcon() { if (process.platform !== "darwin") return const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png")) @@ -392,13 +411,31 @@ function isRendererUrl(value?: string, html = false) { } function wireZoom(win: BrowserWindow) { + pinchZoomEnabled.set(win, getPinchZoomEnabled()) win.webContents.setZoomFactor(1) - win.webContents.on("zoom-changed", () => { - win.webContents.setZoomFactor(1) - updateTitlebar(win) + win.webContents.on("zoom-changed", (event, zoomDirection) => { + event.preventDefault() + if (pinchZoomEnabled.get(win)) { + win.webContents.setZoomFactor( + clampZoom(win.webContents.getZoomFactor() + (zoomDirection === "in" ? 0.2 : -0.2)), + ) + updateZoom(win) + return + } + if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1) + updateZoom(win) }) } +function clampZoom(value: number) { + return Math.min(Math.max(value, minZoomLevel), maxZoomLevel) +} + +function updateZoom(win: BrowserWindow) { + updateTitlebar(win) + win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor()) +} + function upsertKeyValue(obj: Record, keyToChange: string, value: any) { const keyToChangeLower = keyToChange.toLowerCase() for (const key of Object.keys(obj)) { diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index ac69632037..f6d83a270f 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -60,6 +60,18 @@ const api: ElectronAPI = { relaunch: () => ipcRenderer.send("relaunch"), getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"), setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor), + getPinchZoomEnabled: () => ipcRenderer.invoke("get-pinch-zoom-enabled"), + setPinchZoomEnabled: (enabled) => ipcRenderer.invoke("set-pinch-zoom-enabled", enabled), + onPinchZoomEnabledChanged: (cb) => { + const handler = (_: unknown, enabled: boolean) => cb(enabled) + ipcRenderer.on("pinch-zoom-enabled-changed", handler) + return () => ipcRenderer.removeListener("pinch-zoom-enabled-changed", handler) + }, + onZoomFactorChanged: (cb) => { + const handler = (_: unknown, factor: number) => cb(factor) + ipcRenderer.on("zoom-factor-changed", handler) + return () => ipcRenderer.removeListener("zoom-factor-changed", handler) + }, setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme), runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action), loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 055f8589b3..081659d2b6 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -79,6 +79,10 @@ export type ElectronAPI = { relaunch: () => void getZoomFactor: () => Promise setZoomFactor: (factor: number) => Promise + getPinchZoomEnabled: () => Promise + setPinchZoomEnabled: (enabled: boolean) => Promise + onPinchZoomEnabledChanged: (cb: (enabled: boolean) => void) => () => void + onZoomFactorChanged: (cb: (factor: number) => void) => () => void setTitlebar: (theme: TitlebarTheme) => Promise runDesktopMenuAction: (action: DesktopMenuAction) => Promise loadingWindowComplete: () => void diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index e0b0ad2bbf..3ccd34596e 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -21,7 +21,7 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" -import { resetZoom, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" +import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" import "./styles.css" import { useTheme } from "@opencode-ai/ui/theme" @@ -274,6 +274,10 @@ const createPlatform = (): Platform => { webviewZoom, + getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(), + + setPinchZoomEnabled, + runDesktopMenuAction, checkAppExists: async (appName: string) => { diff --git a/packages/desktop/src/renderer/webview-zoom.ts b/packages/desktop/src/renderer/webview-zoom.ts index 843be46378..7993bf5ee2 100644 --- a/packages/desktop/src/renderer/webview-zoom.ts +++ b/packages/desktop/src/renderer/webview-zoom.ts @@ -13,9 +13,21 @@ const OS_NAME = (() => { const [webviewZoom, setWebviewZoom] = createSignal(1) let requestedZoom = 1 +let pinchZoomEnabled = false +let wheelPinch = undefined as + | { + active: boolean + startZoom: number + totalDelta: number + timeout: ReturnType | undefined + } + | undefined const MAX_ZOOM_LEVEL = 10 const MIN_ZOOM_LEVEL = 0.2 +const WHEEL_PINCH_THRESHOLD = 20 +const WHEEL_PINCH_STEP = 0.2 +const WHEEL_PINCH_END_DELAY = 160 const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) @@ -33,10 +45,77 @@ const applyZoom = (next: number) => { }) } +window.api.onZoomFactorChanged((factor) => { + requestedZoom = clamp(factor) + setWebviewZoom(requestedZoom) +}) + +void window.api.getPinchZoomEnabled().then((enabled) => { + pinchZoomEnabled = enabled +}) + +window.api.onPinchZoomEnabledChanged((enabled) => { + pinchZoomEnabled = enabled + resetWheelPinch() +}) + +const setPinchZoomEnabled = (enabled: boolean) => { + pinchZoomEnabled = enabled + resetWheelPinch() + return window.api.setPinchZoomEnabled(enabled) +} + const resetZoom = () => applyZoom(1) const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2)) const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2)) +const resetWheelPinch = () => { + clearTimeout(wheelPinch?.timeout) + wheelPinch = undefined +} + +const normalizeWheelDelta = (event: WheelEvent) => { + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16 + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * window.innerHeight + return event.deltaY +} + +const updateWheelPinch = (event: WheelEvent) => { + wheelPinch ??= { + active: false, + startZoom: requestedZoom, + totalDelta: 0, + timeout: undefined, + } + + clearTimeout(wheelPinch.timeout) + wheelPinch.timeout = setTimeout(resetWheelPinch, WHEEL_PINCH_END_DELAY) + wheelPinch.totalDelta += normalizeWheelDelta(event) + + if (!wheelPinch.active && Math.abs(wheelPinch.totalDelta) < WHEEL_PINCH_THRESHOLD) return + if (!wheelPinch.active) { + wheelPinch.active = true + wheelPinch.startZoom = requestedZoom + wheelPinch.totalDelta = 0 + return + } + + wheelPinch.active = true + applyZoom(clamp(wheelPinch.startZoom - (wheelPinch.totalDelta / WHEEL_PINCH_THRESHOLD) * WHEEL_PINCH_STEP)) +} + +window.addEventListener( + "wheel", + (event) => { + if (!pinchZoomEnabled) return + if (!event.ctrlKey) return + + event.preventDefault() + updateWheelPinch(event) + }, + { passive: false }, +) + window.addEventListener("keydown", (event) => { if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return @@ -56,4 +135,4 @@ window.addEventListener("keydown", (event) => { } }) -export { webviewZoom, resetZoom, zoomIn, zoomOut } +export { webviewZoom, resetZoom, setPinchZoomEnabled, zoomIn, zoomOut }