Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 14 additions & 67 deletions desktop/src/app/useWebviewZoomShortcuts.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -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);
});

Expand All @@ -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);
Expand Down
89 changes: 88 additions & 1 deletion desktop/src/features/settings/ui/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -526,9 +536,11 @@ function ThemeSettingsCard() {
>
<SettingsSectionHeader
title="Appearance"
description="Choose a theme for Buzz."
description="Choose a theme and interface scale for Buzz."
/>

<TextScaleSetting />

{/* Mode selector: System / Light / Dark */}
<div className="mb-4 flex gap-2">
{(
Expand Down Expand Up @@ -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 (
<SettingsOptionGroup className="mb-6">
<SettingsOptionRow className="flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Interface scale</p>
<p className="text-sm font-normal text-muted-foreground">
Enlarge or shrink text and UI. Keyboard: Cmd/Ctrl + / − / 0.
</p>
</div>
<div className="flex min-w-0 items-center gap-3 sm:max-w-xs sm:flex-1">
<span
aria-hidden="true"
className="shrink-0 text-2xs tabular-nums text-muted-foreground"
>
{formatTextScalePercent(MIN_TEXT_SCALE)}
</span>
<input
aria-label="Interface scale"
aria-valuemax={TEXT_SCALE_PRESETS.length - 1}
aria-valuemin={0}
aria-valuenow={presetIndex}
aria-valuetext={formatTextScalePercent(textScale)}
className="h-1.5 w-full min-w-24 cursor-pointer appearance-none rounded-full bg-foreground/15 accent-primary"
data-testid="interface-scale-slider"
max={TEXT_SCALE_PRESETS.length - 1}
min={0}
onChange={(event) => {
const index = Number(event.target.value);
const next = TEXT_SCALE_PRESETS[index];
if (next != null) {
setTextScale(next);
}
}}
step={1}
type="range"
value={presetIndex}
/>
<span
aria-hidden="true"
className="shrink-0 text-2xs tabular-nums text-muted-foreground"
>
{formatTextScalePercent(MAX_TEXT_SCALE)}
</span>
<span
className="w-10 shrink-0 text-right text-sm tabular-nums font-medium text-foreground"
data-testid="interface-scale-value"
>
{formatTextScalePercent(textScale)}
</span>
<Button
className="h-7 shrink-0 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium shadow-none hover:bg-muted/70 disabled:opacity-40"
data-testid="interface-scale-reset"
disabled={isDefault}
onClick={() => setTextScale(DEFAULT_TEXT_SCALE)}
size="sm"
type="button"
variant="ghost"
>
Reset
</Button>
</div>
</SettingsOptionRow>
</SettingsOptionGroup>
);
}

const THREAD_VIEW_MODE_OPTIONS: {
value: ThreadViewMode;
label: string;
Expand Down
Loading