blob: 1f9e08304eeb4bbca5334d9238f2258c8f4d76ab [file] [log] [blame]
Ben Rohlfs966586e2021-10-06 16:15:24 +02001/**
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 Poucet55cbccb2021-11-16 03:17:06 +010017import {Subscription} from 'rxjs';
Ben Rohlfs966586e2021-10-06 16:15:24 +020018import {
19 config,
20 Shortcut,
21 ShortcutHelpItem,
22 ShortcutSection,
23} from './shortcuts-config';
Ben Rohlfs42333cb2021-10-07 07:54:10 +020024import {disableShortcuts$} from '../user/user-model';
Ben Rohlfs410f5222021-10-19 23:44:02 +020025import {
26 ComboKey,
27 eventMatchesShortcut,
28 isElementTarget,
29 Key,
30 Modifier,
31 Binding,
Ben Rohlfs4c832c42021-10-25 14:36:27 +020032 shouldSuppress,
Ben Rohlfs410f5222021-10-19 23:44:02 +020033} from '../../utils/dom-util';
Ben Rohlfs42333cb2021-10-07 07:54:10 +020034import {ReportingService} from '../gr-reporting/gr-reporting';
Chris Poucet55cbccb2021-11-16 03:17:06 +010035import {Finalizable} from '../registry';
Ben Rohlfs966586e2021-10-06 16:15:24 +020036
Ben Rohlfs966586e2021-10-06 16:15:24 +020037export type SectionView = Array<{binding: string[][]; text: string}>;
38
Ben Rohlfs410f5222021-10-19 23:44:02 +020039export interface ShortcutListener {
40 shortcut: Shortcut;
41 listener: (e: KeyboardEvent) => void;
42}
43
44export function listen(
45 shortcut: Shortcut,
46 listener: (e: KeyboardEvent) => void
47): ShortcutListener {
48 return {shortcut, listener};
49}
50
Ben Rohlfs966586e2021-10-06 16:15:24 +020051/**
52 * The interface for listener for shortcut events.
53 */
Ben Rohlfs410f5222021-10-19 23:44:02 +020054export type ShortcutViewListener = (
Ben Rohlfs966586e2021-10-06 16:15:24 +020055 viewMap?: Map<ShortcutSection, SectionView>
56) => void;
57
Ben Rohlfsbe0c0ba2021-10-17 19:36:43 +020058function isComboKey(key: string): key is ComboKey {
59 return Object.values(ComboKey).includes(key as ComboKey);
60}
61
62export const COMBO_TIMEOUT_MS = 1000;
Ben Rohlfsf41b5972021-10-07 15:36:49 +020063
Ben Rohlfs966586e2021-10-06 16:15:24 +020064/**
65 * Shortcuts service, holds all hosts, bindings and listeners.
66 */
Chris Poucet55cbccb2021-11-16 03:17:06 +010067export class ShortcutsService implements Finalizable {
Ben Rohlfs42333cb2021-10-07 07:54:10 +020068 /**
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 Rohlfs410f5222021-10-19 23:44:02 +020073 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 Rohlfs966586e2021-10-06 16:15:24 +020080
Ben Rohlfs42333cb2021-10-07 07:54:10 +020081 /** Static map built in the constructor by iterating over the config. */
Ben Rohlfs410f5222021-10-19 23:44:02 +020082 private readonly bindings = new Map<Shortcut, Binding[]>();
Ben Rohlfs966586e2021-10-06 16:15:24 +020083
Ben Rohlfs410f5222021-10-19 23:44:02 +020084 private readonly listeners = new Set<ShortcutViewListener>();
Ben Rohlfsfea82402021-10-06 17:50:47 +020085
Ben Rohlfsf41b5972021-10-07 15:36:49 +020086 /**
Ben Rohlfsbe0c0ba2021-10-17 19:36:43 +020087 * Stores the timestamp of the last combo key being pressed.
Ben Rohlfsf41b5972021-10-07 15:36:49 +020088 * 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 Rohlfsbe0c0ba2021-10-17 19:36:43 +020092 private comboKeyLastPressed: {key?: ComboKey; timestampMs?: number} = {};
Ben Rohlfsf41b5972021-10-07 15:36:49 +020093
Ben Rohlfs42333cb2021-10-07 07:54:10 +020094 /** Keeps track of the corresponding user preference. */
95 private shortcutsDisabled = false;
96
Chris Poucet55cbccb2021-11-16 03:17:06 +010097 private readonly keydownListener: (e: KeyboardEvent) => void;
98
99 private readonly subscriptions: Subscription[] = [];
100
Ben Rohlfs42333cb2021-10-07 07:54:10 +0200101 constructor(readonly reporting?: ReportingService) {
Ben Rohlfsfea82402021-10-06 17:50:47 +0200102 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 Poucet55cbccb2021-11-16 03:17:06 +0100108 this.subscriptions.push(
109 disableShortcuts$.subscribe(x => (this.shortcutsDisabled = x))
110 );
111 this.keydownListener = (e: KeyboardEvent) => {
Ben Rohlfsbe0c0ba2021-10-17 19:36:43 +0200112 if (!isComboKey(e.key)) return;
Ben Rohlfsf41b5972021-10-07 15:36:49 +0200113 if (this.shouldSuppress(e)) return;
Ben Rohlfsbe0c0ba2021-10-17 19:36:43 +0200114 this.comboKeyLastPressed = {key: e.key, timestampMs: Date.now()};
Chris Poucet55cbccb2021-11-16 03:17:06 +0100115 };
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 Rohlfs966586e2021-10-06 16:15:24 +0200124 }
125
126 public _testOnly_isEmpty() {
Ben Rohlfs410f5222021-10-19 23:44:02 +0200127 return this.activeShortcuts.size === 0 && this.listeners.size === 0;
Ben Rohlfs966586e2021-10-06 16:15:24 +0200128 }
129
Ben Rohlfsbe0c0ba2021-10-17 19:36:43 +0200130 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 Rohlfs410f5222021-10-19 23:44:02 +0200145 /**
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 Rohlfs5f5cb5e2021-11-08 12:10:25 +0100156 if (e.repeat && !shortcut.allowRepeat) return;
Ben Rohlfs410f5222021-10-19 23:44:02 +0200157 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 Rohlfsbe0c0ba2021-10-17 19:36:43 +0200170 }
171
Ben Rohlfs410f5222021-10-19 23:44:02 +0200172 shouldSuppress(e: KeyboardEvent) {
Ben Rohlfs42333cb2021-10-07 07:54:10 +0200173 if (this.shortcutsDisabled) return true;
Ben Rohlfs4c832c42021-10-25 14:36:27 +0200174 if (shouldSuppress(e)) return true;
Ben Rohlfs42333cb2021-10-07 07:54:10 +0200175
Ben Rohlfs42333cb2021-10-07 07:54:10 +0200176 // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
Ben Rohlfsf41b5972021-10-07 15:36:49 +0200177 let key = `${e.key}:${e.type}`;
Ben Rohlfsbe0c0ba2021-10-17 19:36:43 +0200178 if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
179 if (this.isInSpecificComboKeyMode(ComboKey.V)) key = 'v+' + key;
Ben Rohlfs42333cb2021-10-07 07:54:10 +0200180 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 Rohlfsb83d0512021-10-06 16:45:08 +0200192 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 Rohlfs966586e2021-10-06 16:15:24 +0200198 getBindingsForShortcut(shortcut: Shortcut) {
199 return this.bindings.get(shortcut);
200 }
201
Ben Rohlfs410f5222021-10-19 23:44:02 +0200202 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 Rohlfs966586e2021-10-06 16:15:24 +0200217 }
218
Ben Rohlfs410f5222021-10-19 23:44:02 +0200219 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 Rohlfs966586e2021-10-06 16:15:24 +0200224 return true;
225 }
226
Ben Rohlfs410f5222021-10-19 23:44:02 +0200227 addListener(listener: ShortcutViewListener) {
Ben Rohlfs966586e2021-10-06 16:15:24 +0200228 this.listeners.add(listener);
229 listener(this.directoryView());
230 }
231
Ben Rohlfs410f5222021-10-19 23:44:02 +0200232 removeListener(listener: ShortcutViewListener) {
Ben Rohlfs966586e2021-10-06 16:15:24 +0200233 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 Rohlfs410f5222021-10-19 23:44:02 +0200247 .map(binding => describeBinding(binding).join('+'))
Ben Rohlfs966586e2021-10-06 16:15:24 +0200248 .join(',');
249 }
250
251 activeShortcutsBySection() {
Ben Rohlfs410f5222021-10-19 23:44:02 +0200252 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 Rohlfs966586e2021-10-06 16:15:24 +0200258
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 Rohlfs966586e2021-10-06 16:15:24 +0200269 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 Rohlfs410f5222021-10-19 23:44:02 +0200330 if (!bindings) return null;
Ben Rohlfs966586e2021-10-06 16:15:24 +0200331 return bindings
Ben Rohlfs410f5222021-10-19 23:44:02 +0200332 .filter(binding => !binding.docOnly)
333 .map(binding => describeBinding(binding));
Ben Rohlfs966586e2021-10-06 16:15:24 +0200334 }
335
Ben Rohlfs410f5222021-10-19 23:44:02 +0200336 notifyViewListeners() {
Ben Rohlfs966586e2021-10-06 16:15:24 +0200337 const view = this.directoryView();
338 this.listeners.forEach(listener => listener(view));
339 }
340}
Ben Rohlfs410f5222021-10-19 23:44:02 +0200341
342function 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
357export 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}