diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 4adf945b61..74f43209e2 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2333,6 +2333,7 @@ export const ar: TranslationResources = { searchPlaceholder: "البحث في الاختصارات", unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب", capturePrompt: "اضغط على الاختصار...", + captureIndexPrompt: "اختم برقم من 1 إلى 9.", unassigned: "غير معين", actions: { menu: "إجراءات {{name}}", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9bb0460036..5aedaad115 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2456,6 +2456,7 @@ export const en = { searchPlaceholder: "Search shortcuts", unavailableOnMobile: "Keyboard shortcuts are only available on desktop", capturePrompt: "Press shortcut...", + captureIndexPrompt: "End with a number from 1 to 9.", unassigned: "Not set", actions: { menu: "Actions for {{name}}", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 9ed1e85572..a996fc97de 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2387,6 +2387,7 @@ export const es: TranslationResources = { searchPlaceholder: "Buscar atajos", unavailableOnMobile: "Los atajos de teclado solo están disponibles en el escritorio", capturePrompt: "Presione el acceso directo...", + captureIndexPrompt: "Termina con un número del 1 al 9.", unassigned: "Sin asignar", actions: { menu: "Acciones para {{name}}", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6563b1100e..253b4135f9 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2387,6 +2387,7 @@ export const fr: TranslationResources = { searchPlaceholder: "Rechercher des raccourcis", unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur l’app de bureau", capturePrompt: "Appuyez sur le raccourci…", + captureIndexPrompt: "Terminez par un chiffre de 1 à 9.", unassigned: "Non attribué", actions: { menu: "Actions pour {{name}}", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index eb22dd663a..7e22c7d5b9 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2354,6 +2354,7 @@ export const ja: TranslationResources = { searchPlaceholder: "ショートカットを検索", unavailableOnMobile: "キーボードショートカットはデスクトップでのみ利用できます", capturePrompt: "ショートカットを押してください...", + captureIndexPrompt: "最後に1〜9の数字を入力してください。", unassigned: "未設定", actions: { menu: "{{name}} のアクション", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index afdb8974e9..a6d8c44cbf 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2343,6 +2343,7 @@ export const ko: TranslationResources = { searchPlaceholder: "검색 단축키", unavailableOnMobile: "키보드 단축키는 데스크톱에서만 사용할 수 있습니다", capturePrompt: "단축키를 누르세요...", + captureIndexPrompt: "마지막에 1부터 9까지의 숫자를 입력하세요.", unassigned: "설정되지 않음", actions: { menu: "{{name}} 작업", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f89fb97bf0..ecda7818af 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2368,6 +2368,7 @@ export const ptBR: TranslationResources = { searchPlaceholder: "Pesquisar atalhos", unavailableOnMobile: "Atalhos de teclado estão disponíveis apenas no desktop", capturePrompt: "Pressione o atalho...", + captureIndexPrompt: "Termine com um número de 1 a 9.", unassigned: "Não definido", actions: { menu: "Ações para {{name}}", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index a1a70822b4..e110866c52 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2373,6 +2373,7 @@ export const ru: TranslationResources = { searchPlaceholder: "Поиск сочетаний клавиш", unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.", capturePrompt: "Нажмите сочетание клавиш...", + captureIndexPrompt: "Завершите цифрой от 1 до 9.", unassigned: "Не задано", actions: { menu: "Действия для {{name}}", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index ca221d7d3a..f60f1a385b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2305,6 +2305,7 @@ export const zhCN: TranslationResources = { searchPlaceholder: "搜索快捷键", unavailableOnMobile: "键盘快捷键仅在桌面端可用", capturePrompt: "按下快捷键...", + captureIndexPrompt: "请以 1 到 9 的数字结尾。", unassigned: "未设置", actions: { menu: "{{name}} 的操作", diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 9e56ef66cd..770d7e710c 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -4,7 +4,9 @@ import { keyboardEventToComboString } from "./shortcut-string"; import { buildKeyboardShortcutHelpSections, buildEffectiveBindings, + DEFAULT_BINDINGS, normalizeCapturedShortcutCombo, + isShortcutCaptureComplete, getBindingIdForAction, getDefaultKeysForAction, getWorkspaceIndexJumpModifierKey, @@ -1390,3 +1392,22 @@ describe("number-group shortcut capture", () => { expect(normalizeCapturedShortcutCombo(tabBindingId, "Ctrl+9")).toBe("Ctrl+Digit"); }); }); + +describe("shortcut capture completion", () => { + const indexBindings = DEFAULT_BINDINGS.filter((binding) => binding.payload?.type === "index"); + it.each(indexBindings)("requires a final 1–9 number for $id", ({ id }) => { + for (const combos of [[], ["Ctrl+K"], ["Ctrl+0"], ["Ctrl+Digit", "Ctrl+K"]]) { + expect(isShortcutCaptureComplete(id, combos)).toBe(false); + } + for (let index = 1; index <= 9; index++) { + const combo = normalizeCapturedShortcutCombo(id, `Ctrl+${index}`); + expect(isShortcutCaptureComplete(id, [combo])).toBe(true); + expect(isShortcutCaptureComplete(id, ["Ctrl+K", combo])).toBe(true); + } + }); + it("keeps ordinary shortcuts available without a number", () => { + expect(isShortcutCaptureComplete("show-shortcuts", [])).toBe(false); + expect(isShortcutCaptureComplete("show-shortcuts", ["Ctrl+K"])).toBe(true); + expect(isShortcutCaptureComplete("show-shortcuts", ["Ctrl+0"])).toBe(true); + }); +}); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index a009008b85..9f6329c013 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -1217,6 +1217,14 @@ export function normalizeCapturedShortcutCombo(bindingId: string, combo: string) return combo.replace(/(^|\+)[1-9]$/, "$1Digit"); } +/** Index navigation needs a final number; earlier combos may be any chord prefix. */ +export function isShortcutCaptureComplete(bindingId: string, combos: readonly string[]): boolean { + const lastCombo = combos.at(-1); + if (!lastCombo) return false; + const binding = DEFAULT_BINDINGS.find((candidate) => candidate.id === bindingId); + return binding?.payload?.type !== "index" || /(^|\+)(Digit|[1-9])$/.test(lastCombo); +} + export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShortcutBinding[] { return DEFAULT_BINDINGS.map(function (binding) { const override = overrides[binding.id]; diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index 8a8c4b42b9..6a8459fc3f 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -20,6 +20,7 @@ import { buildKeyboardShortcutHelpSections, getBindingIdForAction, normalizeCapturedShortcutCombo, + isShortcutCaptureComplete, getDefaultKeysForAction, resolveShortcutKeysForAction, type KeyboardShortcutHelpRow, @@ -269,6 +270,8 @@ function ShortcutRow({ const isBindable = bindingId !== null; const showDone = isCapturing && capturedCombos.length > 0; + const captureComplete = + bindingId !== null && isShortcutCaptureComplete(bindingId, capturedCombos); const showClear = displayChord !== null; // Reset restores the default, so it is only meaningful when there is a // default to restore. A binding that ships without one would otherwise show a @@ -288,11 +291,14 @@ function ShortcutRow({ capturedCombos={capturedCombos} heldModifiers={heldModifiers} /> + {showDone && !captureComplete && ( + {t("settings.shortcuts.captureIndexPrompt")} + )} {isCapturing ? ( <> {showDone && ( - )} @@ -357,7 +363,10 @@ export function KeyboardShortcutsSection() { ); const saveCapture = useCallback(() => { - if (capturingBindingId === null || capturedCombos.length === 0) { + if ( + capturingBindingId === null || + !isShortcutCaptureComplete(capturingBindingId, capturedCombos) + ) { return; } void setOverride(capturingBindingId, capturedCombos.join(" ")); diff --git a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts index b7063b4565..86aef1b880 100644 --- a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts +++ b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts @@ -261,3 +261,35 @@ test("rebinding Jump to tab preserves the number group after reload", async ({ await openSettingsSection(page, "shortcuts"); await expectTabNumberRange(page); }); + +async function expectNumberRequiredBeforeSaving(page: Page, label: string) { + const row = page.getByText(label, { exact: true }).locator(".."); + await expect(row.getByRole("button", { name: "Done", exact: true })).toBeDisabled(); + await expect(row.getByText("End with a number from 1 to 9.", { exact: true })).toBeVisible(); +} + +async function completeNumberShortcut(page: Page, label: string) { + const row = page.getByText(label, { exact: true }).locator(".."); + await page.keyboard.press("Backspace"); + await page.keyboard.press("Control+2"); + await expect(row.getByRole("button", { name: "Done", exact: true })).toBeEnabled(); + await row.getByRole("button", { name: "Done", exact: true }).click(); + await expect(row.getByText("⌃K", { exact: true })).toBeVisible(); + await expect(row.getByText("⌃1-9", { exact: true })).toBeVisible(); +} + +for (const { label, action } of [ + { label: "Jump to tab", action: "workspace-tab-jump-index" }, + { label: "Jump to workspace", action: "workspace-jump-index" }, +]) { + test(`${label} requires a final number before a recorded shortcut can be saved`, async ({ + page, + }) => { + await openShortcutsSettings(page); + await captureShortcut(page, label, action, "Control+K"); + await expectNumberRequiredBeforeSaving(page, label); + await page.keyboard.press("Control+0"); + await expectNumberRequiredBeforeSaving(page, label); + await completeNumberShortcut(page, label); + }); +}