Skip to content
Merged
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
140 changes: 139 additions & 1 deletion src/components/appearance-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

import { AppearanceProvider } from "./appearance-provider"
import { useCustomStyle } from "@/hooks/use-appearance"
import { STORAGE_KEY_CUSTOM_THEME } from "@/lib/appearance-script"
import {
STORAGE_KEY_CUSTOM_THEME,
STORAGE_KEY_ZOOM_LEVEL,
} from "@/lib/appearance-script"

function Probe() {
const { setCustomThemeToken } = useCustomStyle()
Expand Down Expand Up @@ -93,3 +96,138 @@ describe("debounced persistence", () => {
expect(storedPrimary()).toBe("#bbbbbb")
})
})

describe("window zoom keys", () => {
function startAt(zoom: number) {
document.documentElement.style.fontSize = `${(16 * zoom) / 100}px`
}

function currentZoomPx(): string {
return document.documentElement.style.fontSize
}

function keydown(
key: string,
init: KeyboardEventInit = {},
target: EventTarget = window
) {
target.dispatchEvent(
new KeyboardEvent("keydown", {
key,
ctrlKey: true,
bubbles: true,
cancelable: true,
...init,
})
)
}

/** Zoom writes reach Tauri IPC and an on-disk SQLite upsert on the same path. */
function zoomWrites(spy: ReturnType<typeof vi.spyOn>): number {
return spy.mock.calls.filter(([key]) => key === STORAGE_KEY_ZOOM_LEVEL)
.length
}

function renderZoom() {
render(
<AppearanceProvider>
<div data-terminal-panel-region="true">
<span data-testid="terminal-child">term</span>
</div>
</AppearanceProvider>
)
return vi.spyOn(Storage.prototype, "setItem")
}

it("stops writing once a held zoom-out key hits the bottom of the range", () => {
// 80% is the lowest rung, so every further repeat is a no-op on screen. It
// used to persist and hit the DB once per repeat regardless.
startAt(80)
const setItem = renderZoom()

act(() => {
for (let i = 0; i < 5; i += 1) keydown("-", { repeat: i > 0 })
})

expect(zoomWrites(setItem)).toBe(0)
expect(currentZoomPx()).toBe("12.8px")
})

it("never writes when reset is held at 100%", () => {
// stepZoom clamping does not cover reset: it sets the default outright.
startAt(100)
const setItem = renderZoom()

act(() => {
for (let i = 0; i < 5; i += 1) keydown("0", { repeat: i > 0 })
})

expect(zoomWrites(setItem)).toBe(0)
expect(currentZoomPx()).toBe("16px")
})

it("walks one rung per repeat without dropping a step", () => {
// All three land in one act(), so no passive effect gets to run between
// them. Reading the level from an effect-synced ref would step 100 → 110
// three times over and lose two rungs.
startAt(100)
const setItem = renderZoom()

act(() => {
keydown("=")
keydown("=", { repeat: true })
keydown("=", { repeat: true })
})

expect(currentZoomPx()).toBe("24px") // 150%
expect(zoomWrites(setItem)).toBe(3)
})

it("preventDefaults a repeat so the webview does not also page-zoom", () => {
startAt(100)
renderZoom()

const held = new KeyboardEvent("keydown", {
key: "=",
ctrlKey: true,
repeat: true,
bubbles: true,
cancelable: true,
})
act(() => {
window.dispatchEvent(held)
})

expect(held.defaultPrevented).toBe(true)
})

it("declines Ctrl over the terminal but still zooms for Cmd", () => {
// Ctrl+- and Ctrl+= mean something to the shell; Cmd+- does not, so macOS
// keeps zooming over the terminal.
startAt(100)
const setItem = renderZoom()
const terminalChild = screen.getByTestId("terminal-child")

act(() => {
keydown("=", {}, terminalChild)
})
expect(zoomWrites(setItem)).toBe(0)
expect(currentZoomPx()).toBe("16px")

act(() => {
keydown("=", { ctrlKey: false, metaKey: true }, terminalChild)
})
expect(currentZoomPx()).toBe("17.6px") // 110%
})

it("reaches reset from the unshifted AZERTY zero key", () => {
startAt(125)
renderZoom()

act(() => {
keydown("à", { code: "Digit0" })
})

expect(currentZoomPx()).toBe("16px")
})
})
80 changes: 79 additions & 1 deletion src/components/appearance-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ZOOM_LEVELS,
DEFAULT_ZOOM_LEVEL,
type ZoomLevel,
stepZoom,
} from "@/lib/theme-presets"
import {
resolveFontStack,
Expand Down Expand Up @@ -65,7 +66,11 @@ import {
type CustomThemeToken,
} from "@/lib/custom-style"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
import {
isShortcutRecorderArmed,
matchShortcutEvent,
resolveWindowZoomAction,
} from "@/lib/keyboard-shortcuts"
import {
DEFAULT_WORKSPACE_BG_ENABLED,
DEFAULT_WORKSPACE_BG_MASK_OPACITY,
Expand Down Expand Up @@ -466,13 +471,31 @@ export function AppearanceProvider({
persist(STORAGE_KEY_THEME_COLOR, color)
}, [])

// Written here rather than in a passive effect: a held zoom key repeats every
// ~33 ms and must never read a level the last repeat already superseded, or
// the burst drops steps. Keeping the assignment out of the state updater
// keeps that updater free of side effects, which StrictMode double-invokes.
const zoomLevelRef = useRef(zoomLevel)

const setZoomLevel = useCallback((zoom: ZoomLevel) => {
// Re-applying the current level is not free: it reaches Tauri IPC and an
// on-disk SQLite upsert. Holding the key at either end of the range, or
// holding reset at 100%, would otherwise write once per repeat forever.
if (zoomLevelRef.current === zoom) return
zoomLevelRef.current = zoom
setZoomLevelState(zoom)
document.documentElement.style.fontSize = `${(16 * zoom) / 100}px`
syncTrafficLightPosition(zoom)
persist(STORAGE_KEY_ZOOM_LEVEL, String(zoom))
}, [])

const stepZoomLevel = useCallback(
(direction: 1 | -1) => {
setZoomLevel(stepZoom(zoomLevelRef.current, direction))
},
[setZoomLevel]
)

const setShowWelcomeQuickActions = useCallback((on: boolean) => {
setShowWelcomeQuickActionsState(on)
persist(STORAGE_KEY_WELCOME_QUICK_ACTIONS, on ? "1" : "0")
Expand Down Expand Up @@ -744,10 +767,14 @@ export function AppearanceProvider({
// 或某个组件吞掉了冒泡,这一路依然能把自定义样式整体停用。
const { shortcuts } = useShortcutSettings()
const toggleCustomStyleShortcut = shortcuts.toggle_custom_style
const zoomInShortcut = shortcuts.zoom_in
const zoomOutShortcut = shortcuts.zoom_out
const zoomResetShortcut = shortcuts.zoom_reset
useEffect(() => {
if (!toggleCustomStyleShortcut) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.repeat) return
if (isShortcutRecorderArmed()) return
if (!matchShortcutEvent(event, toggleCustomStyleShortcut)) return
event.preventDefault()
setCustomStyleSuspended(!customStyleSuspended)
Expand All @@ -756,6 +783,54 @@ export function AppearanceProvider({
return () => window.removeEventListener("keydown", onKeyDown, true)
}, [toggleCustomStyleShortcut, customStyleSuspended, setCustomStyleSuspended])

// Same levels as Settings → Window zoom. Capture-phase so the webview
// does not eat Ctrl/Cmd +/- as its own page zoom.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.isComposing) return
if (isShortcutRecorderArmed()) return
// Ctrl+- and Ctrl+= carry shell meaning inside the terminal, so decline
// there. Cmd+- does not, so on macOS the terminal keeps zooming.
if (
event.ctrlKey &&
!event.metaKey &&
event.target instanceof Element &&
event.target.closest('[data-terminal-panel-region="true"]')
) {
return
}

const action = resolveWindowZoomAction(event, {
zoom_in: zoomInShortcut,
zoom_out: zoomOutShortcut,
zoom_reset: zoomResetShortcut,
})
if (!action) return

// Match first, then preventDefault, including on repeats. A held
// key should walk the zoom levels, and in the browser the un-
// prevented repeat would also trigger the page's own zoom.
event.preventDefault()
if (action === "in") {
stepZoomLevel(1)
return
}
if (action === "out") {
stepZoomLevel(-1)
return
}
setZoomLevel(DEFAULT_ZOOM_LEVEL)
}
window.addEventListener("keydown", onKeyDown, true)
return () => window.removeEventListener("keydown", onKeyDown, true)
}, [
setZoomLevel,
stepZoomLevel,
zoomInShortcut,
zoomOutShortcut,
zoomResetShortcut,
])

// 跨标签页同步:用户在另一个窗口改了设置时,本窗口实时跟进
useEffect(() => {
const FONT_KEYS = new Set<string>([
Expand Down Expand Up @@ -818,6 +893,9 @@ export function AppearanceProvider({
if (e.key === STORAGE_KEY_ZOOM_LEVEL && e.newValue) {
const zoom = parseInt(e.newValue, 10) as ZoomLevel
if ((ZOOM_LEVELS as readonly number[]).includes(zoom)) {
// Another window moved the level; keep the ref level with it so the
// next local step continues from there and the no-op guard is honest.
zoomLevelRef.current = zoom
setZoomLevelState(zoom)
document.documentElement.style.fontSize = `${(16 * zoom) / 100}px`
syncTrafficLightPosition(zoom)
Expand Down
29 changes: 15 additions & 14 deletions src/components/settings/shortcut-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,15 @@ import {
INPUT_SHORTCUT_IDS,
SHORTCUT_DEFINITIONS,
type ShortcutActionId,
canShareShortcut,
formatShortcutLabel,
setShortcutRecorderArmed,
shortcutFromKeyboardEvent,
shortcutsConflict,
} from "@/lib/keyboard-shortcuts"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"

const SHARED_SHORTCUT_PAIRS: Array<[ShortcutActionId, ShortcutActionId]> = [
["new_terminal_tab", "new_conversation"],
["close_current_terminal_tab", "close_current_tab"],
]

function canShareShortcut(a: ShortcutActionId, b: ShortcutActionId): boolean {
return SHARED_SHORTCUT_PAIRS.some(
([left, right]) =>
(left === a && right === b) || (left === b && right === a)
)
}

export function ShortcutSettings() {
const t = useTranslations("ShortcutSettings")
const { shortcuts, updateShortcut, resetShortcuts } = useShortcutSettings()
Expand All @@ -53,13 +44,19 @@ export function ShortcutSettings() {
[shortcuts]
)

useEffect(() => {
setShortcutRecorderArmed(Boolean(recordingAction))
return () => setShortcutRecorderArmed(false)
}, [recordingAction])

useEffect(() => {
if (!recordingAction) return

const onKeyDown = (event: KeyboardEvent) => {
if (event.repeat) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()

if (event.key === "Escape") {
setRecordingAction(null)
Expand All @@ -70,11 +67,13 @@ export function ShortcutSettings() {
const shortcut = shortcutFromKeyboardEvent(event, allowNoModifier)
if (!shortcut) return

// Matcher semantics, not string equality: two different strings can fire
// on the same event (`mod+=` and `mod+shift++` both match Ctrl/Cmd+Shift+=).
const conflict = SHORTCUT_DEFINITIONS.find(
(definition) =>
definition.id !== recordingAction &&
!canShareShortcut(definition.id, recordingAction) &&
shortcuts[definition.id] === shortcut
shortcutsConflict(shortcuts[definition.id], shortcut)
)

if (conflict) {
Expand Down Expand Up @@ -155,7 +154,9 @@ export function ShortcutSettings() {
>
{isRecording
? t("recording")
: formatShortcutLabel(shortcuts[definition.id], isMac)}
: shortcuts[definition.id]
? formatShortcutLabel(shortcuts[definition.id], isMac)
: t("unassigned")}
</Button>
</div>
)
Expand Down
15 changes: 14 additions & 1 deletion src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
},
"zoomLevel": {
"sectionTitle": "تكبير النافذة",
"sectionDescription": "تكبير أو تصغير الواجهة بالكامل. يتم تطبيقه فوراً ويُحفظ لكل جهاز على حدة.",
"sectionDescription": "تكبير أو تصغير الواجهة بالكامل. يتم تطبيقه فوراً ويُحفظ لكل جهاز على حدة. ⌘/Ctrl + و - ينتقلان بين نفس مستويات التكبير في هذه القائمة (⌘/Ctrl 0 يعيد الضبط إلى 100%).",
"placeholder": "اختر مستوى التكبير",
"default": "افتراضي",
"current": "التكبير الحالي: {zoom}%"
Expand Down Expand Up @@ -359,6 +359,7 @@
"resetDefault": "استعادة الإعدادات الافتراضية",
"recordInstruction": "انقر الزر في الجهة اليمنى ثم اضغط تركيبة مفاتيح. استخدم Ctrl/Cmd وAlt وShift. اضغط Esc لإلغاء التسجيل.",
"recording": "اضغط اختصارًا...",
"unassigned": "غير معيَّن",
"toasts": {
"conflict": "الاختصار مستخدم بالفعل بواسطة \"{title}\"",
"updated": "تم تحديث الاختصار",
Expand Down Expand Up @@ -429,6 +430,18 @@
"toggle_custom_style": {
"title": "إيقاف/استئناف النمط المخصص",
"description": "مخرج طوارئ: يوقف كل الألوان المخصصة وCSS، ويعيد تفعيلها"
},
"zoom_in": {
"title": "تكبير",
"description": "اجعل النافذة أكبر بدرجة واحدة"
},
"zoom_out": {
"title": "تصغير",
"description": "اجعل النافذة أصغر بدرجة واحدة"
},
"zoom_reset": {
"title": "إعادة ضبط التكبير",
"description": "أعد تكبير النافذة إلى 100%"
}
}
},
Expand Down
Loading
Loading