🐛 Fix custom shortcut with shift (#11669)

This commit is contained in:
Eva Marco 2026-09-21 12:36:10 +02:00 committed by GitHub
parent 52825f528c
commit 9fa0d9c996
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 206 additions and 48 deletions

View File

@ -1,3 +1,10 @@
Read and follow the instructions in `AGENTS.md`.
Treat `AGENTS.md` as the canonical project instruction file.
## Comments and docstrings
Keep comments and docstrings brief. They must always describe what the code
currently does (its behavior, invariants, non-obvious rationale) — never
what changed, when it changed, or history like "originally reported" /
"before this existed" / "used to be X".

View File

@ -45,51 +45,76 @@
"Tab" "tab"
" " "space"})
;; Mousetrap (the runtime shortcut matcher, vendored at
;; frontend/packages/mousetrap) resolves a keydown positionally - via keyCode
;; - ONLY for this punctuation set (its _KEYCODE_MAP); it falls back to
;; `event.key.toLowerCase()` (glyph-based) for letters and digits, which
;; aren't in that map. Recording punctuation from `code` therefore matches
;; what Mousetrap triggers regardless of layout (e.g. the key next to Right
;; Shift on a US layout is always "Period", whatever glyph it types on a
;; given ISO layout). Letters/digits must stay glyph-based to match
;; Mousetrap's own resolution for them.
(def ^:private code-name-map
{"Minus" "-"
"Equal" "="
"BracketLeft" "["
"BracketRight" "]"
"Backslash" "\\"
"Semicolon" ";"
"Quote" "'"
"Backquote" "`"
"Comma" ","
"Period" "."
"Slash" "/"})
(defn- event->native
"Unwraps a goog.events.BrowserEvent to the native event, since the
wrapper copies `key` but not `code`."
[^js event]
(if (fn? (.-getBrowserEvent event))
(or (.getBrowserEvent event) event)
event))
(defn- normalize-key
[^js event]
(let [key (.-key event)]
(when (and key (not (contains? modifier-keys key)))
(or (get key-name-map key)
(get code-name-map (.-code (event->native event)))
(some-> key .toLowerCase)))))
(defn- event->modifier-parts
[^js event]
(cond-> []
(and (.-ctrlKey event)
(not (cf/check-platform? :macos)))
(conj "ctrl")
(and (.-metaKey event)
(cf/check-platform? :macos))
(conj "command")
(.-altKey event)
(conj "alt")
(.-shiftKey event)
(conj "shift")))
(defn- keyboard-event->mousetrap
[^js event]
(let [parts (cond-> []
(and (.-ctrlKey event)
(not (cf/check-platform? :macos)))
(conj "ctrl")
(and (.-metaKey event)
(cf/check-platform? :macos))
(conj "command")
(.-altKey event)
(conj "alt")
(.-shiftKey event)
(conj "shift"))
key (.-key event)
key (if (contains? modifier-keys key)
nil
(or (get key-name-map key)
(.toLowerCase key)))]
(let [parts (event->modifier-parts event)
key (normalize-key event)]
(when key
(str/join "+" (conj parts key)))))
(defn- keyboard-event->display-parts
[^js event]
(let [parts (cond-> []
(and (.-ctrlKey event)
(not (cf/check-platform? :macos)))
(conj "ctrl")
(and (.-metaKey event)
(cf/check-platform? :macos))
(conj "command")
(.-altKey event)
(conj "alt")
(.-shiftKey event)
(conj "shift"))
(let [parts (event->modifier-parts event)
key (.-key event)]
(if (contains? modifier-keys key)
{:modifiers parts :finalized? false}
{:modifiers parts
:final-key (or (get key-name-map key) (.toLowerCase key))
:final-key (normalize-key event)
:finalized? true})))
(defn translation-keyname
@ -122,9 +147,10 @@
(into {} (filter (fn [[k _]] (contains? known-keys k))) all-shortcuts)))
(defn- import-context-group
"Imports a single context group from the payload, disabling any default
shortcut whose command collides with a newly imported one, and any
previously-imported entry in the same batch with a duplicate command."
"Builds the custom-shortcuts map for one imported context group, disabling
any default shortcut (from `context-shortcuts`) whose command collides
with an imported one, and any earlier entry in the same batch with a
duplicate command."
[group context-shortcuts]
(reduce
(fn [acc [command recorded-command]]
@ -163,11 +189,10 @@
(map (fn [[k v]] [k (assoc v :translation (translation-keyname type k))]) item))
(defn shortcut->command-string
"Extract a lowercase searchable string from a shortcut entry's key combo(s).
Prefers `:show-command` (display override) over `:command` (Mousetrap format),
matching what the keycap UI renders. Joins vector commands (key sequences)
with a space so every token is searchable. Returns \"\" when there is no
command (e.g. a section/subsection node)."
"Returns a lowercase, searchable string for a shortcut's key combo(s).
Prefers `:show-command` over `:command`; joins a vector command (key
sequence) with spaces so each token is searchable; returns \"\" when
there is no command (e.g. a section/subsection node)."
[shortcut]
(let [cmd (or (:show-command shortcut) (:command shortcut))]
(-> (cond

View File

@ -1,5 +1,6 @@
(ns frontend-tests.ui.settings-shortcuts-test
(:require
[app.config :as cf]
[app.main.data.profile :as du]
[app.main.ui.settings.import-shortcuts-diff-modal :as diff-modal]
[app.main.ui.settings.restore-shortcuts-modal :as restore-modal]
@ -224,12 +225,10 @@
"Should return a default command for :next-frame in :viewer context")))
;; --- shortcut->command-string + command-based search --------------------
;; The search in both the settings shortcuts page and the workspace sidebar
;; matches shortcut entries by their translated name AND by their key-combo
;; string. `shortcut->command-string` (in `app.main.ui.shortcuts`) extracts the
;; searchable form from `:command`/`:show-command`; `matches-search` does the
;; case-insensitive substring match. These tests pin that contract so searching
;; e.g. "ctrl" surfaces every shortcut whose combo includes ctrl.
;; Shortcut search matches by translated name or key-combo string.
;; `shortcut->command-string` (in `app.main.ui.shortcuts`) extracts the
;; searchable combo from `:command`/`:show-command`; `matches-search` does
;; the case-insensitive substring match.
(t/deftest shortcut->command-string-extracts-string-command
(t/testing "a plain string command is returned lowercased"
@ -291,3 +290,130 @@
;; by command
(t/is (or (matches-search (:translation shortcut) "ctrl")
(matches-search (ui-shortcuts/shortcut->command-string shortcut) "ctrl"))))))
;; --- keyboard-event->mousetrap across keyboard layouts -------------------
;; `keyboard-event->mousetrap` (private, exercised via #') turns a recorded
;; keydown into a Mousetrap-style combo string, reading `event.code`
;; (layout-independent) rather than `event.key` (layout- and Shift-dependent)
;; so the recorded combo matches what Mousetrap resolves positionally. Real
;; keydown events are wrapped by goog.events in a goog.events.BrowserEvent,
;; which forwards `key` but not `code`; the fixtures below simulate that
;; wrapper via `getBrowserEvent`.
(defn- mock-wrapped-keydown
"Simulates a goog.events.BrowserEvent-wrapped keydown: `key` and modifiers
are forwarded directly, and `code` is only reachable via `getBrowserEvent`.
`key` is passed through as-is (not defaulted) so callers can simulate a
missing `event.key` with `:key nil` - substituting \"\" would defeat that,
since an empty string is truthy in ClojureScript."
[{:keys [key code shift ctrl alt meta]}]
#js {:key key
:shiftKey (boolean shift)
:ctrlKey (boolean ctrl)
:altKey (boolean alt)
:metaKey (boolean meta)
:getBrowserEvent (fn [] #js {:code code})})
(defn- mock-native-keydown
"Simulates a plain, unwrapped native KeyboardEvent (no getBrowserEvent),
exercising the recorder's defensive fallback to read `code` directly."
[{:keys [key code shift ctrl alt meta]}]
#js {:key key
:code code
:shiftKey (boolean shift)
:ctrlKey (boolean ctrl)
:altKey (boolean alt)
:metaKey (boolean meta)})
(t/deftest mousetrap-shift-period-on-us-layout
(t/testing "US layout: Shift + Period key types \">\""
(with-redefs [cf/check-platform? (constantly false)]
(t/is (= "shift+."
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key ">" :code "Period" :shift true})))))))
(t/deftest mousetrap-shift-period-on-spanish-iso-layout
(t/testing "Spanish (ISO) layout: Shift + Period key types \":\" instead of
\">\", but the physical key (code \"Period\") is the same as on
a US layout, so the recorded combo must match"
(with-redefs [cf/check-platform? (constantly false)]
(t/is (= "shift+."
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key ":" :code "Period" :shift true})))))))
(t/deftest mousetrap-shift-period-on-arbitrary-layout
(t/testing "an arbitrary layout where Shift + Period produces a character
unrelated to \".\" or \":\" still normalizes to \"shift+.\",
since matching is driven by the physical key, not the glyph"
(with-redefs [cf/check-platform? (constantly false)]
(t/is (= "shift+."
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key "?" :code "Period" :shift true})))))))
(t/deftest mousetrap-letter-key-on-azerty-layout
(t/testing "French AZERTY: the key printed \"A\" sits where \"Q\" is on a US
layout (code \"KeyQ\") and types \"a\". Mousetrap doesn't
resolve letters positionally (absent from its
_MAP/_KEYCODE_MAP), so recording must stay glyph-based
(\"a\", not \"q\") to match what that physical key triggers"
(with-redefs [cf/check-platform? (constantly false)]
(t/is (= "a"
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key "a" :code "KeyQ"})))))))
(t/deftest mousetrap-digit-key-on-azerty-layout
(t/testing "French AZERTY: the unshifted top-row key at the US \"1\"
position (code \"Digit1\") types \"&\". Digits, like letters,
are absent from Mousetrap's _MAP/_KEYCODE_MAP, so recording
must stay glyph-based (\"&\") rather than resolve positionally
to \"1\""
(with-redefs [cf/check-platform? (constantly false)]
(t/is (= "&"
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key "&" :code "Digit1"})))))))
(t/deftest mousetrap-reads-code-from-unwrapped-native-event
(t/testing "falls back to reading `code` straight off the event when it is
not a goog.events.BrowserEvent wrapper (no getBrowserEvent)"
(with-redefs [cf/check-platform? (constantly false)]
(t/is (= "shift+."
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-native-keydown {:key ":" :code "Period" :shift true})))))))
(t/deftest mousetrap-tolerates-getBrowserEvent-returning-nil
(t/testing "does not throw when getBrowserEvent() itself returns nil/undefined
(defensive: not reachable via goog.events' real dispatch today,
but normalize-key must not crash the recorder if it happens)"
(with-redefs [cf/check-platform? (constantly false)]
(let [event #js {:key ":" :shiftKey true :ctrlKey false :altKey false
:metaKey false :getBrowserEvent (fn [] nil)}]
(t/is (= "shift+:"
(#'ui-shortcuts/keyboard-event->mousetrap event)))))))
(t/deftest mousetrap-tolerates-missing-key
(t/testing "does not throw when `event.key` itself is nil/undefined"
(with-redefs [cf/check-platform? (constantly false)]
(t/is (nil? (#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key nil :code "Period"})))))))
(t/deftest mousetrap-ignores-bare-modifier-press
(t/testing "pressing only Shift (no other key yet) records nothing"
(with-redefs [cf/check-platform? (constantly false)]
(t/is (nil? (#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key "Shift" :shift true})))))))
(t/deftest mousetrap-falls-back-to-lowercased-key-when-unmapped
(t/testing "a key with no entry in the named-key or code maps (e.g. a
function key) falls back to the lowercased `key` value"
(with-redefs [cf/check-platform? (constantly false)]
(t/is (= "f13"
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key "F13" :code "F13"})))))))
(t/deftest mousetrap-full-modifier-combo-on-non-mac
(t/testing "ctrl+alt+shift all combine with the normalized key, in order"
(with-redefs [cf/check-platform? (constantly false)]
(t/is (= "ctrl+alt+shift+q"
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key "q" :code "KeyQ"
:ctrl true :alt true :shift true})))))))