diff --git a/desktop/frontend-next/src/design/settings.css b/desktop/frontend-next/src/design/settings.css index b290015039..8159077ab3 100644 --- a/desktop/frontend-next/src/design/settings.css +++ b/desktop/frontend-next/src/design/settings.css @@ -33,7 +33,7 @@ .prefs-col[data-cols="2"] { max-width: 1400px; column-count: 2; column-gap: 16px; } .prefs-col[data-cols="2"] > .prefs-card { break-inside: avoid; } } -.prefs-card > .srow { display: flex; align-items: center; gap: 16px; padding: 10px 0; border-top: 1px solid var(--hair); } +.prefs-card > .srow, .grp[data-setting="global-shortcut"] .srow { display: flex; align-items: center; gap: 16px; padding: 10px 0; border-top: 1px solid var(--hair); } .srow-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .srow-name { color: var(--text); font: var(--w-med) var(--fs-ui)/1.4 var(--ui); } .srow-desc { color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); } @@ -41,6 +41,16 @@ .srow-ctl { flex: none; display: flex; align-items: center; gap: 8px; } .spill { height: 24px; padding: 0 10px; border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--text); font: var(--w-reg) var(--fs-meta) var(--ui); white-space: nowrap; cursor: pointer; } .spill:hover:not(:disabled) { background: var(--overlay); } +.spill:disabled { opacity: .5; cursor: default; } +.keybind { box-sizing: border-box; width: 220px; height: 28px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-sm); background: transparent; color: var(--text); font: var(--w-reg) var(--fs-meta) var(--mono); } +.keybind::placeholder { color: var(--faint); } +.keybind:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } +.keybind[data-recording] { border-color: var(--net); background: var(--net-wash); } +.keyacts { flex-wrap: wrap; row-gap: 8px; } +.keyacts > .srow-meta { flex: 1 1 16em; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 4px 10px; } +.keyacts > .srow-meta > .srow-desc { flex: 1 1 14em; min-width: 0; } +.keycap { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-micro) var(--mono); } +.grp[data-setting="global-shortcut"] > .hint { overflow: visible; text-overflow: clip; white-space: normal; } .srow-more { padding: 0 0 6px; } .srow-more .lrow { padding: 8px 0 8px 16px; border-top: 1px solid var(--hair); } .apply::before { display: none; } diff --git a/desktop/frontend-next/src/ui/GlobalShortcutSection.tsx b/desktop/frontend-next/src/ui/GlobalShortcutSection.tsx index 5250d11345..a0bd370d92 100644 --- a/desktop/frontend-next/src/ui/GlobalShortcutSection.tsx +++ b/desktop/frontend-next/src/ui/GlobalShortcutSection.tsx @@ -63,25 +63,33 @@ export function GlobalShortcutSection() { return (
{ event.preventDefault(); void apply(draft); }}> - - {draft &&

{acceleratorLabel(draft)}

} -

{t("settings.click_record_and_press_a")}

-
- - - +
+
+ {t("settings.shortcut_binding")} + {now && {t(STATUS[now.status], { value: acceleratorLabel(now.binding) })}} +
+
+ setDraft(event.target.value)} onBlur={() => setRecording(false)} onKeyDown={event => { + if (!recording || event.key === "Tab") return; + event.preventDefault(); event.stopPropagation(); + if (event.key === "Escape" && !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { setRecording(false); return; } + const binding = capturedBinding(event); + if (binding) { setDraft(binding); setRecording(false); } + }} disabled={!now || busy} /> +
+
+
+
+ {draft && {acceleratorLabel(draft)}} + {t("settings.click_record_and_press_a")} +
+
+ + + +
- {now &&

{t(STATUS[now.status], { value: acceleratorLabel(now.binding) })}

} {now?.error &&

{t(ERRORS[now.error])}

} {failed &&

{t("settings.could_not_read_or_change")}

} diff --git a/desktop/frontend-next/src/ui/global-shortcut.interaction.test.tsx b/desktop/frontend-next/src/ui/global-shortcut.interaction.test.tsx index 58ff35a07c..816bff6c53 100644 --- a/desktop/frontend-next/src/ui/global-shortcut.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/global-shortcut.interaction.test.tsx @@ -140,3 +140,32 @@ it.each([ expect(input.value).toBe(binding); expect(fixture.apply).not.toHaveBeenCalled(); }); + +it("suggests a chord in the notation the person reads, not Electron's accelerator grammar", async () => { + render(); + await screen.findByRole("status"); + const input = screen.getByRole("textbox") as HTMLInputElement; + expect(input.placeholder).toBe(acceleratorLabel("CommandOrControl+Shift+Space")); + expect(input.placeholder).not.toContain("CommandOrControl"); +}); + +it("marks the field while it is listening for a chord and releases it afterwards", async () => { + render(); + await screen.findByRole("status"); + const input = screen.getByRole("textbox") as HTMLInputElement; + expect(input.hasAttribute("data-recording")).toBe(false); + await userEvent.click(screen.getByRole("button", { name: t("settings.record_shortcut") })); + expect(input.hasAttribute("data-recording")).toBe(true); + fireEvent.keyDown(input, { key: "k", code: "KeyK", ctrlKey: true }); + expect(input.hasAttribute("data-recording")).toBe(false); +}); + +it("keeps the status and the actions inside one form, so Enter applies what the field holds", async () => { + fixture.apply.mockImplementation(async (binding: string) => ({ binding, status: "registered" })); + render(); + await screen.findByRole("status"); + const input = screen.getByRole("textbox") as HTMLInputElement; + await userEvent.type(input, "Control+Space{Enter}"); + expect(fixture.apply).toHaveBeenLastCalledWith("Control+Space"); + expect(input.form).toBe(screen.getByRole("button", { name: t("settings.apply_shortcut") }).closest("form")); +});