Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 1 | /** |
| 2 | * @license |
| 3 | * Copyright (C) 2021 The Android Open Source Project |
| 4 | * |
| 5 | * Licensed under the Apache License, Version 2.0 (the "License"); |
| 6 | * you may not use this file except in compliance with the License. |
| 7 | * You may obtain a copy of the License at |
| 8 | * |
| 9 | * http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | * |
| 11 | * Unless required by applicable law or agreed to in writing, software |
| 12 | * distributed under the License is distributed on an "AS IS" BASIS, |
| 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 14 | * See the License for the specific language governing permissions and |
| 15 | * limitations under the License. |
| 16 | */ |
Chris Poucet | 55cbccb | 2021-11-16 03:17:06 +0100 | [diff] [blame] | 17 | import {Subscription} from 'rxjs'; |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 18 | import { |
| 19 | config, |
| 20 | Shortcut, |
| 21 | ShortcutHelpItem, |
| 22 | ShortcutSection, |
| 23 | } from './shortcuts-config'; |
Ben Rohlfs | 42333cb | 2021-10-07 07:54:10 +0200 | [diff] [blame] | 24 | import {disableShortcuts$} from '../user/user-model'; |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 25 | import { |
| 26 | ComboKey, |
| 27 | eventMatchesShortcut, |
| 28 | isElementTarget, |
| 29 | Key, |
| 30 | Modifier, |
| 31 | Binding, |
Ben Rohlfs | 4c832c4 | 2021-10-25 14:36:27 +0200 | [diff] [blame] | 32 | shouldSuppress, |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 33 | } from '../../utils/dom-util'; |
Ben Rohlfs | 42333cb | 2021-10-07 07:54:10 +0200 | [diff] [blame] | 34 | import {ReportingService} from '../gr-reporting/gr-reporting'; |
Chris Poucet | 55cbccb | 2021-11-16 03:17:06 +0100 | [diff] [blame] | 35 | import {Finalizable} from '../registry'; |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 36 | |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 37 | export type SectionView = Array<{binding: string[][]; text: string}>; |
| 38 | |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 39 | export interface ShortcutListener { |
| 40 | shortcut: Shortcut; |
| 41 | listener: (e: KeyboardEvent) => void; |
| 42 | } |
| 43 | |
| 44 | export function listen( |
| 45 | shortcut: Shortcut, |
| 46 | listener: (e: KeyboardEvent) => void |
| 47 | ): ShortcutListener { |
| 48 | return {shortcut, listener}; |
| 49 | } |
| 50 | |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 51 | /** |
| 52 | * The interface for listener for shortcut events. |
| 53 | */ |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 54 | export type ShortcutViewListener = ( |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 55 | viewMap?: Map<ShortcutSection, SectionView> |
| 56 | ) => void; |
| 57 | |
Ben Rohlfs | be0c0ba | 2021-10-17 19:36:43 +0200 | [diff] [blame] | 58 | function isComboKey(key: string): key is ComboKey { |
| 59 | return Object.values(ComboKey).includes(key as ComboKey); |
| 60 | } |
| 61 | |
| 62 | export const COMBO_TIMEOUT_MS = 1000; |
Ben Rohlfs | f41b597 | 2021-10-07 15:36:49 +0200 | [diff] [blame] | 63 | |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 64 | /** |
| 65 | * Shortcuts service, holds all hosts, bindings and listeners. |
| 66 | */ |
Chris Poucet | 55cbccb | 2021-11-16 03:17:06 +0100 | [diff] [blame] | 67 | export class ShortcutsService implements Finalizable { |
Ben Rohlfs | 42333cb | 2021-10-07 07:54:10 +0200 | [diff] [blame] | 68 | /** |
| 69 | * Keeps track of the components that are currently active such that we can |
| 70 | * show a shortcut help dialog that only shows the shortcuts that are |
| 71 | * currently relevant. |
| 72 | */ |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 73 | private readonly activeShortcuts = new Map<HTMLElement, Shortcut[]>(); |
| 74 | |
| 75 | /** |
| 76 | * Keeps track of cleanup callbacks (which remove keyboard listeners) that |
| 77 | * have to be invoked when a component unregisters itself. |
| 78 | */ |
| 79 | private readonly cleanupsPerHost = new Map<HTMLElement, (() => void)[]>(); |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 80 | |
Ben Rohlfs | 42333cb | 2021-10-07 07:54:10 +0200 | [diff] [blame] | 81 | /** Static map built in the constructor by iterating over the config. */ |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 82 | private readonly bindings = new Map<Shortcut, Binding[]>(); |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 83 | |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 84 | private readonly listeners = new Set<ShortcutViewListener>(); |
Ben Rohlfs | fea8240 | 2021-10-06 17:50:47 +0200 | [diff] [blame] | 85 | |
Ben Rohlfs | f41b597 | 2021-10-07 15:36:49 +0200 | [diff] [blame] | 86 | /** |
Ben Rohlfs | be0c0ba | 2021-10-17 19:36:43 +0200 | [diff] [blame] | 87 | * Stores the timestamp of the last combo key being pressed. |
Ben Rohlfs | f41b597 | 2021-10-07 15:36:49 +0200 | [diff] [blame] | 88 | * This enabled key combinations like 'g+o' where we can check whether 'g' was |
| 89 | * pressed recently when 'o' is processed. Keys of this map must be items of |
| 90 | * COMBO_KEYS. Values are Date timestamps in milliseconds. |
| 91 | */ |
Ben Rohlfs | be0c0ba | 2021-10-17 19:36:43 +0200 | [diff] [blame] | 92 | private comboKeyLastPressed: {key?: ComboKey; timestampMs?: number} = {}; |
Ben Rohlfs | f41b597 | 2021-10-07 15:36:49 +0200 | [diff] [blame] | 93 | |
Ben Rohlfs | 42333cb | 2021-10-07 07:54:10 +0200 | [diff] [blame] | 94 | /** Keeps track of the corresponding user preference. */ |
| 95 | private shortcutsDisabled = false; |
| 96 | |
Chris Poucet | 55cbccb | 2021-11-16 03:17:06 +0100 | [diff] [blame] | 97 | private readonly keydownListener: (e: KeyboardEvent) => void; |
| 98 | |
| 99 | private readonly subscriptions: Subscription[] = []; |
| 100 | |
Ben Rohlfs | 42333cb | 2021-10-07 07:54:10 +0200 | [diff] [blame] | 101 | constructor(readonly reporting?: ReportingService) { |
Ben Rohlfs | fea8240 | 2021-10-06 17:50:47 +0200 | [diff] [blame] | 102 | for (const section of config.keys()) { |
| 103 | const items = config.get(section) ?? []; |
| 104 | for (const item of items) { |
| 105 | this.bindings.set(item.shortcut, item.bindings); |
| 106 | } |
| 107 | } |
Chris Poucet | 55cbccb | 2021-11-16 03:17:06 +0100 | [diff] [blame] | 108 | this.subscriptions.push( |
| 109 | disableShortcuts$.subscribe(x => (this.shortcutsDisabled = x)) |
| 110 | ); |
| 111 | this.keydownListener = (e: KeyboardEvent) => { |
Ben Rohlfs | be0c0ba | 2021-10-17 19:36:43 +0200 | [diff] [blame] | 112 | if (!isComboKey(e.key)) return; |
Ben Rohlfs | f41b597 | 2021-10-07 15:36:49 +0200 | [diff] [blame] | 113 | if (this.shouldSuppress(e)) return; |
Ben Rohlfs | be0c0ba | 2021-10-17 19:36:43 +0200 | [diff] [blame] | 114 | this.comboKeyLastPressed = {key: e.key, timestampMs: Date.now()}; |
Chris Poucet | 55cbccb | 2021-11-16 03:17:06 +0100 | [diff] [blame] | 115 | }; |
| 116 | document.addEventListener('keydown', this.keydownListener); |
| 117 | } |
| 118 | |
| 119 | finalize() { |
| 120 | document.removeEventListener('keydown', this.keydownListener); |
| 121 | for (const s of this.subscriptions) { |
| 122 | s.unsubscribe(); |
| 123 | } |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 124 | } |
| 125 | |
| 126 | public _testOnly_isEmpty() { |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 127 | return this.activeShortcuts.size === 0 && this.listeners.size === 0; |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 128 | } |
| 129 | |
Ben Rohlfs | be0c0ba | 2021-10-17 19:36:43 +0200 | [diff] [blame] | 130 | isInComboKeyMode() { |
| 131 | return Object.values(ComboKey).some(key => |
| 132 | this.isInSpecificComboKeyMode(key) |
| 133 | ); |
| 134 | } |
| 135 | |
| 136 | isInSpecificComboKeyMode(comboKey: ComboKey) { |
| 137 | const {key, timestampMs} = this.comboKeyLastPressed; |
| 138 | return ( |
| 139 | key === comboKey && |
| 140 | timestampMs && |
| 141 | Date.now() - timestampMs < COMBO_TIMEOUT_MS |
| 142 | ); |
| 143 | } |
| 144 | |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 145 | /** |
| 146 | * TODO(brohlfs): Reconcile with the addShortcut() function in dom-util. |
| 147 | * Most likely we will just keep this one here, but that is something for a |
| 148 | * follow-up change. |
| 149 | */ |
| 150 | addShortcut( |
| 151 | element: HTMLElement, |
| 152 | shortcut: Binding, |
| 153 | listener: (e: KeyboardEvent) => void |
| 154 | ) { |
| 155 | const wrappedListener = (e: KeyboardEvent) => { |
Ben Rohlfs | 5f5cb5e | 2021-11-08 12:10:25 +0100 | [diff] [blame] | 156 | if (e.repeat && !shortcut.allowRepeat) return; |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 157 | if (!eventMatchesShortcut(e, shortcut)) return; |
| 158 | if (shortcut.combo) { |
| 159 | if (!this.isInSpecificComboKeyMode(shortcut.combo)) return; |
| 160 | } else { |
| 161 | if (this.isInComboKeyMode()) return; |
| 162 | } |
| 163 | if (this.shouldSuppress(e)) return; |
| 164 | e.preventDefault(); |
| 165 | e.stopPropagation(); |
| 166 | listener(e); |
| 167 | }; |
| 168 | element.addEventListener('keydown', wrappedListener); |
| 169 | return () => element.removeEventListener('keydown', wrappedListener); |
Ben Rohlfs | be0c0ba | 2021-10-17 19:36:43 +0200 | [diff] [blame] | 170 | } |
| 171 | |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 172 | shouldSuppress(e: KeyboardEvent) { |
Ben Rohlfs | 42333cb | 2021-10-07 07:54:10 +0200 | [diff] [blame] | 173 | if (this.shortcutsDisabled) return true; |
Ben Rohlfs | 4c832c4 | 2021-10-25 14:36:27 +0200 | [diff] [blame] | 174 | if (shouldSuppress(e)) return true; |
Ben Rohlfs | 42333cb | 2021-10-07 07:54:10 +0200 | [diff] [blame] | 175 | |
Ben Rohlfs | 42333cb | 2021-10-07 07:54:10 +0200 | [diff] [blame] | 176 | // eg: {key: "k:keydown", ..., from: "gr-diff-view"} |
Ben Rohlfs | f41b597 | 2021-10-07 15:36:49 +0200 | [diff] [blame] | 177 | let key = `${e.key}:${e.type}`; |
Ben Rohlfs | be0c0ba | 2021-10-17 19:36:43 +0200 | [diff] [blame] | 178 | if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key; |
| 179 | if (this.isInSpecificComboKeyMode(ComboKey.V)) key = 'v+' + key; |
Ben Rohlfs | 42333cb | 2021-10-07 07:54:10 +0200 | [diff] [blame] | 180 | if (e.shiftKey) key = 'shift+' + key; |
| 181 | if (e.ctrlKey) key = 'ctrl+' + key; |
| 182 | if (e.metaKey) key = 'meta+' + key; |
| 183 | if (e.altKey) key = 'alt+' + key; |
| 184 | let from = 'unknown'; |
| 185 | if (isElementTarget(e.currentTarget)) { |
| 186 | from = e.currentTarget.tagName; |
| 187 | } |
| 188 | this.reporting?.reportInteraction('shortcut-triggered', {key, from}); |
| 189 | return false; |
| 190 | } |
| 191 | |
Ben Rohlfs | b83d051 | 2021-10-06 16:45:08 +0200 | [diff] [blame] | 192 | createTitle(shortcutName: Shortcut, section: ShortcutSection) { |
| 193 | const desc = this.getDescription(section, shortcutName); |
| 194 | const shortcut = this.getShortcut(shortcutName); |
| 195 | return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : ''; |
| 196 | } |
| 197 | |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 198 | getBindingsForShortcut(shortcut: Shortcut) { |
| 199 | return this.bindings.get(shortcut); |
| 200 | } |
| 201 | |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 202 | attachHost(host: HTMLElement, shortcuts: ShortcutListener[]) { |
| 203 | this.activeShortcuts.set( |
| 204 | host, |
| 205 | shortcuts.map(s => s.shortcut) |
| 206 | ); |
| 207 | const cleanups: (() => void)[] = []; |
| 208 | for (const s of shortcuts) { |
| 209 | const bindings = this.getBindingsForShortcut(s.shortcut); |
| 210 | for (const binding of bindings ?? []) { |
| 211 | if (binding.docOnly) continue; |
| 212 | cleanups.push(this.addShortcut(document.body, binding, s.listener)); |
| 213 | } |
| 214 | } |
| 215 | this.cleanupsPerHost.set(host, cleanups); |
| 216 | this.notifyViewListeners(); |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 217 | } |
| 218 | |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 219 | detachHost(host: HTMLElement) { |
| 220 | this.activeShortcuts.delete(host); |
| 221 | const cleanups = this.cleanupsPerHost.get(host); |
| 222 | for (const cleanup of cleanups ?? []) cleanup(); |
| 223 | this.notifyViewListeners(); |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 224 | return true; |
| 225 | } |
| 226 | |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 227 | addListener(listener: ShortcutViewListener) { |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 228 | this.listeners.add(listener); |
| 229 | listener(this.directoryView()); |
| 230 | } |
| 231 | |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 232 | removeListener(listener: ShortcutViewListener) { |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 233 | return this.listeners.delete(listener); |
| 234 | } |
| 235 | |
| 236 | getDescription(section: ShortcutSection, shortcutName: Shortcut) { |
| 237 | const bindings = config.get(section); |
| 238 | if (!bindings) return ''; |
| 239 | const binding = bindings.find(binding => binding.shortcut === shortcutName); |
| 240 | return binding?.text ?? ''; |
| 241 | } |
| 242 | |
| 243 | getShortcut(shortcutName: Shortcut) { |
| 244 | const bindings = this.bindings.get(shortcutName); |
| 245 | if (!bindings) return ''; |
| 246 | return bindings |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 247 | .map(binding => describeBinding(binding).join('+')) |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 248 | .join(','); |
| 249 | } |
| 250 | |
| 251 | activeShortcutsBySection() { |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 252 | const activeShortcuts = new Set<Shortcut>(); |
| 253 | for (const shortcuts of this.activeShortcuts.values()) { |
| 254 | for (const shortcut of shortcuts) { |
| 255 | activeShortcuts.add(shortcut); |
| 256 | } |
| 257 | } |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 258 | |
| 259 | const activeShortcutsBySection = new Map< |
| 260 | ShortcutSection, |
| 261 | ShortcutHelpItem[] |
| 262 | >(); |
| 263 | config.forEach((shortcutList, section) => { |
| 264 | shortcutList.forEach(shortcutHelp => { |
| 265 | if (activeShortcuts.has(shortcutHelp.shortcut)) { |
| 266 | if (!activeShortcutsBySection.has(section)) { |
| 267 | activeShortcutsBySection.set(section, []); |
| 268 | } |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 269 | activeShortcutsBySection.get(section)!.push(shortcutHelp); |
| 270 | } |
| 271 | }); |
| 272 | }); |
| 273 | return activeShortcutsBySection; |
| 274 | } |
| 275 | |
| 276 | directoryView() { |
| 277 | const view = new Map<ShortcutSection, SectionView>(); |
| 278 | this.activeShortcutsBySection().forEach((shortcutHelps, section) => { |
| 279 | const sectionView: SectionView = []; |
| 280 | shortcutHelps.forEach(shortcutHelp => { |
| 281 | const bindingDesc = this.describeBindings(shortcutHelp.shortcut); |
| 282 | if (!bindingDesc) { |
| 283 | return; |
| 284 | } |
| 285 | this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => { |
| 286 | sectionView.push({ |
| 287 | binding: bindingDesc, |
| 288 | text: shortcutHelp.text, |
| 289 | }); |
| 290 | }); |
| 291 | }); |
| 292 | view.set(section, sectionView); |
| 293 | }); |
| 294 | return view; |
| 295 | } |
| 296 | |
| 297 | distributeBindingDesc(bindingDesc: string[][]): string[][][] { |
| 298 | if ( |
| 299 | bindingDesc.length === 1 || |
| 300 | this.comboSetDisplayWidth(bindingDesc) < 21 |
| 301 | ) { |
| 302 | return [bindingDesc]; |
| 303 | } |
| 304 | // Find the largest prefix of bindings that is under the |
| 305 | // size threshold. |
| 306 | const head = [bindingDesc[0]]; |
| 307 | for (let i = 1; i < bindingDesc.length; i++) { |
| 308 | head.push(bindingDesc[i]); |
| 309 | if (this.comboSetDisplayWidth(head) >= 21) { |
| 310 | head.pop(); |
| 311 | return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i))); |
| 312 | } |
| 313 | } |
| 314 | return []; |
| 315 | } |
| 316 | |
| 317 | comboSetDisplayWidth(bindingDesc: string[][]) { |
| 318 | const bindingSizer = (binding: string[]) => |
| 319 | binding.reduce((acc, key) => acc + key.length, 0); |
| 320 | // Width is the sum of strings + (n-1) * 2 to account for the word |
| 321 | // "or" joining them. |
| 322 | return ( |
| 323 | bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) + |
| 324 | 2 * (bindingDesc.length - 1) |
| 325 | ); |
| 326 | } |
| 327 | |
| 328 | describeBindings(shortcut: Shortcut): string[][] | null { |
| 329 | const bindings = this.bindings.get(shortcut); |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 330 | if (!bindings) return null; |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 331 | return bindings |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 332 | .filter(binding => !binding.docOnly) |
| 333 | .map(binding => describeBinding(binding)); |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 334 | } |
| 335 | |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 336 | notifyViewListeners() { |
Ben Rohlfs | 966586e | 2021-10-06 16:15:24 +0200 | [diff] [blame] | 337 | const view = this.directoryView(); |
| 338 | this.listeners.forEach(listener => listener(view)); |
| 339 | } |
| 340 | } |
Ben Rohlfs | 410f522 | 2021-10-19 23:44:02 +0200 | [diff] [blame] | 341 | |
| 342 | function describeKey(key: string | Key) { |
| 343 | switch (key) { |
| 344 | case Key.UP: |
| 345 | return '\u2191'; // ↑ |
| 346 | case Key.DOWN: |
| 347 | return '\u2193'; // ↓ |
| 348 | case Key.LEFT: |
| 349 | return '\u2190'; // ← |
| 350 | case Key.RIGHT: |
| 351 | return '\u2192'; // → |
| 352 | default: |
| 353 | return key; |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | export function describeBinding(binding: Binding): string[] { |
| 358 | const description: string[] = []; |
| 359 | if (binding.combo === ComboKey.G) { |
| 360 | description.push('g'); |
| 361 | } |
| 362 | if (binding.combo === ComboKey.V) { |
| 363 | description.push('v'); |
| 364 | } |
| 365 | if (binding.modifiers?.includes(Modifier.SHIFT_KEY)) { |
| 366 | description.push('Shift'); |
| 367 | } |
| 368 | if (binding.modifiers?.includes(Modifier.ALT_KEY)) { |
| 369 | description.push('Alt'); |
| 370 | } |
| 371 | if (binding.modifiers?.includes(Modifier.CTRL_KEY)) { |
| 372 | description.push('Ctrl'); |
| 373 | } |
| 374 | if (binding.modifiers?.includes(Modifier.META_KEY)) { |
| 375 | description.push('Meta/Cmd'); |
| 376 | } |
| 377 | description.push(describeKey(binding.key)); |
| 378 | return description; |
| 379 | } |