From f353c4effcd4c1f4e1af96ff16898b175385f6f5 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:52:37 -0700 Subject: [PATCH 1/5] feat(settings): Ctrl/Cmd + and - step the window zoom --- src/components/appearance-provider.tsx | 34 +++++++++++++++++++++- src/i18n/messages/ar.json | 12 ++++++++ src/i18n/messages/de.json | 12 ++++++++ src/i18n/messages/en.json | 14 ++++++++- src/i18n/messages/es.json | 12 ++++++++ src/i18n/messages/fr.json | 12 ++++++++ src/i18n/messages/ja.json | 12 ++++++++ src/i18n/messages/ko.json | 12 ++++++++ src/i18n/messages/pt.json | 12 ++++++++ src/i18n/messages/zh-CN.json | 12 ++++++++ src/i18n/messages/zh-TW.json | 12 ++++++++ src/lib/keyboard-shortcuts.test.ts | 34 ++++++++++++++++++++++ src/lib/keyboard-shortcuts.ts | 40 ++++++++++++++++++++++++++ src/lib/theme-presets.test.ts | 13 +++++++++ src/lib/theme-presets.ts | 8 ++++++ 15 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 src/lib/theme-presets.test.ts diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index 4cdebc977d..88f89a92db 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -15,6 +15,7 @@ import { ZOOM_LEVELS, DEFAULT_ZOOM_LEVEL, type ZoomLevel, + stepZoom, } from "@/lib/theme-presets" import { resolveFontStack, @@ -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 { + isZoomInShortcutEvent, + isZoomOutShortcutEvent, + matchShortcutEvent, +} from "@/lib/keyboard-shortcuts" import { DEFAULT_WORKSPACE_BG_ENABLED, DEFAULT_WORKSPACE_BG_MASK_OPACITY, @@ -472,6 +477,8 @@ export function AppearanceProvider({ syncTrafficLightPosition(zoom) persist(STORAGE_KEY_ZOOM_LEVEL, String(zoom)) }, []) + const zoomLevelRef = useRef(zoomLevel) + zoomLevelRef.current = zoomLevel const setShowWelcomeQuickActions = useCallback((on: boolean) => { setShowWelcomeQuickActionsState(on) @@ -744,6 +751,7 @@ export function AppearanceProvider({ // 或某个组件吞掉了冒泡,这一路依然能把自定义样式整体停用。 const { shortcuts } = useShortcutSettings() const toggleCustomStyleShortcut = shortcuts.toggle_custom_style + const zoomResetShortcut = shortcuts.zoom_reset useEffect(() => { if (!toggleCustomStyleShortcut) return const onKeyDown = (event: KeyboardEvent) => { @@ -756,6 +764,30 @@ export function AppearanceProvider({ return () => window.removeEventListener("keydown", onKeyDown, true) }, [toggleCustomStyleShortcut, customStyleSuspended, setCustomStyleSuspended]) + // Same rungs 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.repeat) return + if (isZoomInShortcutEvent(event)) { + event.preventDefault() + setZoomLevel(stepZoom(zoomLevelRef.current, 1)) + return + } + if (isZoomOutShortcutEvent(event)) { + event.preventDefault() + setZoomLevel(stepZoom(zoomLevelRef.current, -1)) + return + } + if (zoomResetShortcut && matchShortcutEvent(event, zoomResetShortcut)) { + event.preventDefault() + setZoomLevel(DEFAULT_ZOOM_LEVEL) + } + } + window.addEventListener("keydown", onKeyDown, true) + return () => window.removeEventListener("keydown", onKeyDown, true) + }, [setZoomLevel, zoomResetShortcut]) + // 跨标签页同步:用户在另一个窗口改了设置时,本窗口实时跟进 useEffect(() => { const FONT_KEYS = new Set([ diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 9aa997b032..f8ac4b85c1 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "إيقاف/استئناف النمط المخصص", "description": "مخرج طوارئ: يوقف كل الألوان المخصصة وCSS، ويعيد تفعيلها" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index c368aa0bfd..068488b4f3 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "Eigenen Stil aussetzen/fortsetzen", "description": "Notausstieg: schaltet alle eigenen Farben und CSS aus und wieder ein" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 422884785e..be79753809 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Window zoom", - "sectionDescription": "Scale the entire interface. Applies immediately and persists per device.", + "sectionDescription": "Scale the entire interface. Applies immediately and persists per device. Ctrl + and Ctrl - step the same rungs as this menu (Ctrl 0 resets to 100%).", "placeholder": "Select zoom level", "default": "Default", "current": "Current zoom: {zoom}%" @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "Suspend/resume custom style", "description": "Escape hatch: turns all custom colors and CSS off, and back on" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 1b36bec76d..b7d15d1432 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "Suspender/reanudar estilo personalizado", "description": "Vía de escape: desactiva todos los colores y el CSS personalizados, y los vuelve a activar" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 05e52f8349..608a7d2875 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "Suspendre/réactiver le style personnalisé", "description": "Issue de secours : désactive toutes les couleurs et le CSS personnalisés, puis les réactive" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 51f97a0fcf..1d418d3e81 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "カスタムスタイルの停止/再開", "description": "緊急脱出用: カスタム配色と CSS をすべてオフにし、再度押すと元に戻します" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index daf9cc2854..df2b34b9b4 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "사용자 지정 스타일 중지/재개", "description": "비상 탈출구: 모든 사용자 지정 색상과 CSS를 끄고, 다시 누르면 되돌립니다" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index de196b6e75..f8fa065119 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "Suspender/retomar estilo personalizado", "description": "Saída de emergência: desliga todas as cores e o CSS personalizados e volta a ligá-los" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 4f0c74f0a7..949d870ca0 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "停用/恢复自定义样式", "description": "逃生舱:一键关闭全部自定义配色与 CSS,再按一次恢复" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index f4349960ee..b7361f3564 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -429,6 +429,18 @@ "toggle_custom_style": { "title": "停用/恢復自訂樣式", "description": "逃生艙:一鍵關閉全部自訂配色與 CSS,再按一次恢復" + }, + "zoom_in": { + "title": "Zoom in", + "description": "Step the window zoom up one Settings rung" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Step the window zoom down one Settings rung" + }, + "zoom_reset": { + "title": "Reset zoom", + "description": "Set the window zoom back to 100%" } } }, diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index d32d869494..6dfd0985c9 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest" import { DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, + isZoomInShortcutEvent, + isZoomOutShortcutEvent, matchShortcutEvent, shortcutFromKeyboardEvent, } from "./keyboard-shortcuts" @@ -117,3 +119,35 @@ describe("alt combinations use event.code", () => { ).toBe(true) }) }) + +describe("window zoom shortcuts", () => { + it("registers zoom_in / zoom_out / zoom_reset defaults", () => { + const ids = SHORTCUT_DEFINITIONS.map((definition) => definition.id) + expect(ids).toContain("zoom_in") + expect(ids).toContain("zoom_out") + expect(ids).toContain("zoom_reset") + expect(DEFAULT_SHORTCUTS.zoom_in).toBe("mod+=") + expect(DEFAULT_SHORTCUTS.zoom_out).toBe("mod+-") + expect(DEFAULT_SHORTCUTS.zoom_reset).toBe("mod+0") + }) + + it("treats Ctrl+= and Ctrl++ as zoom in", () => { + expect( + isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true })) + ).toBe(true) + expect( + isZoomInShortcutEvent(keyEvent("+", { ctrlKey: true, shiftKey: true })) + ).toBe(true) + expect(isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(true) + expect(isZoomInShortcutEvent(keyEvent("k", { ctrlKey: true }))).toBe(false) + }) + + it("treats Ctrl+- as zoom out", () => { + expect( + isZoomOutShortcutEvent(keyEvent("-", { ctrlKey: true })) + ).toBe(true) + expect(isZoomOutShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe( + false + ) + }) +}) diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index 342995d881..7d8d1ad00c 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -15,6 +15,9 @@ export type ShortcutActionId = | "send_message" | "newline_in_message" | "toggle_custom_style" + | "zoom_in" + | "zoom_out" + | "zoom_reset" export interface ShortcutDefinition { id: ShortcutActionId @@ -69,6 +72,15 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ { id: "toggle_custom_style", }, + { + id: "zoom_in", + }, + { + id: "zoom_out", + }, + { + id: "zoom_reset", + }, ] /** Actions that allow shortcuts without modifier keys (e.g. plain Enter). */ @@ -98,6 +110,11 @@ export const DEFAULT_SHORTCUTS: ShortcutSettings = { // 自定义样式的逃生舱:用户把界面改到不可用时,这一路必须仍然按得动,所以选一个 // 三修饰键组合(不会与任何常用操作撞车),并在捕获阶段监听。 toggle_custom_style: "mod+alt+shift+s", + // Same rungs as Settings → Window zoom. `=` is what US keyboards fire for + // Ctrl/+ without Shift; `+` is Shift+= and the numpad. + zoom_in: "mod+=", + zoom_out: "mod+-", + zoom_reset: "mod+0", } export const SHORTCUTS_STORAGE_KEY = "settings:shortcuts:v1" @@ -115,6 +132,9 @@ const SPECIAL_KEY_ALIASES: Record = { down: "arrowdown", left: "arrowleft", right: "arrowright", + add: "+", + subtract: "-", + equal: "=", } const KEY_LABELS: Record = { @@ -306,6 +326,26 @@ export function shortcutFromKeyboardEvent( return parts.join("+") } +/** Ctrl/Cmd + or = (with or without Shift). Matches the Settings zoom-in rung. */ +export function isZoomInShortcutEvent(event: ShortcutEventLike): boolean { + if (matchShortcutEvent(event, "mod+=") || matchShortcutEvent(event, "mod++")) { + return true + } + if (!(event.metaKey || event.ctrlKey) || event.altKey) return false + const key = eventKeyToken(event) + if (key === "+" || key === "=") return true + return event.code === "Equal" || event.code === "NumpadAdd" +} + +/** Ctrl/Cmd - (with or without Shift). Matches the Settings zoom-out rung. */ +export function isZoomOutShortcutEvent(event: ShortcutEventLike): boolean { + if (matchShortcutEvent(event, "mod+-")) return true + if (!(event.metaKey || event.ctrlKey) || event.altKey) return false + const key = eventKeyToken(event) + if (key === "-" || key === "_") return true + return event.code === "Minus" || event.code === "NumpadSubtract" +} + export function matchShortcutEvent( event: ShortcutEventLike, shortcut: string diff --git a/src/lib/theme-presets.test.ts b/src/lib/theme-presets.test.ts new file mode 100644 index 0000000000..9ac742a173 --- /dev/null +++ b/src/lib/theme-presets.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest" + +import { DEFAULT_ZOOM_LEVEL, stepZoom } from "./theme-presets" + +describe("stepZoom", () => { + it("walks the Settings rungs and stops at the ends", () => { + expect(stepZoom(100, 1)).toBe(110) + expect(stepZoom(110, -1)).toBe(100) + expect(stepZoom(80, -1)).toBe(80) + expect(stepZoom(150, 1)).toBe(150) + expect(stepZoom(DEFAULT_ZOOM_LEVEL, 1)).toBe(110) + }) +}) diff --git a/src/lib/theme-presets.ts b/src/lib/theme-presets.ts index 2d8a9a2b12..574ecbfb12 100644 --- a/src/lib/theme-presets.ts +++ b/src/lib/theme-presets.ts @@ -97,3 +97,11 @@ export const ZOOM_LEVELS = [80, 90, 100, 110, 125, 150] as const export type ZoomLevel = (typeof ZOOM_LEVELS)[number] export const DEFAULT_ZOOM_LEVEL: ZoomLevel = 100 + +/** Next discrete Settings zoom step. Stops at the first / last rung. */ +export function stepZoom(current: ZoomLevel, direction: 1 | -1): ZoomLevel { + const index = ZOOM_LEVELS.indexOf(current) + const from = index >= 0 ? index : ZOOM_LEVELS.indexOf(DEFAULT_ZOOM_LEVEL) + const next = Math.min(ZOOM_LEVELS.length - 1, Math.max(0, from + direction)) + return ZOOM_LEVELS[next] +} From 2f7d74d811fe8594118a993250f7dcd5f25527b2 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:03:21 -0700 Subject: [PATCH 2/5] chore: format after rebase onto main --- src/lib/keyboard-shortcuts.test.ts | 12 +++--------- src/lib/keyboard-shortcuts.ts | 5 ++++- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index 6dfd0985c9..2255894b1d 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -132,9 +132,7 @@ describe("window zoom shortcuts", () => { }) it("treats Ctrl+= and Ctrl++ as zoom in", () => { - expect( - isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true })) - ).toBe(true) + expect(isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(true) expect( isZoomInShortcutEvent(keyEvent("+", { ctrlKey: true, shiftKey: true })) ).toBe(true) @@ -143,11 +141,7 @@ describe("window zoom shortcuts", () => { }) it("treats Ctrl+- as zoom out", () => { - expect( - isZoomOutShortcutEvent(keyEvent("-", { ctrlKey: true })) - ).toBe(true) - expect(isZoomOutShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe( - false - ) + expect(isZoomOutShortcutEvent(keyEvent("-", { ctrlKey: true }))).toBe(true) + expect(isZoomOutShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(false) }) }) diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index 7d8d1ad00c..e5cf6a4fa8 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -328,7 +328,10 @@ export function shortcutFromKeyboardEvent( /** Ctrl/Cmd + or = (with or without Shift). Matches the Settings zoom-in rung. */ export function isZoomInShortcutEvent(event: ShortcutEventLike): boolean { - if (matchShortcutEvent(event, "mod+=") || matchShortcutEvent(event, "mod++")) { + if ( + matchShortcutEvent(event, "mod+=") || + matchShortcutEvent(event, "mod++") + ) { return true } if (!(event.metaKey || event.ctrlKey) || event.altKey) return false From 7138cb454eae6f1c448ce053c7eb1934e4ccdec4 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:08:35 -0700 Subject: [PATCH 3/5] fix(settings): honor remapped zoom shortcuts and record + Zoom in/out now match the Settings bindings instead of a hardcoded Ctrl+= / Ctrl+-. Held keys repeat and still preventDefault. The integrated terminal keeps Ctrl+_, recording a shortcut no longer zooms the window, and + survives normalizeShortcut. --- src/components/appearance-provider.tsx | 46 ++-- src/components/settings/shortcut-settings.tsx | 7 + src/i18n/messages/ar.json | 14 +- src/i18n/messages/de.json | 14 +- src/i18n/messages/en.json | 6 +- src/i18n/messages/es.json | 14 +- src/i18n/messages/fr.json | 14 +- src/i18n/messages/ja.json | 14 +- src/i18n/messages/ko.json | 14 +- src/i18n/messages/pt.json | 14 +- src/i18n/messages/zh-CN.json | 14 +- src/i18n/messages/zh-TW.json | 14 +- src/lib/keyboard-shortcuts.test.ts | 113 ++++++++- src/lib/keyboard-shortcuts.ts | 227 +++++++++++------- 14 files changed, 348 insertions(+), 177 deletions(-) diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index 88f89a92db..5b2d854817 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -67,9 +67,9 @@ import { } from "@/lib/custom-style" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { - isZoomInShortcutEvent, - isZoomOutShortcutEvent, + isShortcutRecorderArmed, matchShortcutEvent, + resolveWindowZoomAction, } from "@/lib/keyboard-shortcuts" import { DEFAULT_WORKSPACE_BG_ENABLED, @@ -478,7 +478,9 @@ export function AppearanceProvider({ persist(STORAGE_KEY_ZOOM_LEVEL, String(zoom)) }, []) const zoomLevelRef = useRef(zoomLevel) - zoomLevelRef.current = zoomLevel + useEffect(() => { + zoomLevelRef.current = zoomLevel + }, [zoomLevel]) const setShowWelcomeQuickActions = useCallback((on: boolean) => { setShowWelcomeQuickActionsState(on) @@ -751,6 +753,8 @@ 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 @@ -764,29 +768,43 @@ export function AppearanceProvider({ return () => window.removeEventListener("keydown", onKeyDown, true) }, [toggleCustomStyleShortcut, customStyleSuspended, setCustomStyleSuspended]) - // Same rungs as Settings → Window zoom. Capture-phase so the webview + // 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.repeat) return - if (isZoomInShortcutEvent(event)) { - event.preventDefault() + if (event.isComposing) return + if (isShortcutRecorderArmed()) return + if ( + 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") { setZoomLevel(stepZoom(zoomLevelRef.current, 1)) return } - if (isZoomOutShortcutEvent(event)) { - event.preventDefault() + if (action === "out") { setZoomLevel(stepZoom(zoomLevelRef.current, -1)) return } - if (zoomResetShortcut && matchShortcutEvent(event, zoomResetShortcut)) { - event.preventDefault() - setZoomLevel(DEFAULT_ZOOM_LEVEL) - } + setZoomLevel(DEFAULT_ZOOM_LEVEL) } window.addEventListener("keydown", onKeyDown, true) return () => window.removeEventListener("keydown", onKeyDown, true) - }, [setZoomLevel, zoomResetShortcut]) + }, [setZoomLevel, zoomInShortcut, zoomOutShortcut, zoomResetShortcut]) // 跨标签页同步:用户在另一个窗口改了设置时,本窗口实时跟进 useEffect(() => { diff --git a/src/components/settings/shortcut-settings.tsx b/src/components/settings/shortcut-settings.tsx index 97b3dfd9f9..b928160d36 100644 --- a/src/components/settings/shortcut-settings.tsx +++ b/src/components/settings/shortcut-settings.tsx @@ -12,6 +12,7 @@ import { SHORTCUT_DEFINITIONS, type ShortcutActionId, formatShortcutLabel, + setShortcutRecorderArmed, shortcutFromKeyboardEvent, } from "@/lib/keyboard-shortcuts" import { Button } from "@/components/ui/button" @@ -53,6 +54,11 @@ export function ShortcutSettings() { [shortcuts] ) + useEffect(() => { + setShortcutRecorderArmed(Boolean(recordingAction)) + return () => setShortcutRecorderArmed(false) + }, [recordingAction]) + useEffect(() => { if (!recordingAction) return @@ -60,6 +66,7 @@ export function ShortcutSettings() { if (event.repeat) return event.preventDefault() event.stopPropagation() + event.stopImmediatePropagation() if (event.key === "Escape") { setRecordingAction(null) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index f8ac4b85c1..4543f604b3 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "تكبير النافذة", - "sectionDescription": "تكبير أو تصغير الواجهة بالكامل. يتم تطبيقه فوراً ويُحفظ لكل جهاز على حدة.", + "sectionDescription": "تكبير أو تصغير الواجهة بالكامل. يتم تطبيقه فوراً ويُحفظ لكل جهاز على حدة. ⌘/Ctrl + و - ينتقلان بين نفس مستويات التكبير في هذه القائمة (⌘/Ctrl 0 يعيد الضبط إلى 100%).", "placeholder": "اختر مستوى التكبير", "default": "افتراضي", "current": "التكبير الحالي: {zoom}%" @@ -431,16 +431,16 @@ "description": "مخرج طوارئ: يوقف كل الألوان المخصصة وCSS، ويعيد تفعيلها" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "تكبير", + "description": "اجعل النافذة أكبر بدرجة واحدة" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "تصغير", + "description": "اجعل النافذة أصغر بدرجة واحدة" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "إعادة ضبط التكبير", + "description": "أعد تكبير النافذة إلى 100%" } } }, diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 068488b4f3..2ef5da4f2f 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Fensterzoom", - "sectionDescription": "Skaliert die gesamte Oberfläche. Wird sofort übernommen und pro Gerät gespeichert.", + "sectionDescription": "Skaliert die gesamte Oberfläche. Wird sofort übernommen und pro Gerät gespeichert. ⌘/Ctrl + und - durchlaufen dieselben Zoomstufen wie dieses Menü (⌘/Ctrl 0 setzt auf 100% zurück).", "placeholder": "Zoomstufe wählen", "default": "Standard", "current": "Aktueller Zoom: {zoom}%" @@ -431,16 +431,16 @@ "description": "Notausstieg: schaltet alle eigenen Farben und CSS aus und wieder ein" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "Vergrößern", + "description": "Das Fenster eine Stufe größer machen" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "Verkleinern", + "description": "Das Fenster eine Stufe kleiner machen" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "Zoom zurücksetzen", + "description": "Fensterzoom auf 100% zurücksetzen" } } }, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index be79753809..e82e4039e9 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Window zoom", - "sectionDescription": "Scale the entire interface. Applies immediately and persists per device. Ctrl + and Ctrl - step the same rungs as this menu (Ctrl 0 resets to 100%).", + "sectionDescription": "Scale the entire interface. Applies immediately and persists per device. ⌘/Ctrl + and - move through the same zoom levels as this menu (⌘/Ctrl 0 resets to 100%).", "placeholder": "Select zoom level", "default": "Default", "current": "Current zoom: {zoom}%" @@ -432,11 +432,11 @@ }, "zoom_in": { "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "description": "Make the window one step larger" }, "zoom_out": { "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "description": "Make the window one step smaller" }, "zoom_reset": { "title": "Reset zoom", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index b7d15d1432..e5644e86e2 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Zoom de ventana", - "sectionDescription": "Escala toda la interfaz. Se aplica al instante y se guarda por dispositivo.", + "sectionDescription": "Escala toda la interfaz. Se aplica al instante y se guarda por dispositivo. ⌘/Ctrl + y - recorren los mismos niveles de zoom que este menú (⌘/Ctrl 0 vuelve al 100%).", "placeholder": "Selecciona el nivel de zoom", "default": "Predeterminado", "current": "Zoom actual: {zoom}%" @@ -431,16 +431,16 @@ "description": "Vía de escape: desactiva todos los colores y el CSS personalizados, y los vuelve a activar" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "Acercar", + "description": "Amplía la ventana un nivel" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "Alejar", + "description": "Reduce la ventana un nivel" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "Restablecer zoom", + "description": "Restaura el zoom de la ventana al 100%" } } }, diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 608a7d2875..efdfdb84d8 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Zoom de la fenêtre", - "sectionDescription": "Met à l'échelle toute l'interface. S'applique immédiatement et est enregistré par appareil.", + "sectionDescription": "Met à l'échelle toute l'interface. S'applique immédiatement et est enregistré par appareil. ⌘/Ctrl + et - parcourent les mêmes niveaux de zoom que ce menu (⌘/Ctrl 0 rétablit 100%).", "placeholder": "Sélectionnez le niveau de zoom", "default": "Par défaut", "current": "Zoom actuel : {zoom}%" @@ -431,16 +431,16 @@ "description": "Issue de secours : désactive toutes les couleurs et le CSS personnalisés, puis les réactive" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "Zoom avant", + "description": "Agrandit la fenêtre d'un cran" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "Zoom arrière", + "description": "Réduit la fenêtre d'un cran" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "Réinitialiser le zoom", + "description": "Remet le zoom de la fenêtre à 100%" } } }, diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 1d418d3e81..7eb3b7b345 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "ウィンドウズーム", - "sectionDescription": "インターフェイス全体を拡大・縮小します。すぐに反映され、デバイスごとに保存されます。", + "sectionDescription": "インターフェイス全体を拡大・縮小します。すぐに反映され、デバイスごとに保存されます。⌘/Ctrl + と - で、このメニューと同じ段階で拡大縮小します(⌘/Ctrl 0 で 100% に戻します)。", "placeholder": "ズームレベルを選択", "default": "デフォルト", "current": "現在のズーム:{zoom}%" @@ -431,16 +431,16 @@ "description": "緊急脱出用: カスタム配色と CSS をすべてオフにし、再度押すと元に戻します" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "拡大", + "description": "ウィンドウの表示倍率を一段階上げます" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "縮小", + "description": "ウィンドウの表示倍率を一段階下げます" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "ズームをリセット", + "description": "ウィンドウのズームを 100% に戻します" } } }, diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index df2b34b9b4..15f427ddcf 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "창 확대/축소", - "sectionDescription": "전체 인터페이스를 확대하거나 축소합니다. 즉시 적용되며 장치별로 저장됩니다.", + "sectionDescription": "전체 인터페이스를 확대하거나 축소합니다. 즉시 적용되며 장치별로 저장됩니다. ⌘/Ctrl +와 -는 이 메뉴와 같은 단계로 확대/축소합니다(⌘/Ctrl 0은 100%로 되돌립니다).", "placeholder": "확대/축소 단계 선택", "default": "기본값", "current": "현재 확대/축소: {zoom}%" @@ -431,16 +431,16 @@ "description": "비상 탈출구: 모든 사용자 지정 색상과 CSS를 끄고, 다시 누르면 되돌립니다" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "확대", + "description": "창을 한 단계 더 크게 만듭니다" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "축소", + "description": "창을 한 단계 더 작게 만듭니다" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "확대/축소 초기화", + "description": "창 확대/축소를 100%로 되돌립니다" } } }, diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index f8fa065119..f31140b116 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "Zoom da janela", - "sectionDescription": "Dimensiona toda a interface. Aplica imediatamente e é salvo por dispositivo.", + "sectionDescription": "Dimensiona toda a interface. Aplica imediatamente e é salvo por dispositivo. ⌘/Ctrl + e - percorrem os mesmos níveis de zoom deste menu (⌘/Ctrl 0 volta para 100%).", "placeholder": "Selecione o nível de zoom", "default": "Padrão", "current": "Zoom atual: {zoom}%" @@ -431,16 +431,16 @@ "description": "Saída de emergência: desliga todas as cores e o CSS personalizados e volta a ligá-los" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "Aumentar zoom", + "description": "Aumenta a janela em um nível" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "Diminuir zoom", + "description": "Diminui a janela em um nível" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "Redefinir zoom", + "description": "Restaura o zoom da janela para 100%" } } }, diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 949d870ca0..f9eec1c2fc 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "窗口缩放", - "sectionDescription": "整体放大或缩小界面,立即生效,按设备分别保存。", + "sectionDescription": "整体放大或缩小界面,立即生效,按设备分别保存。⌘/Ctrl + 和 - 按与此菜单相同的档位调节(⌘/Ctrl 0 恢复为 100%)。", "placeholder": "请选择缩放档位", "default": "默认", "current": "当前缩放:{zoom}%" @@ -431,16 +431,16 @@ "description": "逃生舱:一键关闭全部自定义配色与 CSS,再按一次恢复" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "放大", + "description": "把窗口缩放提高一档" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "缩小", + "description": "把窗口缩放降低一档" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "重置缩放", + "description": "把窗口缩放恢复为 100%" } } }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index b7361f3564..0e157e7114 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -146,7 +146,7 @@ }, "zoomLevel": { "sectionTitle": "視窗縮放", - "sectionDescription": "整體放大或縮小介面,立即生效,依裝置分別儲存。", + "sectionDescription": "整體放大或縮小介面,立即生效,依裝置分別儲存。⌘/Ctrl + 和 - 依與此選單相同的檔位調節(⌘/Ctrl 0 恢復為 100%)。", "placeholder": "請選擇縮放檔位", "default": "預設", "current": "目前縮放:{zoom}%" @@ -431,16 +431,16 @@ "description": "逃生艙:一鍵關閉全部自訂配色與 CSS,再按一次恢復" }, "zoom_in": { - "title": "Zoom in", - "description": "Step the window zoom up one Settings rung" + "title": "放大", + "description": "把視窗縮放提高一檔" }, "zoom_out": { - "title": "Zoom out", - "description": "Step the window zoom down one Settings rung" + "title": "縮小", + "description": "把視窗縮放降低一檔" }, "zoom_reset": { - "title": "Reset zoom", - "description": "Set the window zoom back to 100%" + "title": "重設縮放", + "description": "把視窗縮放恢復為 100%" } } }, diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index 2255894b1d..a47ee8a98e 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -3,9 +3,10 @@ import { describe, expect, it } from "vitest" import { DEFAULT_SHORTCUTS, SHORTCUT_DEFINITIONS, - isZoomInShortcutEvent, - isZoomOutShortcutEvent, + formatShortcutLabel, matchShortcutEvent, + normalizeShortcut, + resolveWindowZoomAction, shortcutFromKeyboardEvent, } from "./keyboard-shortcuts" @@ -120,6 +121,12 @@ describe("alt combinations use event.code", () => { }) }) +const defaultZoom = { + zoom_in: DEFAULT_SHORTCUTS.zoom_in, + zoom_out: DEFAULT_SHORTCUTS.zoom_out, + zoom_reset: DEFAULT_SHORTCUTS.zoom_reset, +} + describe("window zoom shortcuts", () => { it("registers zoom_in / zoom_out / zoom_reset defaults", () => { const ids = SHORTCUT_DEFINITIONS.map((definition) => definition.id) @@ -131,17 +138,103 @@ describe("window zoom shortcuts", () => { expect(DEFAULT_SHORTCUTS.zoom_reset).toBe("mod+0") }) - it("treats Ctrl+= and Ctrl++ as zoom in", () => { - expect(isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(true) + it("lets + survive normalize so Ctrl/Cmd Shift = can be recorded", () => { + expect(normalizeShortcut("mod++")).toBe("mod++") + expect(normalizeShortcut("mod+shift++")).toBe("mod+shift++") expect( - isZoomInShortcutEvent(keyEvent("+", { ctrlKey: true, shiftKey: true })) + shortcutFromKeyboardEvent( + keyEvent("+", { ctrlKey: true, shiftKey: true }) + ) + ).toBe("mod+shift++") + expect(formatShortcutLabel("mod++", false)).toBe("Ctrl++") + expect(formatShortcutLabel("mod++", true)).toBe("⌘+") + }) + + it("treats = and + as the same physical key on the bound zoom-in shortcut", () => { + expect(matchShortcutEvent(keyEvent("=", { ctrlKey: true }), "mod+=")).toBe( + true + ) + expect( + matchShortcutEvent( + keyEvent("+", { ctrlKey: true, shiftKey: true }), + "mod+=" + ) ).toBe(true) - expect(isZoomInShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(true) - expect(isZoomInShortcutEvent(keyEvent("k", { ctrlKey: true }))).toBe(false) + expect(matchShortcutEvent(keyEvent("k", { ctrlKey: true }), "mod+=")).toBe( + false + ) + }) + + it("treats - and _ as the same physical key on the bound zoom-out shortcut", () => { + expect(matchShortcutEvent(keyEvent("-", { ctrlKey: true }), "mod+-")).toBe( + true + ) + expect( + matchShortcutEvent( + keyEvent("_", { ctrlKey: true, shiftKey: true }), + "mod+-" + ) + ).toBe(true) + expect(matchShortcutEvent(keyEvent("=", { ctrlKey: true }), "mod+-")).toBe( + false + ) + }) + + it("follows a remapped zoom-in binding and ignores the old default", () => { + const remapped = { + ...defaultZoom, + zoom_in: "mod+shift+z", + } + expect( + resolveWindowZoomAction( + keyEvent("z", { ctrlKey: true, shiftKey: true }), + remapped + ) + ).toBe("in") + expect( + resolveWindowZoomAction(keyEvent("=", { ctrlKey: true }), remapped) + ).toBeNull() + expect( + resolveWindowZoomAction( + keyEvent("+", { ctrlKey: true, shiftKey: true }), + remapped + ) + ).toBeNull() }) - it("treats Ctrl+- as zoom out", () => { - expect(isZoomOutShortcutEvent(keyEvent("-", { ctrlKey: true }))).toBe(true) - expect(isZoomOutShortcutEvent(keyEvent("=", { ctrlKey: true }))).toBe(false) + it("still matches a repeat of the bound zoom key", () => { + expect( + resolveWindowZoomAction(keyEvent("=", { ctrlKey: true }), defaultZoom) + ).toBe("in") + expect( + resolveWindowZoomAction(keyEvent("-", { ctrlKey: true }), defaultZoom) + ).toBe("out") + expect( + resolveWindowZoomAction(keyEvent("0", { ctrlKey: true }), defaultZoom) + ).toBe("reset") + }) + + it("does not treat AZERTY Ctrl+) as zoom out just because code is Minus", () => { + expect( + matchShortcutEvent( + keyEvent(")", { ctrlKey: true, code: "Minus" }), + "mod+-" + ) + ).toBe(false) + }) + + it("still matches numpad + / - against the default zoom bindings", () => { + expect( + matchShortcutEvent( + keyEvent("Add", { ctrlKey: true, code: "NumpadAdd" }), + "mod+=" + ) + ).toBe(true) + expect( + matchShortcutEvent( + keyEvent("Subtract", { ctrlKey: true, code: "NumpadSubtract" }), + "mod+-" + ) + ).toBe(true) }) }) diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index e5cf6a4fa8..0dd2b185e3 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -132,9 +132,40 @@ const SPECIAL_KEY_ALIASES: Record = { down: "arrowdown", left: "arrowleft", right: "arrowright", - add: "+", - subtract: "-", - equal: "=", +} + +/** + * `=`/`+` and `-`/`_` are the same physical key (unshifted vs shifted). + * Bindings on one should also fire for the other; extra Shift is ignored + * only for these pairs, because Shift is how you type the sibling. + */ +const PHYSICAL_KEY_SIBLINGS: Record = { + "=": "+", + "+": "=", + "-": "_", + _: "-", +} + +export interface ParsedShortcut { + mod: boolean + alt: boolean + shift: boolean + key: string +} + +/** + * Recording a shortcut in Settings and the global zoom listener are both + * capture handlers on `window`. `stopPropagation()` does not stop a sibling + * listener on the same target, so the recorder arms this flag instead. + */ +let shortcutRecorderArmed = false + +export function setShortcutRecorderArmed(armed: boolean): void { + shortcutRecorderArmed = armed +} + +export function isShortcutRecorderArmed(): boolean { + return shortcutRecorderArmed } const KEY_LABELS: Record = { @@ -194,59 +225,77 @@ function normalizeSettings(input: unknown): ShortcutSettings { return next } -export function normalizeShortcut(rawShortcut: string): string | null { - const parts = rawShortcut - .toLowerCase() - .split("+") - .map((part) => part.trim()) - .filter(Boolean) +/** + * Split a shortcut string without treating a trailing `+` key as a delimiter. + * `mod++` and `mod+shift++` are how Ctrl/Cmd+Shift+= serializes. + */ +export function parseShortcut(rawShortcut: string): ParsedShortcut | null { + const lowered = rawShortcut.toLowerCase().trim() + if (!lowered) return null + + let keyRaw: string + let prefix: string + if (lowered === "+" || lowered.endsWith("++")) { + keyRaw = "+" + prefix = lowered === "+" ? "" : lowered.slice(0, -2) + } else { + const lastPlus = lowered.lastIndexOf("+") + if (lastPlus === -1) { + keyRaw = lowered + prefix = "" + } else { + keyRaw = lowered.slice(lastPlus + 1) + prefix = lowered.slice(0, lastPlus) + } + } - if (parts.length === 0) return null + const keyToken = normalizeKeyToken(keyRaw.trim()) + if (!keyToken || MODIFIER_KEY_SET.has(keyToken)) return null let mod = false let alt = false let shift = false - let keyToken: string | null = null - - for (const part of parts) { - if ( - part === "mod" || - part === "cmd" || - part === "command" || - part === "meta" || - part === "ctrl" || - part === "control" - ) { - mod = true - continue - } - - if (part === "alt" || part === "option") { - alt = true - continue - } - - if (part === "shift") { - shift = true - continue + if (prefix) { + const parts = prefix + .split("+") + .map((part) => part.trim()) + .filter(Boolean) + for (const part of parts) { + if ( + part === "mod" || + part === "cmd" || + part === "command" || + part === "meta" || + part === "ctrl" || + part === "control" + ) { + mod = true + continue + } + if (part === "alt" || part === "option") { + alt = true + continue + } + if (part === "shift") { + shift = true + continue + } + return null } - - if (keyToken) return null - - const normalizedKey = normalizeKeyToken(part) - if (!normalizedKey || MODIFIER_KEY_SET.has(normalizedKey)) return null - - keyToken = normalizedKey } - if (!keyToken) return null + return { mod, alt, shift, key: keyToken } +} - const normalizedParts: string[] = [] - if (mod) normalizedParts.push("mod") - if (alt) normalizedParts.push("alt") - if (shift) normalizedParts.push("shift") - normalizedParts.push(keyToken) +export function normalizeShortcut(rawShortcut: string): string | null { + const parsed = parseShortcut(rawShortcut) + if (!parsed) return null + const normalizedParts: string[] = [] + if (parsed.mod) normalizedParts.push("mod") + if (parsed.alt) normalizedParts.push("alt") + if (parsed.shift) normalizedParts.push("shift") + normalizedParts.push(parsed.key) return normalizedParts.join("+") } @@ -326,54 +375,61 @@ export function shortcutFromKeyboardEvent( return parts.join("+") } -/** Ctrl/Cmd + or = (with or without Shift). Matches the Settings zoom-in rung. */ -export function isZoomInShortcutEvent(event: ShortcutEventLike): boolean { - if ( - matchShortcutEvent(event, "mod+=") || - matchShortcutEvent(event, "mod++") - ) { - return true - } - if (!(event.metaKey || event.ctrlKey) || event.altKey) return false - const key = eventKeyToken(event) - if (key === "+" || key === "=") return true - return event.code === "Equal" || event.code === "NumpadAdd" +function siblingKeys(keyToken: string): Set { + const sibling = PHYSICAL_KEY_SIBLINGS[keyToken] + return sibling ? new Set([keyToken, sibling]) : new Set([keyToken]) } -/** Ctrl/Cmd - (with or without Shift). Matches the Settings zoom-out rung. */ -export function isZoomOutShortcutEvent(event: ShortcutEventLike): boolean { - if (matchShortcutEvent(event, "mod+-")) return true - if (!(event.metaKey || event.ctrlKey) || event.altKey) return false - const key = eventKeyToken(event) - if (key === "-" || key === "_") return true - return event.code === "Minus" || event.code === "NumpadSubtract" +function matchesNumpadCode( + event: ShortcutEventLike, + boundKey: string +): boolean { + if (boundKey === "=" || boundKey === "+") { + return event.code === "NumpadAdd" + } + if (boundKey === "-" || boundKey === "_") { + return event.code === "NumpadSubtract" + } + return false } export function matchShortcutEvent( event: ShortcutEventLike, shortcut: string ): boolean { - const normalized = normalizeShortcut(shortcut) - if (!normalized) return false - - const parts = normalized.split("+") - const keyToken = parts[parts.length - 1] - const needsMod = parts.includes("mod") - const needsAlt = parts.includes("alt") - const needsShift = parts.includes("shift") + const parsed = parseShortcut(shortcut) + if (!parsed) return false + const keys = siblingKeys(parsed.key) const actualKey = eventKeyToken(event) - if (!actualKey) return false - if (actualKey !== keyToken) return false + const matchesKey = actualKey !== null && keys.has(actualKey) + if (!matchesKey && !matchesNumpadCode(event, parsed.key)) return false const hasMod = event.metaKey || event.ctrlKey - if (hasMod !== needsMod) return false - if (event.altKey !== needsAlt) return false - if (event.shiftKey !== needsShift) return false + if (hasMod !== parsed.mod) return false + if (event.altKey !== parsed.alt) return false + + // Extra Shift is how `=` becomes `+` (and `-` becomes `_`). Require Shift + // when the binding asked for it; ignore a surplus Shift only on those pairs. + if (parsed.shift) { + if (!event.shiftKey) return false + } else if (event.shiftKey && keys.size === 1) { + return false + } return true } +export function resolveWindowZoomAction( + event: ShortcutEventLike, + shortcuts: Pick +): "in" | "out" | "reset" | null { + if (matchShortcutEvent(event, shortcuts.zoom_in)) return "in" + if (matchShortcutEvent(event, shortcuts.zoom_out)) return "out" + if (matchShortcutEvent(event, shortcuts.zoom_reset)) return "reset" + return null +} + function toKeyLabel(keyToken: string): string { const common = KEY_LABELS[keyToken] if (common) return common @@ -385,18 +441,15 @@ function toKeyLabel(keyToken: string): string { } export function formatShortcutLabel(shortcut: string, isMac: boolean): string { - const normalized = normalizeShortcut(shortcut) - if (!normalized) return shortcut - - const parts = normalized.split("+") - const keyToken = parts[parts.length - 1] + const parsed = parseShortcut(shortcut) + if (!parsed) return shortcut const modifiers: string[] = [] - if (parts.includes("mod")) modifiers.push(isMac ? "⌘" : "Ctrl") - if (parts.includes("alt")) modifiers.push(isMac ? "⌥" : "Alt") - if (parts.includes("shift")) modifiers.push(isMac ? "⇧" : "Shift") + if (parsed.mod) modifiers.push(isMac ? "⌘" : "Ctrl") + if (parsed.alt) modifiers.push(isMac ? "⌥" : "Alt") + if (parsed.shift) modifiers.push(isMac ? "⇧" : "Shift") - const keyLabel = toKeyLabel(keyToken) + const keyLabel = toKeyLabel(parsed.key) if (isMac) { return `${modifiers.join("")}${keyLabel}` From 2d2f2a1b8c04bb616b8111a716ad3af56790aad4 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:58:10 -0700 Subject: [PATCH 4/5] fix(settings): keep new zoom defaults off chords users already assigned A default added after a profile was written used to land on top of a stored binding, so upgrading silently fired both actions. The stored binding now keeps the chord and the new action arrives unassigned. Collisions compare with matcher semantics rather than string equality, which also closes the Settings gap where mod+= and mod+shift++ read as distinct while the same event fires both. setZoomLevel returns early when the level is unchanged, so a held key at either end of the range, or reset held at 100%, no longer reaches Tauri IPC and the SQLite upsert once per repeat. stepZoomLevel reads the level from a ref written in the setter instead of a passive effect, so a fast burst cannot drop a rung. mod+0 is reachable on AZERTY: the code fallback accepts Digit for a digit binding, and a surplus Shift is tolerated on that physical key the way it already is for = and -. Ctrl over the terminal still declines, Cmd keeps zooming, and toggle_custom_style now checks the recorder flag like its neighbour. --- src/components/appearance-provider.test.tsx | 140 ++++++++++++- src/components/appearance-provider.tsx | 42 +++- src/components/settings/shortcut-settings.tsx | 22 +- src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/lib/keyboard-shortcuts.test.ts | 195 +++++++++++++++++- src/lib/keyboard-shortcuts.ts | 118 ++++++++++- 15 files changed, 499 insertions(+), 28 deletions(-) diff --git a/src/components/appearance-provider.test.tsx b/src/components/appearance-provider.test.tsx index 3aa8162888..c22efab174 100644 --- a/src/components/appearance-provider.test.tsx +++ b/src/components/appearance-provider.test.tsx @@ -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() @@ -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): number { + return spy.mock.calls.filter(([key]) => key === STORAGE_KEY_ZOOM_LEVEL) + .length + } + + function renderZoom() { + render( + +
+ term +
+
+ ) + 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") + }) +}) diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index 5b2d854817..3daf9a9d0f 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -471,16 +471,30 @@ 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 zoomLevelRef = useRef(zoomLevel) - useEffect(() => { - zoomLevelRef.current = zoomLevel - }, [zoomLevel]) + + const stepZoomLevel = useCallback( + (direction: 1 | -1) => { + setZoomLevel(stepZoom(zoomLevelRef.current, direction)) + }, + [setZoomLevel] + ) const setShowWelcomeQuickActions = useCallback((on: boolean) => { setShowWelcomeQuickActionsState(on) @@ -760,6 +774,7 @@ export function AppearanceProvider({ if (!toggleCustomStyleShortcut) return const onKeyDown = (event: KeyboardEvent) => { if (event.repeat) return + if (isShortcutRecorderArmed()) return if (!matchShortcutEvent(event, toggleCustomStyleShortcut)) return event.preventDefault() setCustomStyleSuspended(!customStyleSuspended) @@ -774,7 +789,11 @@ export function AppearanceProvider({ 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"]') ) { @@ -793,18 +812,24 @@ export function AppearanceProvider({ // prevented repeat would also trigger the page's own zoom. event.preventDefault() if (action === "in") { - setZoomLevel(stepZoom(zoomLevelRef.current, 1)) + stepZoomLevel(1) return } if (action === "out") { - setZoomLevel(stepZoom(zoomLevelRef.current, -1)) + stepZoomLevel(-1) return } setZoomLevel(DEFAULT_ZOOM_LEVEL) } window.addEventListener("keydown", onKeyDown, true) return () => window.removeEventListener("keydown", onKeyDown, true) - }, [setZoomLevel, zoomInShortcut, zoomOutShortcut, zoomResetShortcut]) + }, [ + setZoomLevel, + stepZoomLevel, + zoomInShortcut, + zoomOutShortcut, + zoomResetShortcut, + ]) // 跨标签页同步:用户在另一个窗口改了设置时,本窗口实时跟进 useEffect(() => { @@ -868,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) diff --git a/src/components/settings/shortcut-settings.tsx b/src/components/settings/shortcut-settings.tsx index b928160d36..6f2e1be95c 100644 --- a/src/components/settings/shortcut-settings.tsx +++ b/src/components/settings/shortcut-settings.tsx @@ -11,25 +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() @@ -77,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) { @@ -162,7 +154,9 @@ export function ShortcutSettings() { > {isRecording ? t("recording") - : formatShortcutLabel(shortcuts[definition.id], isMac)} + : shortcuts[definition.id] + ? formatShortcutLabel(shortcuts[definition.id], isMac) + : t("unassigned")} ) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 4543f604b3..67d7aec152 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -359,6 +359,7 @@ "resetDefault": "استعادة الإعدادات الافتراضية", "recordInstruction": "انقر الزر في الجهة اليمنى ثم اضغط تركيبة مفاتيح. استخدم Ctrl/Cmd وAlt وShift. اضغط Esc لإلغاء التسجيل.", "recording": "اضغط اختصارًا...", + "unassigned": "غير معيَّن", "toasts": { "conflict": "الاختصار مستخدم بالفعل بواسطة \"{title}\"", "updated": "تم تحديث الاختصار", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 2ef5da4f2f..689863fea3 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -359,6 +359,7 @@ "resetDefault": "Standardwerte zurücksetzen", "recordInstruction": "Klicke auf die rechte Schaltfläche und drücke dann eine Tastenkombination. Verwende Ctrl/Cmd, Alt und Shift. Drücke Esc, um die Aufzeichnung abzubrechen.", "recording": "Kurzbefehl drücken...", + "unassigned": "Nicht zugewiesen", "toasts": { "conflict": "Der Kurzbefehl wird bereits von \"{title}\" verwendet", "updated": "Kurzbefehl aktualisiert", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index e82e4039e9..48f48db644 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -359,6 +359,7 @@ "resetDefault": "Reset defaults", "recordInstruction": "Click the right-side button, then press a key combination. Use Ctrl/Cmd, Alt, and Shift. Press Esc to cancel recording.", "recording": "Press shortcut...", + "unassigned": "Unassigned", "toasts": { "conflict": "Shortcut is already used by \"{title}\"", "updated": "Shortcut updated", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index e5644e86e2..10f2f188df 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -359,6 +359,7 @@ "resetDefault": "Restablecer valores predeterminados", "recordInstruction": "Haz clic en el botón derecho y luego pulsa una combinación de teclas. Usa Ctrl/Cmd, Alt y Shift. Pulsa Esc para cancelar la grabación.", "recording": "Pulsa un atajo...", + "unassigned": "Sin asignar", "toasts": { "conflict": "El atajo ya está en uso por \"{title}\"", "updated": "Atajo actualizado", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index efdfdb84d8..689211f19e 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -359,6 +359,7 @@ "resetDefault": "Rétablir les valeurs par défaut", "recordInstruction": "Cliquez sur le bouton à droite, puis appuyez sur une combinaison de touches. Utilisez Ctrl/Cmd, Alt et Shift. Appuyez sur Échap pour annuler l’enregistrement.", "recording": "Appuyez sur un raccourci...", + "unassigned": "Non attribué", "toasts": { "conflict": "Le raccourci est déjà utilisé par \"{title}\"", "updated": "Raccourci mis à jour", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 7eb3b7b345..e870552cc0 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -359,6 +359,7 @@ "resetDefault": "デフォルトに戻す", "recordInstruction": "右側のボタンをクリックしてからキーの組み合わせを押してください。Ctrl/Cmd、Alt、Shift が使用できます。Esc で記録をキャンセルします。", "recording": "ショートカットを入力...", + "unassigned": "未割り当て", "toasts": { "conflict": "ショートカットはすでに「{title}」で使用されています", "updated": "ショートカットを更新しました", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 15f427ddcf..71cd7b8cd3 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -359,6 +359,7 @@ "resetDefault": "기본값으로 재설정", "recordInstruction": "오른쪽 버튼을 클릭한 다음 키 조합을 누르세요. Ctrl/Cmd, Alt, Shift를 사용할 수 있습니다. Esc를 누르면 기록을 취소합니다.", "recording": "단축키 입력...", + "unassigned": "할당 안 됨", "toasts": { "conflict": "단축키가 이미 \"{title}\"에서 사용 중입니다", "updated": "단축키가 업데이트되었습니다", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index f31140b116..3f690861ac 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -359,6 +359,7 @@ "resetDefault": "Restaurar padrões", "recordInstruction": "Clique no botão à direita e pressione uma combinação de teclas. Use Ctrl/Cmd, Alt e Shift. Pressione Esc para cancelar a gravação.", "recording": "Pressione um atalho...", + "unassigned": "Não atribuído", "toasts": { "conflict": "O atalho já está em uso por \"{title}\"", "updated": "Atalho atualizado", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f9eec1c2fc..a919c5e1a9 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -359,6 +359,7 @@ "resetDefault": "恢复默认", "recordInstruction": "点击右侧按钮后按下组合键即可修改。建议使用 Ctrl/Cmd、Alt、Shift 的组合。按 Esc 可取消录制。", "recording": "按下快捷键...", + "unassigned": "未分配", "toasts": { "conflict": "快捷键已被「{title}」占用", "updated": "快捷键已更新", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 0e157e7114..e0d8b56faf 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -359,6 +359,7 @@ "resetDefault": "恢復預設", "recordInstruction": "點擊右側按鈕後按下組合鍵即可修改。建議使用 Ctrl/Cmd、Alt、Shift 的組合。按 Esc 可取消錄製。", "recording": "按下快捷鍵...", + "unassigned": "未指派", "toasts": { "conflict": "快捷鍵已被「{title}」占用", "updated": "快捷鍵已更新", diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index a47ee8a98e..27959f575a 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -1,13 +1,17 @@ -import { describe, expect, it } from "vitest" +import { beforeEach, describe, expect, it } from "vitest" import { DEFAULT_SHORTCUTS, + SHORTCUTS_STORAGE_KEY, SHORTCUT_DEFINITIONS, formatShortcutLabel, matchShortcutEvent, normalizeShortcut, + readShortcutSettings, resolveWindowZoomAction, shortcutFromKeyboardEvent, + shortcutsConflict, + writeShortcutSettings, } from "./keyboard-shortcuts" function keyEvent( @@ -238,3 +242,192 @@ describe("window zoom shortcuts", () => { ).toBe(true) }) }) + +describe("digit bindings on a shifted digit row", () => { + // AZERTY shifts the digit row: unshifted Digit0 types "à", and "0" needs + // Shift. Both spellings used to miss `mod+0`, leaving reset unpressable. + it("resolves reset from the unshifted AZERTY zero key", () => { + expect( + resolveWindowZoomAction( + keyEvent("à", { ctrlKey: true, code: "Digit0" }), + defaultZoom + ) + ).toBe("reset") + }) + + it("resolves reset from the shifted AZERTY spelling that actually types 0", () => { + expect( + resolveWindowZoomAction( + keyEvent("0", { ctrlKey: true, shiftKey: true, code: "Digit0" }), + defaultZoom + ) + ).toBe("reset") + }) + + it("keeps QWERTY Ctrl+0 working, with or without event.code", () => { + expect( + resolveWindowZoomAction( + keyEvent("0", { ctrlKey: true, code: "Digit0" }), + defaultZoom + ) + ).toBe("reset") + expect( + resolveWindowZoomAction(keyEvent("0", { ctrlKey: true }), defaultZoom) + ).toBe("reset") + }) + + it("only accepts the bound digit's own physical key", () => { + expect( + matchShortcutEvent( + keyEvent("à", { ctrlKey: true, code: "Digit0" }), + "mod+1" + ) + ).toBe(false) + expect( + matchShortcutEvent( + keyEvent("&", { ctrlKey: true, code: "Digit1" }), + "mod+0" + ) + ).toBe(false) + }) + + it("leaves the surplus-Shift rule exact for non-digit keys", () => { + // The tolerance is scoped to the digit row; a letter must still be exact, + // or every mod+shift+X binding would start firing its mod+X neighbour. + expect( + matchShortcutEvent( + keyEvent("k", { ctrlKey: true, shiftKey: true, code: "KeyK" }), + "mod+k" + ) + ).toBe(false) + expect( + matchShortcutEvent( + keyEvent("Enter", { shiftKey: true, code: "Enter" }), + "enter" + ) + ).toBe(false) + }) +}) + +describe("binding conflicts use matcher semantics", () => { + it("catches two different strings that fire on the same event", () => { + // Ctrl/Cmd+Shift+= matches both, so string equality reports no conflict + // while both actions run. + expect( + matchShortcutEvent( + keyEvent("+", { ctrlKey: true, shiftKey: true }), + "mod+=" + ) + ).toBe(true) + expect( + matchShortcutEvent( + keyEvent("+", { ctrlKey: true, shiftKey: true }), + "mod+shift++" + ) + ).toBe(true) + expect(shortcutsConflict("mod+=", "mod+shift++")).toBe(true) + }) + + it("catches a digit colliding with its own shifted spelling", () => { + expect(shortcutsConflict("mod+0", "mod+shift+0")).toBe(true) + }) + + it("does not invent a conflict between genuinely distinct chords", () => { + expect(shortcutsConflict("mod+k", "mod+shift+k")).toBe(false) + expect(shortcutsConflict("mod+k", "mod+j")).toBe(false) + expect(shortcutsConflict("enter", "shift+enter")).toBe(false) + expect(shortcutsConflict("mod+=", "mod+-")).toBe(false) + }) + + it("still reports an exact duplicate", () => { + expect(shortcutsConflict("mod+b", "mod+b")).toBe(true) + }) + + it("treats an unparseable or unbound side as no conflict", () => { + expect(shortcutsConflict("", "mod+b")).toBe(false) + expect(shortcutsConflict("mod+b", "")).toBe(false) + }) +}) + +describe("a new default never steals a stored binding", () => { + beforeEach(() => { + localStorage.clear() + }) + + it("leaves the new action unbound when the chord is already claimed", () => { + // Exactly the payload the previously shipped app could have written: the + // zoom keys are new, so they are absent and get seeded on top. + localStorage.setItem( + SHORTCUTS_STORAGE_KEY, + JSON.stringify({ toggle_search: "mod+-", toggle_sidebar: "mod+0" }) + ) + + const settings = readShortcutSettings() + + expect(settings.toggle_search).toBe("mod+-") + expect(settings.toggle_sidebar).toBe("mod+0") + expect(settings.zoom_out).toBe("") + expect(settings.zoom_reset).toBe("") + // The one that does not collide still arrives on its default. + expect(settings.zoom_in).toBe(DEFAULT_SHORTCUTS.zoom_in) + }) + + it("compares with matcher semantics, not string equality", () => { + // `mod+shift++` is not the string `mod+=`, but the same event fires both. + localStorage.setItem( + SHORTCUTS_STORAGE_KEY, + JSON.stringify({ toggle_search: "mod+shift++" }) + ) + + expect(readShortcutSettings().zoom_in).toBe("") + }) + + it("survives a later write instead of reseeding the collision", () => { + localStorage.setItem( + SHORTCUTS_STORAGE_KEY, + JSON.stringify({ toggle_sidebar: "mod+0" }) + ) + + const settings = readShortcutSettings() + expect(settings.zoom_reset).toBe("") + + // Changing something unrelated writes the whole object back. The unbound + // action has to stay unbound, or the collision returns on the next read. + writeShortcutSettings({ ...settings, toggle_terminal: "mod+shift+j" }) + + const reread = readShortcutSettings() + expect(reread.zoom_reset).toBe("") + expect(reread.toggle_sidebar).toBe("mod+0") + expect(reread.toggle_terminal).toBe("mod+shift+j") + }) + + it("still seeds every default for a profile that never stored anything", () => { + expect(readShortcutSettings()).toEqual(DEFAULT_SHORTCUTS) + }) + + it("keeps the pairs that are meant to share a chord", () => { + // new_terminal_tab and new_conversation ship on the same chord on purpose, + // so a stored copy of one must not unbind the other. + localStorage.setItem( + SHORTCUTS_STORAGE_KEY, + JSON.stringify({ new_terminal_tab: "mod+t" }) + ) + + const settings = readShortcutSettings() + expect(settings.new_terminal_tab).toBe("mod+t") + expect(settings.new_conversation).toBe(DEFAULT_SHORTCUTS.new_conversation) + }) + + it("does not unbind a default over a collision between two defaults", () => { + localStorage.setItem( + SHORTCUTS_STORAGE_KEY, + JSON.stringify({ toggle_search: "mod+shift+f" }) + ) + + const settings = readShortcutSettings() + for (const definition of SHORTCUT_DEFINITIONS) { + if (definition.id === "toggle_search") continue + expect(settings[definition.id]).toBe(DEFAULT_SHORTCUTS[definition.id]) + } + }) +}) diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index 0dd2b185e3..648d199276 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -89,6 +89,25 @@ export const INPUT_SHORTCUT_IDS = new Set([ "newline_in_message", ]) +/** + * Pairs that are meant to share a chord: the two actions never apply to the + * same surface, so one key can serve both. + */ +const SHARED_SHORTCUT_PAIRS: Array<[ShortcutActionId, ShortcutActionId]> = [ + ["new_terminal_tab", "new_conversation"], + ["close_current_terminal_tab", "close_current_tab"], +] + +export function canShareShortcut( + a: ShortcutActionId, + b: ShortcutActionId +): boolean { + return SHARED_SHORTCUT_PAIRS.some( + ([left, right]) => + (left === a && right === b) || (left === b && right === a) + ) +} + export type ShortcutSettings = Record export const DEFAULT_SHORTCUTS: ShortcutSettings = { @@ -121,6 +140,7 @@ export const SHORTCUTS_STORAGE_KEY = "settings:shortcuts:v1" export const SHORTCUTS_UPDATED_EVENT = "codeg:shortcuts-updated" const FUNCTION_KEY_PATTERN = /^f\d{1,2}$/ +const DIGIT_KEY_PATTERN = /^[0-9]$/ const MODIFIER_KEY_SET = new Set(["shift", "meta", "control", "alt"]) const SPECIAL_KEY_ALIASES: Record = { @@ -214,12 +234,44 @@ function normalizeSettings(input: unknown): ShortcutSettings { if (!input || typeof input !== "object") return next const record = input as Record + const stored = new Set() for (const definition of SHORTCUT_DEFINITIONS) { const rawValue = record[definition.id] if (typeof rawValue !== "string") continue + // An empty string is a real state, not a missing key: it is what an action + // holds once its default lost to a stored binding below. Reseeding the + // default here would put the collision straight back on the next write. + if (!rawValue.trim()) { + next[definition.id] = "" + stored.add(definition.id) + continue + } + const normalized = normalizeShortcut(rawValue) - if (normalized) next[definition.id] = normalized + if (!normalized) continue + next[definition.id] = normalized + stored.add(definition.id) + } + + // A default added after this profile was written can land on a chord the user + // already assigned. Both actions would then fire, with nothing on screen to + // say so, so the stored binding keeps the chord and the new action arrives + // unbound for the user to place. + for (const definition of SHORTCUT_DEFINITIONS) { + if (stored.has(definition.id)) continue + + const seeded = next[definition.id] + if (!seeded) continue + + const taken = SHORTCUT_DEFINITIONS.some( + (other) => + other.id !== definition.id && + stored.has(other.id) && + !canShareShortcut(other.id, definition.id) && + shortcutsConflict(next[other.id], seeded) + ) + if (taken) next[definition.id] = "" } return next @@ -393,6 +445,22 @@ function matchesNumpadCode( return false } +/** + * A digit binding must also fire from its own digit-row key on layouts that + * shift that row. On AZERTY unshifted `Digit0` types `à` and the digit needs + * Shift, so `mod+0` matches neither spelling on `event.key` alone. + * + * `Digit` names one physical key, so this cannot mis-fire the way a bare + * `Minus`/`Equal` fallback would. + */ +function matchesDigitRowCode( + event: ShortcutEventLike, + boundKey: string +): boolean { + if (!DIGIT_KEY_PATTERN.test(boundKey)) return false + return event.code === `Digit${boundKey}` +} + export function matchShortcutEvent( event: ShortcutEventLike, shortcut: string @@ -403,23 +471,63 @@ export function matchShortcutEvent( const keys = siblingKeys(parsed.key) const actualKey = eventKeyToken(event) const matchesKey = actualKey !== null && keys.has(actualKey) - if (!matchesKey && !matchesNumpadCode(event, parsed.key)) return false + const matchesDigitRow = matchesDigitRowCode(event, parsed.key) + if ( + !matchesKey && + !matchesDigitRow && + !matchesNumpadCode(event, parsed.key) + ) { + return false + } const hasMod = event.metaKey || event.ctrlKey if (hasMod !== parsed.mod) return false if (event.altKey !== parsed.alt) return false - // Extra Shift is how `=` becomes `+` (and `-` becomes `_`). Require Shift - // when the binding asked for it; ignore a surplus Shift only on those pairs. + // Extra Shift is how `=` becomes `+` (and `-` becomes `_`), and on a shifted + // digit row it is how you type the digit at all. Require Shift when the + // binding asked for it; ignore a surplus Shift only in those cases. if (parsed.shift) { if (!event.shiftKey) return false - } else if (event.shiftKey && keys.size === 1) { + } else if (event.shiftKey && keys.size === 1 && !matchesDigitRow) { return false } return true } +/** + * Two bindings collide when some event matches both. String equality misses + * that: `mod+=` and `mod+shift++` are different strings that both fire on + * Ctrl/Cmd+Shift+=. Round-trip each side through the real matcher instead. + */ +export function shortcutsConflict(a: string, b: string): boolean { + const parsedA = parseShortcut(a) + const parsedB = parseShortcut(b) + if (!parsedA || !parsedB) return false + + return ( + matchShortcutEvent(syntheticEvent(parsedA), b) || + matchShortcutEvent(syntheticEvent(parsedB), a) + ) +} + +/** The canonical event a binding describes, as the matcher would see it. */ +function syntheticEvent(parsed: ParsedShortcut): ShortcutEventLike { + return { + key: parsed.key, + metaKey: parsed.mod, + ctrlKey: false, + altKey: parsed.alt, + shiftKey: parsed.shift, + // Carry the physical key for digits so the shifted-digit-row tolerance + // above is visible to conflict checks too. + ...(DIGIT_KEY_PATTERN.test(parsed.key) + ? { code: `Digit${parsed.key}` } + : {}), + } +} + export function resolveWindowZoomAction( event: ShortcutEventLike, shortcuts: Pick From 1bc10ff8c538a9c280fc35f5ed662f3398443df2 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:56:11 -0700 Subject: [PATCH 5/5] fix(settings): keep the digit-row fallback off shifted keys --- src/lib/keyboard-shortcuts.test.ts | 24 ++++++++++++++++++++++++ src/lib/keyboard-shortcuts.ts | 17 ++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index 27959f575a..e34deb34ce 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -276,6 +276,30 @@ describe("digit bindings on a shifted digit row", () => { ).toBe("reset") }) + // The positional fallback must not claim a SHIFTED digit key: with Shift that + // key types a character that belongs to another binding, so matching by + // position would make one press satisfy two. + it("leaves shifted US Digit0 to whoever owns the character it types", () => { + expect( + matchShortcutEvent( + keyEvent(")", { metaKey: true, shiftKey: true, code: "Digit0" }), + "mod+0" + ) + ).toBe(false) + }) + + it("gives QWERTZ Ctrl+Shift+0 to zoom in only, not also to reset", () => { + // On German QWERTZ that keypress types "=", which is the zoom-in binding. + const event = keyEvent("=", { + ctrlKey: true, + shiftKey: true, + code: "Digit0", + }) + expect(matchShortcutEvent(event, "mod+0")).toBe(false) + expect(matchShortcutEvent(event, "mod+=")).toBe(true) + expect(resolveWindowZoomAction(event, defaultZoom)).toBe("in") + }) + it("only accepts the bound digit's own physical key", () => { expect( matchShortcutEvent( diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index 648d199276..4709b04529 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -458,6 +458,17 @@ function matchesDigitRowCode( boundKey: string ): boolean { if (!DIGIT_KEY_PATTERN.test(boundKey)) return false + // Unshifted only: with Shift the digit row yields a character that belongs to + // someone else — ")" on US, "=" on QWERTZ — and claiming it by position makes + // one press satisfy two bindings. + // + // This narrows the positional fallback rather than closing it: the unshifted + // half remains, because AZERTY puts `-` on Digit6 and `_` on Digit8, so + // `Ctrl+-` matches both `mod+-` and `mod+6` while `shortcutsConflict` cannot + // see it (the recorder serialises from `key`, this matches by `code`, and the + // synthetic event only rebuilds the `key` form). Latent while nothing binds + // digits 1-9; any `Digit` positional fallback has this shape. + if (event.shiftKey) return false return event.code === `Digit${boundKey}` } @@ -489,7 +500,11 @@ export function matchShortcutEvent( // binding asked for it; ignore a surplus Shift only in those cases. if (parsed.shift) { if (!event.shiftKey) return false - } else if (event.shiftKey && keys.size === 1 && !matchesDigitRow) { + } else if ( + event.shiftKey && + keys.size === 1 && + !DIGIT_KEY_PATTERN.test(parsed.key) + ) { return false }