Skip to content

Commit 5bf3e82

Browse files
feat(desktop): add a zoom setting that persists (#59277)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
1 parent ed74ec8 commit 5bf3e82

21 files changed

Lines changed: 207 additions & 67 deletions

File tree

‎packages/app/src/runtime/i18n/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,8 @@ export const dict = {
819819
"settings.general.row.interfaceMode.inbox.description":
820820
"Every session across your servers, sorted by what needs you next.",
821821
"settings.general.row.interfaceMode.experimental": "Experimental",
822+
"settings.appearance.row.zoom.title": "Zoom",
823+
"settings.appearance.row.zoom.description": "Make text and controls larger across the app",
822824
"settings.appearance.row.reduceMotion.title": "Reduce motion",
823825
"settings.appearance.row.reduceMotion.description": "Reduce animations or match your system",
824826
"settings.appearance.row.reduceMotion.system": "System",

‎packages/app/src/runtime/platform/platform.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createSimpleContext } from "@opencode/ui/context"
22
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
33
import type { Accessor } from "solid-js"
4-
import type { DesktopMenuAction } from "@/shell/commands/desktop-menu"
4+
import type { DesktopMenuAction, DesktopZoomLevel } from "@/shell/commands/desktop-menu"
55
import type { DraftStore } from "@/runtime/persistence/drafts"
66
import type { Bridge } from "@opencode/gui-extensions/sdk/bridge"
77

@@ -85,6 +85,12 @@ type PlatformBase = {
8585
/** Webview zoom level (desktop only) */
8686
webviewZoom?: Accessor<number>
8787

88+
/** Saved interface zoom level; a pinch can move `webviewZoom` off it (desktop only) */
89+
zoomLevel?: Accessor<DesktopZoomLevel>
90+
91+
/** Save an interface zoom level and apply it to every window (desktop only) */
92+
setZoomLevel?(level: DesktopZoomLevel): Promise<void>
93+
8894
/** Whether the native desktop window is fullscreen */
8995
windowFullscreen?: Accessor<boolean>
9096

‎packages/app/src/settings/appearance/appearance.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component } from "solid-js"
1+
import { Component, Show } from "solid-js"
22
import { Select } from "@opencode/ui/select"
33
import { TextInput } from "@opencode/ui/text-input"
44
import { useLanguage } from "@/runtime/i18n/language"
@@ -135,6 +135,24 @@ export const SettingsAppearance: Component = () => {
135135
<FontSetting kind="code" fonts={appearance.fonts} />
136136
<FontSetting kind="terminal" fonts={appearance.fonts} />
137137

138+
<Show when={appearance.zoom.available()}>
139+
<SettingsRow
140+
title={language.t("settings.appearance.row.zoom.title")}
141+
description={language.t("settings.appearance.row.zoom.description")}
142+
>
143+
<Select
144+
data-action="settings-zoom"
145+
options={appearance.zoom.options}
146+
current={appearance.zoom.current()}
147+
placement="bottom-end"
148+
gutter={6}
149+
value={(level) => String(level)}
150+
label={appearance.zoom.label}
151+
onSelect={(level) => level && appearance.zoom.select(level)}
152+
/>
153+
</SettingsRow>
154+
</Show>
155+
138156
<SettingsRow
139157
title={language.t("settings.appearance.row.reduceMotion.title")}
140158
description={language.t("settings.appearance.row.reduceMotion.description")}

‎packages/app/src/settings/general/controllers.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
terminalInput,
1515
useSettings,
1616
} from "@/settings/model"
17+
import { usePlatform } from "@/runtime/platform/platform"
18+
import { desktopZoomLevels, type DesktopZoomLevel } from "@/shell/commands/desktop-menu"
1719
import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound"
1820
import { createSoundPreviewController } from "./behavior"
1921
import { ServerConnection } from "@/runtime/server/registry"
@@ -58,7 +60,7 @@ export function createServerShellController(server: Accessor<ServerConnection.An
5860
actions.mutate({ ...previous, shell: value || undefined })
5961
void serverCtx()
6062
.sdk.api.config.update({ shell: value || null })
61-
.catch((error: unknown) => {
63+
.catch((error) => {
6264
actions.mutate(previous)
6365
showToast({
6466
variant: "error",
@@ -72,6 +74,8 @@ export function createServerShellController(server: Accessor<ServerConnection.An
7274

7375
export function createAppearanceSettingsController() {
7476
const settings = useSettings()
77+
const platform = usePlatform()
78+
const language = useLanguage()
7579
const theme = useTheme()
7680
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
7781

@@ -87,6 +91,13 @@ export function createAppearanceSettingsController() {
8791
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
8892
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
8993
},
94+
zoom: {
95+
available: () => platform.zoomLevel !== undefined,
96+
options: [...desktopZoomLevels],
97+
current: () => platform.zoomLevel?.() ?? 1,
98+
label: (level: DesktopZoomLevel) => new Intl.NumberFormat(language.intl(), { style: "percent" }).format(level),
99+
select: (level: DesktopZoomLevel) => void platform.setZoomLevel?.(level),
100+
},
90101
reduceMotion: {
91102
current: settings.appearance.reduceMotion,
92103
select: (value: ReduceMotion) => settings.appearance.setReduceMotion(value),

‎packages/app/src/settings/search-catalog.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
136136
target: "settings-terminal-font",
137137
keywords: "terminal typeface",
138138
},
139+
{
140+
tab: "appearance",
141+
label: "settings.appearance.row.zoom.title",
142+
target: "settings-zoom",
143+
keywords: "scale size text font larger bigger accessibility",
144+
available: "desktop",
145+
},
139146
{
140147
tab: "appearance",
141148
label: "settings.appearance.row.reduceMotion.title",

‎packages/app/src/settings/search-results.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,12 @@ describe("settings search index", () => {
5151
test("only advertises settings supported by this platform", () => {
5252
const targets = (input: Parameters<typeof index>[0]) => index(input).map((item) => item.view.target)
5353
expect(targets({})).not.toContain("settings-pinch-zoom")
54+
expect(targets({})).not.toContain("settings-zoom")
5455
expect(targets({})).not.toContain("settings-experimental-browser")
5556
expect(targets({})).not.toContain("settings-show-project-icon")
5657
expect(targets({})).toContain("settings-tab-layout")
5758
expect(targets({ desktop: true })).toContain("settings-pinch-zoom")
59+
expect(targets({ desktop: true })).toContain("settings-zoom")
5860
expect(targets({ browser: true })).not.toContain("settings-experimental-browser")
5961
expect(targets({})).not.toContain("settings-show-project-icon")
6062
expect(targets({})).not.toContain("settings-mobile-titlebar-bottom")

‎packages/app/src/shell/commands/desktop-menu.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { sidebarToggleCommand } from "@/shell/shell"
55
import { previousSessionCommand, nextSessionCommand } from "@/shell/titlebar/tab-strip"
66
import { goBackCommand, goForwardCommand, newTabCommand } from "@/shell/titlebar/titlebar"
77
import { exportLogsCommand } from "./desktop"
8-
import { DESKTOP_MENU } from "./desktop-menu"
8+
import { DESKTOP_MENU, nextDesktopZoomLevel } from "./desktop-menu"
99

1010
test("every desktop menu command is one its owner registers", () => {
1111
const registered = new Set([
@@ -27,3 +27,20 @@ test("every desktop menu command is one its owner registers", () => {
2727

2828
expect(commands.filter((command) => !registered.has(command))).toEqual([])
2929
})
30+
31+
test.each([
32+
{ factor: 1, direction: "in", level: 1.1 },
33+
{ factor: 1.25, direction: "out", level: 1.1 },
34+
// A factor read back from Chromium sits a hair off the level it was set to.
35+
{ factor: 1.0999999, direction: "in", level: 1.25 },
36+
{ factor: 1.1000001, direction: "out", level: 1 },
37+
// A pinch leaves the factor between levels, or outside them.
38+
{ factor: 1.3, direction: "in", level: 1.5 },
39+
{ factor: 1.3, direction: "out", level: 1.25 },
40+
{ factor: 0.5, direction: "out", level: 1 },
41+
{ factor: 3, direction: "in", level: 2 },
42+
{ factor: 2, direction: "in", level: 2 },
43+
{ factor: 1, direction: "out", level: 1 },
44+
] as const)("zooming $direction from $factor lands on $level", ({ factor, direction, level }) => {
45+
expect(nextDesktopZoomLevel(factor, direction)).toBe(level)
46+
})

‎packages/app/src/shell/commands/desktop-menu.ts‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,12 @@ export type DesktopMenuRole =
3434
| "quit"
3535
| "redo"
3636
| "reload"
37-
| "resetZoom"
3837
| "selectAll"
3938
| "toggleDevTools"
4039
| "togglefullscreen"
4140
| "undo"
4241
| "unhide"
4342
| "windowMenu"
44-
| "zoomIn"
45-
| "zoomOut"
4643

4744
export type DesktopMenuItem = {
4845
type: "item"
@@ -62,6 +59,20 @@ export type DesktopMenuSeparator = {
6259

6360
export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
6461

62+
// Slack's desktop zoom levels from 100% up. Zoom In, Zoom Out and the Appearance setting all pick one.
63+
export const desktopZoomLevels = [1, 1.1, 1.25, 1.5, 1.75, 2] as const
64+
65+
export type DesktopZoomLevel = (typeof desktopZoomLevels)[number]
66+
67+
/** The level after `factor` in `direction`. A pinch can leave `factor` between two levels. */
68+
export function nextDesktopZoomLevel(factor: number, direction: "in" | "out"): DesktopZoomLevel {
69+
// Chromium stores zoom on a log scale, so a factor read back can be a hair off the level it was set to.
70+
if (direction === "in")
71+
return desktopZoomLevels.find((level) => level > factor + 0.01) ?? desktopZoomLevels[desktopZoomLevels.length - 1]
72+
73+
return desktopZoomLevels.findLast((level) => level < factor - 0.01) ?? desktopZoomLevels[0]
74+
}
75+
6576
export type DesktopMenu = {
6677
id: string
6778
labelKey: DesktopNativeKey
@@ -202,22 +213,19 @@ export const DESKTOP_MENU: DesktopMenu[] = [
202213
type: "item",
203214
labelKey: "desktop.menu.actualSize",
204215
action: "view.resetZoom",
205-
role: "resetZoom",
206-
accelerator: { windows: "Ctrl+0" },
216+
accelerator: { macos: "Cmd+0", windows: "Ctrl+0" },
207217
},
208218
{
209219
type: "item",
210220
labelKey: "desktop.menu.zoomIn",
211221
action: "view.zoomIn",
212-
role: "zoomIn",
213-
accelerator: { windows: "Ctrl++" },
222+
accelerator: { macos: "Cmd+=", windows: "Ctrl++" },
214223
},
215224
{
216225
type: "item",
217226
labelKey: "desktop.menu.zoomOut",
218227
action: "view.zoomOut",
219-
role: "zoomOut",
220-
accelerator: { windows: "Ctrl+-" },
228+
accelerator: { macos: "Cmd+-", windows: "Ctrl+-" },
221229
},
222230
{ type: "separator" },
223231
{

‎packages/desktop/src/main/ipc-handlers/window.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {
88
setTitlebar,
99
setTrafficLightsPosition,
1010
setWindowThemeReady,
11+
setZoomLevel,
1112
updateTitlebar,
1213
} from "../windows"
14+
import { storedZoomLevel } from "../windows/defaults"
1315
import { focusWindow, showWindow } from "../windows/focus"
1416
import { sender } from "./context"
1517

@@ -50,6 +52,8 @@ export const windowHandlers = WindowRpcs.toLayer(
5052

5153
if (win) updateTitlebar(win)
5254
}),
55+
WindowGetZoomLevel: () => Effect.sync(storedZoomLevel),
56+
WindowSetZoomLevel: ({ level }) => Effect.sync(() => setZoomLevel(level)),
5357
WindowGetPinchZoomEnabled: () => Effect.sync(getPinchZoomEnabled),
5458
WindowSetPinchZoomEnabled: ({ enabled }) => Effect.sync(() => setPinchZoomEnabled(enabled)),
5559
WindowSetTitlebar: ({ theme }, context) =>

‎packages/desktop/src/main/native/menu-actions.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BrowserWindow } from "electron"
2-
import type { DesktopMenuAction } from "@opencode/app/desktop-menu"
3-
import { setZoomFactor } from "../windows"
2+
import { nextDesktopZoomLevel, type DesktopMenuAction } from "@opencode/app/desktop-menu"
3+
import { setZoomLevel } from "../windows"
44

55
export type DesktopMenuActionHandlers = Partial<{
66
installCli: () => void
@@ -53,15 +53,15 @@ export function runDesktopMenuAction(
5353

5454
return
5555
case "view.resetZoom":
56-
if (win) setZoomFactor(win, 1)
56+
setZoomLevel(1)
5757

5858
return
5959
case "view.zoomIn":
60-
if (win) setZoomFactor(win, win.webContents.getZoomFactor() + 0.2)
60+
if (win) setZoomLevel(nextDesktopZoomLevel(win.webContents.getZoomFactor(), "in"))
6161

6262
return
6363
case "view.zoomOut":
64-
if (win) setZoomFactor(win, win.webContents.getZoomFactor() - 0.2)
64+
if (win) setZoomLevel(nextDesktopZoomLevel(win.webContents.getZoomFactor(), "out"))
6565

6666
return
6767
case "view.toggleFullscreen":

0 commit comments

Comments
 (0)