diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index cda6c0f2ed..efed7a7fb2 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -1,22 +1,15 @@ import * as React from "react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { + adjustTextScale, + applyCurrentTextScale, + DEFAULT_TEXT_SCALE, + type TextScaleAction, +} from "@/shared/lib/textScale"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; -const DEFAULT_ZOOM_FACTOR = 1; -const MIN_ZOOM_FACTOR = 0.75; -const MAX_ZOOM_FACTOR = 1.5; -const ZOOM_STEP = 0.1; -const BASE_FONT_SIZE_PX = 16; -const TEXT_SCALE_STORAGE_KEY = "buzz:text-scale"; - -type ZoomAction = "increase" | "decrease" | "reset"; - -function roundZoomFactor(zoomFactor: number) { - return Math.round(zoomFactor * 10) / 10; -} - -function getZoomAction(event: KeyboardEvent): ZoomAction | null { +function getZoomAction(event: KeyboardEvent): TextScaleAction | null { if (!hasPrimaryShortcutModifier(event) || event.altKey) { return null; } @@ -49,55 +42,18 @@ function getZoomAction(event: KeyboardEvent): ZoomAction | null { return null; } -function getNextZoomFactor(action: ZoomAction, zoomFactor: number) { - if (action === "reset") { - return DEFAULT_ZOOM_FACTOR; - } - - if (action === "increase") { - return Math.min(roundZoomFactor(zoomFactor + ZOOM_STEP), MAX_ZOOM_FACTOR); - } - - return Math.max(roundZoomFactor(zoomFactor - ZOOM_STEP), MIN_ZOOM_FACTOR); -} - -function readStoredZoomFactor() { - const raw = window.localStorage.getItem(TEXT_SCALE_STORAGE_KEY); - if (!raw) { - return DEFAULT_ZOOM_FACTOR; - } - - const parsed = Number.parseFloat(raw); - if (!Number.isFinite(parsed)) { - return DEFAULT_ZOOM_FACTOR; - } - - return Math.min(Math.max(parsed, MIN_ZOOM_FACTOR), MAX_ZOOM_FACTOR); -} - -function applyTextScale(zoomFactor: number) { - if (zoomFactor === DEFAULT_ZOOM_FACTOR) { - document.documentElement.style.fontSize = ""; - window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY); - return; - } - - document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`; - window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor)); -} - +/** + * Bootstraps persisted text scale and wires Cmd/Ctrl +/- / 0 shortcuts. + * Layout zoom stays on the root font-size; native webview zoom is pinned at 1. + */ export function useWebviewZoomShortcuts() { - const zoomFactorRef = React.useRef(DEFAULT_ZOOM_FACTOR); - React.useLayoutEffect(() => { const webview = getCurrentWebview(); - const storedZoomFactor = readStoredZoomFactor(); - zoomFactorRef.current = storedZoomFactor; - applyTextScale(storedZoomFactor); + applyCurrentTextScale(); // Keep the webview coordinate system stable; only text should scale. - void webview.setZoom(DEFAULT_ZOOM_FACTOR).catch((error) => { + void webview.setZoom(DEFAULT_TEXT_SCALE).catch((error) => { console.error("Failed to reset webview zoom", error); }); @@ -108,16 +64,7 @@ export function useWebviewZoomShortcuts() { } event.preventDefault(); - - const previousZoomFactor = zoomFactorRef.current; - const nextZoomFactor = getNextZoomFactor(action, previousZoomFactor); - - if (nextZoomFactor === previousZoomFactor) { - return; - } - - zoomFactorRef.current = nextZoomFactor; - applyTextScale(nextZoomFactor); + adjustTextScale(action); } window.addEventListener("keydown", handleKeyDown); diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 5c997efbc4..ed59615ae1 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -38,6 +38,16 @@ import { type ThreadViewMode, } from "@/features/channels/lib/threadViewModePreference"; import { cn } from "@/shared/lib/cn"; +import { + DEFAULT_TEXT_SCALE, + formatTextScalePercent, + MAX_TEXT_SCALE, + MIN_TEXT_SCALE, + setTextScale, + TEXT_SCALE_PRESETS, + textScalePresetIndex, +} from "@/shared/lib/textScale"; +import { useTextScale } from "@/shared/lib/useTextScale"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -526,9 +536,11 @@ function ThemeSettingsCard() { > + + {/* Mode selector: System / Light / Dark */}
{( @@ -660,6 +672,81 @@ function ThemeSettingsCard() { ); } +/** + * Interface scale slider. Shares the same text-scale store as Cmd/Ctrl +/- + * so keyboard zoom and this control stay in sync. + */ +function TextScaleSetting() { + const textScale = useTextScale(); + const isDefault = textScale === DEFAULT_TEXT_SCALE; + const presetIndex = textScalePresetIndex(textScale); + + return ( + + +
+

Interface scale

+

+ Enlarge or shrink text and UI. Keyboard: Cmd/Ctrl + / − / 0. +

+
+
+ + { + const index = Number(event.target.value); + const next = TEXT_SCALE_PRESETS[index]; + if (next != null) { + setTextScale(next); + } + }} + step={1} + type="range" + value={presetIndex} + /> + + + {formatTextScalePercent(textScale)} + + +
+
+
+ ); +} + const THREAD_VIEW_MODE_OPTIONS: { value: ThreadViewMode; label: string; diff --git a/desktop/src/shared/lib/textScale.test.mjs b/desktop/src/shared/lib/textScale.test.mjs new file mode 100644 index 0000000000..634c219d78 --- /dev/null +++ b/desktop/src/shared/lib/textScale.test.mjs @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const KEY = "buzz:text-scale"; +let importSequence = 0; + +async function withStorage(storage, run) { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + "localStorage", + ); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }); + try { + const module = await import(`./textScale.ts?test=${importSequence++}`); + await run(module); + } finally { + if (descriptor) + Object.defineProperty(globalThis, "localStorage", descriptor); + else delete globalThis.localStorage; + } +} + +test("missing, malformed, and unreadable preferences default to 1", async () => { + for (const stored of [null, "nope", "NaN"]) { + await withStorage( + { getItem: (key) => (key === KEY ? stored : null), setItem() {} }, + ({ getTextScale, DEFAULT_TEXT_SCALE }) => { + assert.equal(getTextScale(), DEFAULT_TEXT_SCALE); + }, + ); + } + + await withStorage( + { + getItem() { + throw new Error("storage unavailable"); + }, + setItem() {}, + }, + ({ getTextScale }) => { + assert.equal(getTextScale(), 1); + }, + ); +}); + +test("loads a stored scale and clamps out-of-range values", async () => { + await withStorage( + { + getItem: (key) => (key === KEY ? "1.2" : null), + setItem() {}, + removeItem() {}, + }, + ({ getTextScale }) => { + assert.equal(getTextScale(), 1.2); + }, + ); + + await withStorage( + { + getItem: (key) => (key === KEY ? "9" : null), + setItem() {}, + removeItem() {}, + }, + ({ getTextScale, MAX_TEXT_SCALE }) => { + assert.equal(getTextScale(), MAX_TEXT_SCALE); + }, + ); + + await withStorage( + { + getItem: (key) => (key === KEY ? "0.1" : null), + setItem() {}, + removeItem() {}, + }, + ({ getTextScale, MIN_TEXT_SCALE }) => { + assert.equal(getTextScale(), MIN_TEXT_SCALE); + }, + ); +}); + +test("writes non-default scale and removes storage at default", async () => { + const writes = []; + const removals = []; + await withStorage( + { + getItem: () => null, + setItem: (key, value) => writes.push([key, value]), + removeItem: (key) => removals.push(key), + }, + ({ setTextScale, getTextScale, DEFAULT_TEXT_SCALE }) => { + setTextScale(1.3); + assert.equal(getTextScale(), 1.3); + assert.deepEqual(writes, [[KEY, "1.3"]]); + + setTextScale(DEFAULT_TEXT_SCALE); + assert.equal(getTextScale(), DEFAULT_TEXT_SCALE); + assert.deepEqual(removals, [KEY]); + }, + ); +}); + +test("adjustTextScale steps and resets within bounds", async () => { + await withStorage( + { + getItem: () => null, + setItem() {}, + removeItem() {}, + }, + ({ + adjustTextScale, + getTextScale, + setTextScale, + MIN_TEXT_SCALE, + MAX_TEXT_SCALE, + DEFAULT_TEXT_SCALE, + }) => { + setTextScale(1); + assert.equal(adjustTextScale("increase"), 1.1); + assert.equal(adjustTextScale("decrease"), 1); + assert.equal(adjustTextScale("reset"), DEFAULT_TEXT_SCALE); + + setTextScale(MAX_TEXT_SCALE); + assert.equal(adjustTextScale("increase"), MAX_TEXT_SCALE); + + setTextScale(MIN_TEXT_SCALE); + assert.equal(adjustTextScale("decrease"), MIN_TEXT_SCALE); + }, + ); +}); + +test("formatTextScalePercent and normalize helpers", async () => { + await withStorage( + { getItem: () => null, setItem() {}, removeItem() {} }, + ({ + formatTextScalePercent, + normalizeTextScale, + clampTextScale, + textScalePresetIndex, + TEXT_SCALE_PRESETS, + }) => { + assert.equal(formatTextScalePercent(1), "100%"); + // Midpoint 1.25 is equidistant from 1.2 and 1.3; first-best wins → 1.2. + assert.equal(formatTextScalePercent(1.25), "120%"); + assert.equal(normalizeTextScale(1.26), 1.3); + assert.equal(normalizeTextScale(1.24), 1.2); + assert.equal(normalizeTextScale(0.75), 0.75); + assert.equal(clampTextScale(2), 1.5); + assert.equal(normalizeTextScale(Number.NaN), 1); + assert.equal(textScalePresetIndex(1), TEXT_SCALE_PRESETS.indexOf(1)); + assert.equal(textScalePresetIndex(0.75), 0); + assert.equal( + textScalePresetIndex(1.5), + TEXT_SCALE_PRESETS.length - 1, + ); + }, + ); +}); + +test("keeps the in-memory choice when persistence fails", async () => { + await withStorage( + { + getItem: () => null, + setItem() { + throw new Error("quota exceeded"); + }, + removeItem() { + throw new Error("quota exceeded"); + }, + }, + ({ getTextScale, setTextScale }) => { + assert.doesNotThrow(() => setTextScale(1.4)); + assert.equal(getTextScale(), 1.4); + }, + ); +}); diff --git a/desktop/src/shared/lib/textScale.ts b/desktop/src/shared/lib/textScale.ts new file mode 100644 index 0000000000..7d35bc0f51 --- /dev/null +++ b/desktop/src/shared/lib/textScale.ts @@ -0,0 +1,185 @@ +/** + * App text / UI scale preference. + * + * Scales the root `` font-size so rem-based layout grows or shrinks. + * Native webview zoom stays pinned at 1 so layout coordinates stay stable + * (see `useWebviewZoomShortcuts`). + * + * Persisted in localStorage under {@link TEXT_SCALE_STORAGE_KEY}. Device-level + * only — not community-scoped. + * + * React hook: {@link useTextScale} in `./useTextScale`. + */ + +export const TEXT_SCALE_STORAGE_KEY = "buzz:text-scale"; + +export const DEFAULT_TEXT_SCALE = 1; +export const MIN_TEXT_SCALE = 0.75; +export const MAX_TEXT_SCALE = 1.5; +export const TEXT_SCALE_STEP = 0.1; + +/** + * Discrete levels for the Appearance slider. Matches the keyboard path from + * 100% (Cmd/Ctrl +/- steps of 10%) plus the 75% floor reached by clamping. + */ +export const TEXT_SCALE_PRESETS = [ + 0.75, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, +] as const; + +const BASE_FONT_SIZE_PX = 16; + +const listeners = new Set<() => void>(); + +let textScale = readStoredTextScale(); + +export function roundTextScale(scale: number): number { + return Math.round(scale * 10) / 10; +} + +export function clampTextScale(scale: number): number { + return Math.min(Math.max(scale, MIN_TEXT_SCALE), MAX_TEXT_SCALE); +} + +/** + * Snap a raw scale to the nearest supported preset. + * + * Note: 0.75 is intentionally supported even though it is not on the 0.1 + * grid from 1.0 — keyboard zoom reaches it by clamping a stepped 0.7. + * Do not round-then-clamp with `Math.round(x * 10) / 10` alone: that turns + * 0.75 into 0.8. + */ +export function normalizeTextScale(scale: number): number { + if (!Number.isFinite(scale)) { + return DEFAULT_TEXT_SCALE; + } + + let best: number = TEXT_SCALE_PRESETS[0]; + let bestDistance = Number.POSITIVE_INFINITY; + for (const preset of TEXT_SCALE_PRESETS) { + const distance = Math.abs(preset - scale); + if (distance < bestDistance) { + bestDistance = distance; + best = preset; + } + } + return best; +} + +function readStoredTextScale(): number { + try { + const raw = globalThis.localStorage?.getItem(TEXT_SCALE_STORAGE_KEY); + if (!raw) { + return DEFAULT_TEXT_SCALE; + } + return normalizeTextScale(Number.parseFloat(raw)); + } catch { + return DEFAULT_TEXT_SCALE; + } +} + +function applyTextScaleToDocument(scale: number): void { + if (typeof document === "undefined") { + return; + } + + if (scale === DEFAULT_TEXT_SCALE) { + document.documentElement.style.fontSize = ""; + return; + } + + document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * scale}px`; +} + +function persistTextScale(scale: number): void { + try { + if (scale === DEFAULT_TEXT_SCALE) { + globalThis.localStorage?.removeItem(TEXT_SCALE_STORAGE_KEY); + } else { + globalThis.localStorage?.setItem(TEXT_SCALE_STORAGE_KEY, String(scale)); + } + } catch { + // Persistence is best-effort; the in-memory value still applies. + } +} + +function notify(): void { + for (const listener of listeners) { + listener(); + } +} + +/** Read the current text scale outside of React. */ +export function getTextScale(): number { + return textScale; +} + +/** + * Set text scale, apply it to the document root, persist, and notify + * subscribers. Values outside the supported range are clamped. + */ +export function setTextScale(scale: number): void { + const next = normalizeTextScale(scale); + if (next === textScale) { + applyTextScaleToDocument(next); + return; + } + + textScale = next; + applyTextScaleToDocument(next); + persistTextScale(next); + notify(); +} + +export type TextScaleAction = "increase" | "decrease" | "reset"; + +/** Step text scale up/down or reset to default (same steps as keyboard zoom). */ +export function adjustTextScale(action: TextScaleAction): number { + if (action === "reset") { + setTextScale(DEFAULT_TEXT_SCALE); + return textScale; + } + + if (action === "increase") { + setTextScale(textScale + TEXT_SCALE_STEP); + return textScale; + } + + setTextScale(textScale - TEXT_SCALE_STEP); + return textScale; +} + +/** + * Apply the current in-memory scale to the document (e.g. on app boot after + * module load, or after restoring from storage in a new document). + */ +export function applyCurrentTextScale(): void { + applyTextScaleToDocument(textScale); +} + +export function subscribeTextScale(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getTextScaleSnapshot(): number { + return textScale; +} + +export function getTextScaleServerSnapshot(): number { + return DEFAULT_TEXT_SCALE; +} + +/** Format a scale factor as a percentage label (e.g. 1.2 → "120%"). */ +export function formatTextScalePercent(scale: number): string { + return `${Math.round(normalizeTextScale(scale) * 100)}%`; +} + +/** Nearest index into {@link TEXT_SCALE_PRESETS} for a scale factor. */ +export function textScalePresetIndex(scale: number): number { + const target = normalizeTextScale(scale); + return TEXT_SCALE_PRESETS.indexOf( + target as (typeof TEXT_SCALE_PRESETS)[number], + ); +} diff --git a/desktop/src/shared/lib/useTextScale.ts b/desktop/src/shared/lib/useTextScale.ts new file mode 100644 index 0000000000..0a8d8f12ae --- /dev/null +++ b/desktop/src/shared/lib/useTextScale.ts @@ -0,0 +1,16 @@ +import * as React from "react"; + +import { + getTextScaleServerSnapshot, + getTextScaleSnapshot, + subscribeTextScale, +} from "@/shared/lib/textScale"; + +/** React hook for the current text / UI scale factor. */ +export function useTextScale(): number { + return React.useSyncExternalStore( + subscribeTextScale, + getTextScaleSnapshot, + getTextScaleServerSnapshot, + ); +}