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 4cdebc977d..3daf9a9d0f 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 { + isShortcutRecorderArmed, + matchShortcutEvent, + resolveWindowZoomAction, +} from "@/lib/keyboard-shortcuts" import { DEFAULT_WORKSPACE_BG_ENABLED, DEFAULT_WORKSPACE_BG_MASK_OPACITY, @@ -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") @@ -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) @@ -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([ @@ -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) diff --git a/src/components/settings/shortcut-settings.tsx b/src/components/settings/shortcut-settings.tsx index 97b3dfd9f9..6f2e1be95c 100644 --- a/src/components/settings/shortcut-settings.tsx +++ b/src/components/settings/shortcut-settings.tsx @@ -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() @@ -53,6 +44,11 @@ export function ShortcutSettings() { [shortcuts] ) + useEffect(() => { + setShortcutRecorderArmed(Boolean(recordingAction)) + return () => setShortcutRecorderArmed(false) + }, [recordingAction]) + useEffect(() => { if (!recordingAction) return @@ -60,6 +56,7 @@ export function ShortcutSettings() { if (event.repeat) return event.preventDefault() event.stopPropagation() + event.stopImmediatePropagation() if (event.key === "Escape") { setRecordingAction(null) @@ -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) { @@ -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")} ) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 9aa997b032..67d7aec152 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}%" @@ -359,6 +359,7 @@ "resetDefault": "استعادة الإعدادات الافتراضية", "recordInstruction": "انقر الزر في الجهة اليمنى ثم اضغط تركيبة مفاتيح. استخدم Ctrl/Cmd وAlt وShift. اضغط Esc لإلغاء التسجيل.", "recording": "اضغط اختصارًا...", + "unassigned": "غير معيَّن", "toasts": { "conflict": "الاختصار مستخدم بالفعل بواسطة \"{title}\"", "updated": "تم تحديث الاختصار", @@ -429,6 +430,18 @@ "toggle_custom_style": { "title": "إيقاف/استئناف النمط المخصص", "description": "مخرج طوارئ: يوقف كل الألوان المخصصة وCSS، ويعيد تفعيلها" + }, + "zoom_in": { + "title": "تكبير", + "description": "اجعل النافذة أكبر بدرجة واحدة" + }, + "zoom_out": { + "title": "تصغير", + "description": "اجعل النافذة أصغر بدرجة واحدة" + }, + "zoom_reset": { + "title": "إعادة ضبط التكبير", + "description": "أعد تكبير النافذة إلى 100%" } } }, diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index c368aa0bfd..689863fea3 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}%" @@ -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", @@ -429,6 +430,18 @@ "toggle_custom_style": { "title": "Eigenen Stil aussetzen/fortsetzen", "description": "Notausstieg: schaltet alle eigenen Farben und CSS aus und wieder ein" + }, + "zoom_in": { + "title": "Vergrößern", + "description": "Das Fenster eine Stufe größer machen" + }, + "zoom_out": { + "title": "Verkleinern", + "description": "Das Fenster eine Stufe kleiner machen" + }, + "zoom_reset": { + "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 422884785e..48f48db644 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 - move through the same zoom levels as this menu (⌘/Ctrl 0 resets to 100%).", "placeholder": "Select zoom level", "default": "Default", "current": "Current zoom: {zoom}%" @@ -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", @@ -429,6 +430,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": "Make the window one step larger" + }, + "zoom_out": { + "title": "Zoom out", + "description": "Make the window one step smaller" + }, + "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..10f2f188df 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}%" @@ -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", @@ -429,6 +430,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": "Acercar", + "description": "Amplía la ventana un nivel" + }, + "zoom_out": { + "title": "Alejar", + "description": "Reduce la ventana un nivel" + }, + "zoom_reset": { + "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 05e52f8349..689211f19e 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}%" @@ -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", @@ -429,6 +430,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 avant", + "description": "Agrandit la fenêtre d'un cran" + }, + "zoom_out": { + "title": "Zoom arrière", + "description": "Réduit la fenêtre d'un cran" + }, + "zoom_reset": { + "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 51f97a0fcf..e870552cc0 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}%" @@ -359,6 +359,7 @@ "resetDefault": "デフォルトに戻す", "recordInstruction": "右側のボタンをクリックしてからキーの組み合わせを押してください。Ctrl/Cmd、Alt、Shift が使用できます。Esc で記録をキャンセルします。", "recording": "ショートカットを入力...", + "unassigned": "未割り当て", "toasts": { "conflict": "ショートカットはすでに「{title}」で使用されています", "updated": "ショートカットを更新しました", @@ -429,6 +430,18 @@ "toggle_custom_style": { "title": "カスタムスタイルの停止/再開", "description": "緊急脱出用: カスタム配色と CSS をすべてオフにし、再度押すと元に戻します" + }, + "zoom_in": { + "title": "拡大", + "description": "ウィンドウの表示倍率を一段階上げます" + }, + "zoom_out": { + "title": "縮小", + "description": "ウィンドウの表示倍率を一段階下げます" + }, + "zoom_reset": { + "title": "ズームをリセット", + "description": "ウィンドウのズームを 100% に戻します" } } }, diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index daf9cc2854..71cd7b8cd3 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}%" @@ -359,6 +359,7 @@ "resetDefault": "기본값으로 재설정", "recordInstruction": "오른쪽 버튼을 클릭한 다음 키 조합을 누르세요. Ctrl/Cmd, Alt, Shift를 사용할 수 있습니다. Esc를 누르면 기록을 취소합니다.", "recording": "단축키 입력...", + "unassigned": "할당 안 됨", "toasts": { "conflict": "단축키가 이미 \"{title}\"에서 사용 중입니다", "updated": "단축키가 업데이트되었습니다", @@ -429,6 +430,18 @@ "toggle_custom_style": { "title": "사용자 지정 스타일 중지/재개", "description": "비상 탈출구: 모든 사용자 지정 색상과 CSS를 끄고, 다시 누르면 되돌립니다" + }, + "zoom_in": { + "title": "확대", + "description": "창을 한 단계 더 크게 만듭니다" + }, + "zoom_out": { + "title": "축소", + "description": "창을 한 단계 더 작게 만듭니다" + }, + "zoom_reset": { + "title": "확대/축소 초기화", + "description": "창 확대/축소를 100%로 되돌립니다" } } }, diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index de196b6e75..3f690861ac 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}%" @@ -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", @@ -429,6 +430,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": "Aumentar zoom", + "description": "Aumenta a janela em um nível" + }, + "zoom_out": { + "title": "Diminuir zoom", + "description": "Diminui a janela em um nível" + }, + "zoom_reset": { + "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 4f0c74f0a7..a919c5e1a9 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}%" @@ -359,6 +359,7 @@ "resetDefault": "恢复默认", "recordInstruction": "点击右侧按钮后按下组合键即可修改。建议使用 Ctrl/Cmd、Alt、Shift 的组合。按 Esc 可取消录制。", "recording": "按下快捷键...", + "unassigned": "未分配", "toasts": { "conflict": "快捷键已被「{title}」占用", "updated": "快捷键已更新", @@ -429,6 +430,18 @@ "toggle_custom_style": { "title": "停用/恢复自定义样式", "description": "逃生舱:一键关闭全部自定义配色与 CSS,再按一次恢复" + }, + "zoom_in": { + "title": "放大", + "description": "把窗口缩放提高一档" + }, + "zoom_out": { + "title": "缩小", + "description": "把窗口缩放降低一档" + }, + "zoom_reset": { + "title": "重置缩放", + "description": "把窗口缩放恢复为 100%" } } }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index f4349960ee..e0d8b56faf 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}%" @@ -359,6 +359,7 @@ "resetDefault": "恢復預設", "recordInstruction": "點擊右側按鈕後按下組合鍵即可修改。建議使用 Ctrl/Cmd、Alt、Shift 的組合。按 Esc 可取消錄製。", "recording": "按下快捷鍵...", + "unassigned": "未指派", "toasts": { "conflict": "快捷鍵已被「{title}」占用", "updated": "快捷鍵已更新", @@ -429,6 +430,18 @@ "toggle_custom_style": { "title": "停用/恢復自訂樣式", "description": "逃生艙:一鍵關閉全部自訂配色與 CSS,再按一次恢復" + }, + "zoom_in": { + "title": "放大", + "description": "把視窗縮放提高一檔" + }, + "zoom_out": { + "title": "縮小", + "description": "把視窗縮放降低一檔" + }, + "zoom_reset": { + "title": "重設縮放", + "description": "把視窗縮放恢復為 100%" } } }, diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index d32d869494..e34deb34ce 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -1,10 +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( @@ -117,3 +124,334 @@ describe("alt combinations use event.code", () => { ).toBe(true) }) }) + +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) + 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("lets + survive normalize so Ctrl/Cmd Shift = can be recorded", () => { + expect(normalizeShortcut("mod++")).toBe("mod++") + expect(normalizeShortcut("mod+shift++")).toBe("mod+shift++") + expect( + 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(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("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) + }) +}) + +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") + }) + + // 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( + 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 342995d881..4709b04529 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). */ @@ -77,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 = { @@ -98,12 +129,18 @@ 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" 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 = { @@ -117,6 +154,40 @@ const SPECIAL_KEY_ALIASES: Record = { right: "arrowright", } +/** + * `=`/`+` 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 = { space: "Space", escape: "Esc", @@ -163,70 +234,120 @@ 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 } -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("+") } @@ -306,31 +427,132 @@ export function shortcutFromKeyboardEvent( return parts.join("+") } +function siblingKeys(keyToken: string): Set { + const sibling = PHYSICAL_KEY_SIBLINGS[keyToken] + return sibling ? new Set([keyToken, sibling]) : new Set([keyToken]) +} + +function matchesNumpadCode( + event: ShortcutEventLike, + boundKey: string +): boolean { + if (boundKey === "=" || boundKey === "+") { + return event.code === "NumpadAdd" + } + if (boundKey === "-" || boundKey === "_") { + return event.code === "NumpadSubtract" + } + 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 + // 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}` +} + 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) + const matchesDigitRow = matchesDigitRowCode(event, parsed.key) + if ( + !matchesKey && + !matchesDigitRow && + !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 `_`), 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 && + !DIGIT_KEY_PATTERN.test(parsed.key) + ) { + 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 +): "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 @@ -342,18 +564,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}` 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] +}