diff --git a/tabby-core/src/api/index.ts b/tabby-core/src/api/index.ts index fd797cde..4b66ee89 100644 --- a/tabby-core/src/api/index.ts +++ b/tabby-core/src/api/index.ts @@ -25,6 +25,7 @@ export { DockingService, Screen } from '../services/docking.service' export { Logger, ConsoleLogger, LogService } from '../services/log.service' export { HomeBaseService } from '../services/homeBase.service' export { HotkeysService } from '../services/hotkeys.service' +export { KeyEventData, KeySequenceItem } from '../services/hotkeys.util' export { NotificationsService } from '../services/notifications.service' export { ThemesService } from '../services/themes.service' export { ProfilesService } from '../services/profiles.service' diff --git a/tabby-core/src/services/hotkeys.service.ts b/tabby-core/src/services/hotkeys.service.ts index d93ffa09..c2735a42 100644 --- a/tabby-core/src/services/hotkeys.service.ts +++ b/tabby-core/src/services/hotkeys.service.ts @@ -1,7 +1,7 @@ import { Injectable, Inject, NgZone, EventEmitter } from '@angular/core' import { Observable, Subject } from 'rxjs' import { HotkeyDescription, HotkeyProvider } from '../api/hotkeyProvider' -import { stringifyKeySequence, EventData } from './hotkeys.util' +import { stringifyKeySequence, KeyEventData, KeySequenceItem } from './hotkeys.util' import { ConfigService } from './config.service' import { HostAppService, Platform } from '../api/hostApp' import { deprecate } from 'util' @@ -27,10 +27,25 @@ export class HotkeysService { */ get hotkey$ (): Observable { return this._hotkey } + /** + * Fired for once hotkey is released + */ + get hotkeyOff$ (): Observable { return this._hotkeyOff } + + /** + * Fired for each recognized hotkey + */ + get key$ (): Observable { return this._key } + private _hotkey = new Subject() - private currentKeystrokes: EventData[] = [] + private _hotkeyOff = new Subject() + private _key = new Subject() + private currentEvents: KeyEventData[] = [] private disabledLevel = 0 private hotkeyDescriptions: HotkeyDescription[] = [] + private pressedHotkey: string|null = null + private lastMatchedHotkeyStartTime = performance.now() + private lastMatchedHotkeyEndTime = performance.now() private constructor ( private zone: NgZone, @@ -39,12 +54,10 @@ export class HotkeysService { hostApp: HostAppService, ) { const events = ['keydown', 'keyup'] - events.forEach(event => { - document.addEventListener(event, (nativeEvent: KeyboardEvent) => { - if (document.querySelectorAll('input:focus').length === 0) { - this.pushKeystroke(event, nativeEvent) - this.processKeystrokes() - this.emitKeyEvent(nativeEvent) + events.forEach(eventType => { + document.addEventListener(eventType, (nativeEvent: KeyboardEvent) => { + if (eventType === 'keyup' || document.querySelectorAll('input:focus').length === 0) { + this.pushKeystroke(eventType, nativeEvent) if (hostApp.platform === Platform.Web) { nativeEvent.preventDefault() nativeEvent.stopPropagation() @@ -60,6 +73,8 @@ export class HotkeysService { // deprecated this.hotkey$.subscribe(h => this.matchedHotkey.emit(h)) this.matchedHotkey.subscribe = deprecate(s => this.hotkey$.subscribe(s), 'matchedHotkey is deprecated, use hotkey$') + + this.key$.subscribe(e => this.key.emit(e)) } /** @@ -70,7 +85,10 @@ export class HotkeysService { */ pushKeystroke (name: string, nativeEvent: KeyboardEvent): void { nativeEvent['event'] = name - this.currentKeystrokes.push({ + if (nativeEvent.timeStamp && this.currentEvents.find(x => x.time === nativeEvent.timeStamp)) { + return + } + this.currentEvents.push({ ctrlKey: nativeEvent.ctrlKey, metaKey: nativeEvent.metaKey, altKey: nativeEvent.altKey, @@ -78,8 +96,11 @@ export class HotkeysService { code: nativeEvent.code, key: nativeEvent.key, eventName: name, - time: performance.now(), + time: nativeEvent.timeStamp, + registrationTime: performance.now(), }) + this.processKeystrokes() + this.emitKeyEvent(nativeEvent) } /** @@ -88,53 +109,87 @@ export class HotkeysService { processKeystrokes (): void { if (this.isEnabled()) { this.zone.run(() => { - const matched = this.getCurrentFullyMatchedHotkey() + let fullMatches: { + id: string, + sequence: string[], + startTime: number, + endTime: number, + }[] = [] + + const currentSequence = this.getCurrentKeySequence() + const config = this.getHotkeysConfig() + for (const id in config) { + for (const sequence of config[id]) { + if (currentSequence.length < sequence.length) { + continue + } + if (sequence.every( + (x: string, index: number) => + x.toLowerCase() === + currentSequence[currentSequence.length - sequence.length + index].value.toLowerCase() + )) { + fullMatches.push({ + id: id, + sequence, + startTime: currentSequence[currentSequence.length - sequence.length].firstEvent.registrationTime, + endTime: currentSequence[currentSequence.length - 1].lastEvent.registrationTime, + }) + } + } + } + + fullMatches.sort((a, b) => b.startTime - a.startTime + (b.sequence.length - a.sequence.length)) + fullMatches = fullMatches.filter(x => x.startTime >= this.lastMatchedHotkeyStartTime) + fullMatches = fullMatches.filter(x => x.endTime > this.lastMatchedHotkeyEndTime) + + const matched = fullMatches[0]?.id if (matched) { - console.log('Matched hotkey', matched) - this._hotkey.next(matched) - this.clearCurrentKeystrokes() + this.emitHotkeyOn(matched) + this.lastMatchedHotkeyStartTime = fullMatches[0].startTime + this.lastMatchedHotkeyEndTime = fullMatches[0].endTime + } else if (this.pressedHotkey) { + this.emitHotkeyOff(this.pressedHotkey) } }) } } + private emitHotkeyOn (hotkey: string) { + if (this.pressedHotkey) { + this.emitHotkeyOff(this.pressedHotkey) + } + console.debug('Matched hotkey', hotkey) + this._hotkey.next(hotkey) + this.pressedHotkey = hotkey + } + + private emitHotkeyOff (hotkey: string) { + console.debug('Unmatched hotkey', hotkey) + this._hotkeyOff.next(hotkey) + this.pressedHotkey = null + } + emitKeyEvent (nativeEvent: KeyboardEvent): void { this.zone.run(() => { - this.key.emit(nativeEvent) + this._key.next(nativeEvent) }) } clearCurrentKeystrokes (): void { - this.currentKeystrokes = [] + this.currentEvents = [] } - getCurrentKeystrokes (): string[] { - this.currentKeystrokes = this.currentKeystrokes.filter(x => performance.now() - x.time < KEY_TIMEOUT) - return stringifyKeySequence(this.currentKeystrokes) + getCurrentKeySequence (): KeySequenceItem[] { + this.currentEvents = this.currentEvents.filter(x => performance.now() - x.time < KEY_TIMEOUT && x.registrationTime >= this.lastMatchedHotkeyStartTime) + return stringifyKeySequence(this.currentEvents) } getCurrentFullyMatchedHotkey (): string|null { - const currentStrokes = this.getCurrentKeystrokes() - const config = this.getHotkeysConfig() - for (const id in config) { - for (const sequence of config[id]) { - if (currentStrokes.length < sequence.length) { - continue - } - if (sequence.every( - (x: string, index: number) => - x.toLowerCase() === - currentStrokes[currentStrokes.length - sequence.length + index].toLowerCase() - )) { - return id - } - } - } - return null + return this.pressedHotkey } getCurrentPartiallyMatchedHotkeys (): PartialHotkeyMatch[] { - const currentStrokes = this.getCurrentKeystrokes() + const currentStrokes = this.getCurrentKeySequence().map(x => x.value) const config = this.getHotkeysConfig() const result: PartialHotkeyMatch[] = [] for (const id in config) { diff --git a/tabby-core/src/services/hotkeys.util.ts b/tabby-core/src/services/hotkeys.util.ts index 6e739172..05e0d773 100644 --- a/tabby-core/src/services/hotkeys.util.ts +++ b/tabby-core/src/services/hotkeys.util.ts @@ -10,46 +10,66 @@ export const altKeyName = { linux: 'Alt', }[process.platform] -export interface EventData { - ctrlKey: boolean - metaKey: boolean - altKey: boolean - shiftKey: boolean +export interface KeyEventData { + ctrlKey?: boolean + metaKey?: boolean + altKey?: boolean + shiftKey?: boolean key: string code: string eventName: string time: number + registrationTime: number } const REGEX_LATIN_KEYNAME = /^[A-Za-z]$/ -export function stringifyKeySequence (events: EventData[]): string[] { - const items: string[] = [] +export interface KeySequenceItem { + value: string + firstEvent: KeyEventData + lastEvent: KeyEventData +} + +export function stringifyKeySequence (events: KeyEventData[]): KeySequenceItem[] { + const items: KeySequenceItem[] = [] + let pressedKeys: KeySequenceItem[] = [] events = events.slice() + const strictOrdering = ['Ctrl', metaKeyName, altKeyName, 'Shift'] + + function flushPressedKeys () { + if (pressedKeys.length) { + const v = { + firstEvent: pressedKeys[0].firstEvent, + lastEvent: pressedKeys[pressedKeys.length - 1].lastEvent, + } + pressedKeys = [ + ...strictOrdering.map(x => pressedKeys.find(p => p.value === x)).filter(x => !!x) as KeySequenceItem[], + ...pressedKeys.filter(p => !strictOrdering.includes(p.value)), + ] + items.push({ + value: pressedKeys.map(x => x.value).join('-'), + ...v, + }) + pressedKeys = [] + } + } + while (events.length > 0) { const event = events.shift()! - if (event.eventName === 'keydown') { - const itemKeys: string[] = [] - if (event.ctrlKey) { - itemKeys.push('Ctrl') - } - if (event.metaKey) { - itemKeys.push(metaKeyName) - } - if (event.altKey) { - itemKeys.push(altKeyName) - } - if (event.shiftKey) { - itemKeys.push('Shift') - } - if (['Control', 'Shift', 'Alt', 'Meta'].includes(event.key)) { - // TODO make this optional? - continue - } - - let key = event.code + // eslint-disable-next-line @typescript-eslint/init-declarations + let key: string + if (event.key === 'Control') { + key = 'Ctrl' + } else if (event.key === 'Meta') { + key = metaKeyName + } else if (event.key === 'Alt') { + key = altKeyName + } else if (event.key === 'Shift') { + key = 'Shift' + } else { + key = event.code if (REGEX_LATIN_KEYNAME.test(event.key)) { // Handle Dvorak etc via the reported "character" instead of the scancode key = event.key.toUpperCase() @@ -72,10 +92,20 @@ export function stringifyKeySequence (events: EventData[]): string[] { BracketRight: ']', }[key] ?? key } + } - itemKeys.push(key) - items.push(itemKeys.join('-')) + if (event.eventName === 'keydown') { + pressedKeys.push({ + value: key, + firstEvent: event, + lastEvent: event, + }) + } + if (event.eventName === 'keyup') { + flushPressedKeys() } } + + flushPressedKeys() return items } diff --git a/tabby-settings/src/components/hotkeyInputModal.component.ts b/tabby-settings/src/components/hotkeyInputModal.component.ts index ec815281..4736c8e8 100644 --- a/tabby-settings/src/components/hotkeyInputModal.component.ts +++ b/tabby-settings/src/components/hotkeyInputModal.component.ts @@ -50,7 +50,7 @@ export class HotkeyInputModalComponent extends BaseComponent { this.hotkeys.clearCurrentKeystrokes() this.subscribeUntilDestroyed(hotkeys.key, (event) => { this.lastKeyEvent = performance.now() - this.value = this.hotkeys.getCurrentKeystrokes() + this.value = this.hotkeys.getCurrentKeySequence().map(x => x.value) event.preventDefault() event.stopPropagation() }) diff --git a/tabby-terminal/src/index.ts b/tabby-terminal/src/index.ts index 8651bea0..15c35d52 100644 --- a/tabby-terminal/src/index.ts +++ b/tabby-terminal/src/index.ts @@ -97,7 +97,7 @@ export default class TerminalModule { // eslint-disable-line @typescript-eslint/ htermHandler: 'onKeyUp_', }, ] - events.forEach((event) => { + events.forEach(event => { const oldHandler = hterm.hterm.Keyboard.prototype[event.htermHandler] hterm.hterm.Keyboard.prototype[event.htermHandler] = function (nativeEvent) { hotkeys.pushKeystroke(event.name, nativeEvent)