fix(studio): the global shortcut setting reads like its neighbours; the chord is shown in the notation people use, the three one-shot actions are pill buttons instead of a selector strip, recording is visible on the field, and the explanatory text wraps instead of being cut off

This commit is contained in:
yhh committed 2026-11-06 18:22:53 -08:00
1 parent 0a38f2e47a
commit 8658f020af
3 files changed
+65 -18

No files matched your search

+11 -1
View File
@@ -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; }
@@ -63,25 +63,33 @@ export function GlobalShortcutSection() {
return (
<Group id="global-shortcut" title={t("settings.global_show_hide_shortcut")} hint={t("settings.off_by_default_while_studio") }>
<form data-action="window.global-shortcut" data-value="apply" onSubmit={event => { event.preventDefault(); void apply(draft); }}>
<label className="lrow">
<span className="tx"><span className="lb">{t("settings.shortcut_binding")}</span></span>
<input ref={input} className="fontown" data-action-change="window.global-shortcut" data-action-keydown="window.global-shortcut" data-value="binding" aria-label={t("settings.shortcut_binding")} maxLength={128} placeholder={recording ? t("settings.press_a_modifier_and_key") : "CommandOrControl+Shift+Space"} value={draft} readOnly={recording} onChange={event => 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} />
</label>
{draft && <p className="note"><kbd>{acceleratorLabel(draft)}</kbd></p>}
<p className="note">{t("settings.click_record_and_press_a")}</p>
<div className="seg" data-text>
<button type="button" data-action="window.global-shortcut" data-value="record" disabled={!now || busy} onClick={() => { setRecording(true); input.current?.focus(); }}>{t("settings.record_shortcut")}</button>
<button type="submit" data-action="window.global-shortcut" data-value="apply" disabled={!now || busy}>{t("settings.apply_shortcut")}</button>
<button type="button" data-action="window.global-shortcut" data-value="disable" disabled={!now || busy || (!now.binding && !draft)} onClick={() => void apply("")}>{t("settings.clear_shortcut")}</button>
<div className="srow">
<div className="srow-meta">
<span className="srow-name">{t("settings.shortcut_binding")}</span>
{now && <span className="srow-desc" role="status">{t(STATUS[now.status], { value: acceleratorLabel(now.binding) })}</span>}
</div>
<div className="srow-ctl">
<input ref={input} className="keybind" data-recording={recording ? "" : undefined} data-action-change="window.global-shortcut" data-action-keydown="window.global-shortcut" data-value="binding" aria-label={t("settings.shortcut_binding")} maxLength={128} placeholder={recording ? t("settings.press_a_modifier_and_key") : acceleratorLabel("CommandOrControl+Shift+Space")} value={draft} readOnly={recording} onChange={event => 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} />
</div>
</div>
<div className="srow keyacts">
<div className="srow-meta">
{draft && <kbd className="keycap">{acceleratorLabel(draft)}</kbd>}
<span className="srow-desc">{t("settings.click_record_and_press_a")}</span>
</div>
<div className="srow-ctl">
<button type="button" className="spill" data-action="window.global-shortcut" data-value="record" disabled={!now || busy} onClick={() => { setRecording(true); input.current?.focus(); }}>{t("settings.record_shortcut")}</button>
<button type="submit" className="spill" data-action="window.global-shortcut" data-value="apply" disabled={!now || busy}>{t("settings.apply_shortcut")}</button>
<button type="button" className="spill" data-action="window.global-shortcut" data-value="disable" disabled={!now || busy || (!now.binding && !draft)} onClick={() => void apply("")}>{t("settings.clear_shortcut")}</button>
</div>
</div>
</form>
{now && <p className="note" role="status">{t(STATUS[now.status], { value: acceleratorLabel(now.binding) })}</p>}
{now?.error && <p className="note" role="alert">{t(ERRORS[now.error])}</p>}
{failed && <p className="note" role="alert">{t("settings.could_not_read_or_change")}</p>}
</Group>
@@ -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(<GlobalShortcutSection />);
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(<GlobalShortcutSection />);
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(<GlobalShortcutSection />);
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"));
});