reworked hotkey handling - fixes #4355, fixes #4326, fixes #4340

This commit is contained in:
Eugene Pankov
2021-08-06 09:03:55 +02:00
parent 533837f5b7
commit 7b59ba4b73
6 changed files with 221 additions and 207 deletions

View File

@@ -25,7 +25,7 @@ export { DockingService, Screen } from '../services/docking.service'
export { Logger, ConsoleLogger, LogService } from '../services/log.service' export { Logger, ConsoleLogger, LogService } from '../services/log.service'
export { HomeBaseService } from '../services/homeBase.service' export { HomeBaseService } from '../services/homeBase.service'
export { HotkeysService } from '../services/hotkeys.service' export { HotkeysService } from '../services/hotkeys.service'
export { KeyEventData, KeySequenceItem } from '../services/hotkeys.util' export { KeyEventData, KeyName, Keystroke } from '../services/hotkeys.util'
export { NotificationsService } from '../services/notifications.service' export { NotificationsService } from '../services/notifications.service'
export { ThemesService } from '../services/themes.service' export { ThemesService } from '../services/themes.service'
export { ProfilesService } from '../services/profiles.service' export { ProfilesService } from '../services/profiles.service'

View File

@@ -1,7 +1,7 @@
import { Injectable, Inject, NgZone, EventEmitter } from '@angular/core' import { Injectable, Inject, NgZone, EventEmitter } from '@angular/core'
import { Observable, Subject } from 'rxjs' import { Observable, Subject } from 'rxjs'
import { HotkeyDescription, HotkeyProvider } from '../api/hotkeyProvider' import { HotkeyDescription, HotkeyProvider } from '../api/hotkeyProvider'
import { stringifyKeySequence, KeyEventData, KeySequenceItem } from './hotkeys.util' import { KeyEventData, getKeyName, Keystroke, KeyName, getKeystrokeName, metaKeyName, altKeyName } from './hotkeys.util'
import { ConfigService } from './config.service' import { ConfigService } from './config.service'
import { HostAppService, Platform } from '../api/hostApp' import { HostAppService, Platform } from '../api/hostApp'
import { deprecate } from 'util' import { deprecate } from 'util'
@@ -12,14 +12,17 @@ export interface PartialHotkeyMatch {
matchedLength: number matchedLength: number
} }
const KEY_TIMEOUT = 2000 interface PastKeystroke {
keystroke: Keystroke
time: number
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class HotkeysService { export class HotkeysService {
/** @hidden @deprecated */
key = new EventEmitter<KeyboardEvent>() key = new EventEmitter<KeyboardEvent>()
/** @hidden */ /** @hidden @deprecated */
matchedHotkey = new EventEmitter<string>() matchedHotkey = new EventEmitter<string>()
/** /**
@@ -33,19 +36,34 @@ export class HotkeysService {
get hotkeyOff$ (): Observable<string> { return this._hotkeyOff } get hotkeyOff$ (): Observable<string> { return this._hotkeyOff }
/** /**
* Fired for each recognized hotkey * Fired for each singular key
*/ */
get key$ (): Observable<KeyboardEvent> { return this._key } get key$ (): Observable<KeyName> { return this._key }
/**
* Fired for each key event
*/
get keyEvent$ (): Observable<KeyboardEvent> { return this._keyEvent }
/**
* Fired for each singular key combination
*/
get keystroke$ (): Observable<Keystroke> { return this._keystroke }
private _hotkey = new Subject<string>() private _hotkey = new Subject<string>()
private _hotkeyOff = new Subject<string>() private _hotkeyOff = new Subject<string>()
private _key = new Subject<KeyboardEvent>() private _keyEvent = new Subject<KeyboardEvent>()
private currentEvents: KeyEventData[] = [] private _key = new Subject<KeyName>()
private _keystroke = new Subject<Keystroke>()
private disabledLevel = 0 private disabledLevel = 0
private hotkeyDescriptions: HotkeyDescription[] = [] private hotkeyDescriptions: HotkeyDescription[] = []
private pressedKeys = new Set<KeyName>()
private pressedKeyTimestamps = new Map<KeyName, number>()
private pressedHotkey: string|null = null private pressedHotkey: string|null = null
private lastMatchedHotkeyStartTime = performance.now() private pressedKeystroke: Keystroke|null = null
private lastMatchedHotkeyEndTime = performance.now() private lastKeystrokes: PastKeystroke[] = []
private shouldSaveNextKeystroke = true
private constructor ( private constructor (
private zone: NgZone, private zone: NgZone,
@@ -56,9 +74,10 @@ export class HotkeysService {
const events = ['keydown', 'keyup'] const events = ['keydown', 'keyup']
events.forEach(eventType => { events.forEach(eventType => {
document.addEventListener(eventType, (nativeEvent: KeyboardEvent) => { document.addEventListener(eventType, (nativeEvent: KeyboardEvent) => {
this._keyEvent.next(nativeEvent)
if (eventType === 'keyup' || document.querySelectorAll('input:focus').length === 0) { if (eventType === 'keyup' || document.querySelectorAll('input:focus').length === 0) {
this.pushKeystroke(eventType, nativeEvent) this.pushKeyEvent(eventType, nativeEvent)
if (hostApp.platform === Platform.Web) { if (hostApp.platform === Platform.Web && this.matchActiveHotkey(true) !== null) {
nativeEvent.preventDefault() nativeEvent.preventDefault()
nativeEvent.stopPropagation() nativeEvent.stopPropagation()
} }
@@ -73,144 +92,149 @@ export class HotkeysService {
// deprecated // deprecated
this.hotkey$.subscribe(h => this.matchedHotkey.emit(h)) this.hotkey$.subscribe(h => this.matchedHotkey.emit(h))
this.matchedHotkey.subscribe = deprecate(s => this.hotkey$.subscribe(s), 'matchedHotkey is deprecated, use hotkey$') this.matchedHotkey.subscribe = deprecate(s => this.hotkey$.subscribe(s), 'matchedHotkey is deprecated, use hotkey$')
this.keyEvent$.subscribe(h => this.key.next(h))
this.key$.subscribe(e => this.key.emit(e)) this.key.subscribe = deprecate(s => this.keyEvent$.subscribe(s), 'key is deprecated, use keyEvent$')
} }
/** /**
* Adds a new key event to the buffer * Adds a new key event to the buffer
* *
* @param name DOM event name * @param eventName DOM event name
* @param nativeEvent event object * @param nativeEvent event object
*/ */
pushKeystroke (name: string, nativeEvent: KeyboardEvent): void { pushKeyEvent (eventName: string, nativeEvent: KeyboardEvent): void {
nativeEvent['event'] = name nativeEvent['event'] = eventName
if (nativeEvent.timeStamp && this.currentEvents.find(x => x.time === nativeEvent.timeStamp)) {
return const eventData = {
}
this.currentEvents.push({
ctrlKey: nativeEvent.ctrlKey, ctrlKey: nativeEvent.ctrlKey,
metaKey: nativeEvent.metaKey, metaKey: nativeEvent.metaKey,
altKey: nativeEvent.altKey, altKey: nativeEvent.altKey,
shiftKey: nativeEvent.shiftKey, shiftKey: nativeEvent.shiftKey,
code: nativeEvent.code, code: nativeEvent.code,
key: nativeEvent.key, key: nativeEvent.key,
eventName: name, eventName,
time: nativeEvent.timeStamp, time: nativeEvent.timeStamp,
registrationTime: performance.now(), registrationTime: performance.now(),
})
this.processKeystrokes()
this.emitKeyEvent(nativeEvent)
} }
/** for (const [key, time] of this.pressedKeyTimestamps.entries()) {
* Check the buffer for new complete keystrokes if (time < performance.now() - 5000) {
*/ this.pressedKeys.delete(key)
processKeystrokes (): void { this.pressedKeyTimestamps.delete(key)
if (this.isEnabled()) { }
}
const keyName = getKeyName(eventData)
if (eventName === 'keydown') {
this.pressedKeys.add(keyName)
this.pressedKeyTimestamps.set(keyName, eventData.registrationTime)
this.shouldSaveNextKeystroke = true
this.updateModifiers(eventData)
}
if (eventName === 'keyup') {
const keystroke = getKeystrokeName([...this.pressedKeys])
if (this.shouldSaveNextKeystroke) {
this._keystroke.next(keystroke)
this.lastKeystrokes.push({
keystroke,
time: performance.now(),
})
this.shouldSaveNextKeystroke = false
}
this.pressedKeys.delete(keyName)
this.pressedKeyTimestamps.delete(keyName)
}
if (this.pressedKeys.size) {
this.pressedKeystroke = getKeystrokeName([...this.pressedKeys])
} else {
this.pressedKeystroke = null
}
this.processKeystrokes()
this.zone.run(() => { this.zone.run(() => {
let fullMatches: { this._key.next(getKeyName(eventData))
})
}
private updateModifiers (event: KeyEventData) {
for (const [prop, key] of Object.entries({
ctrlKey: 'Ctrl',
metaKey: metaKeyName,
altKey: altKeyName,
shiftKey: 'Shift',
})) {
if (!event[prop] && this.pressedKeys.has(key)) {
this.pressedKeys.delete(key)
this.pressedKeyTimestamps.delete(key)
}
}
}
getCurrentKeystrokes (): Keystroke[] {
if (!this.pressedKeystroke) {
return []
}
return [...this.lastKeystrokes.map(x => x.keystroke), this.pressedKeystroke]
}
matchActiveHotkey (partial = false): string|null {
if (!this.isEnabled() || !this.pressedKeystroke) {
return null
}
const matches: {
id: string, id: string,
sequence: string[], sequence: string[],
startTime: number,
endTime: number,
}[] = [] }[] = []
const currentSequence = this.getCurrentKeySequence() const currentSequence = this.getCurrentKeystrokes()
const config = this.getHotkeysConfig() const config = this.getHotkeysConfig()
for (const id in config) { for (const id in config) {
for (const sequence of config[id]) { for (const sequence of config[id]) {
if (currentSequence.length < sequence.length) { if (currentSequence.length < sequence.length) {
continue continue
} }
if (sequence.every( if (sequence[sequence.length - 1] !== this.pressedKeystroke) {
(x: string, index: number) => continue
x.toLowerCase() === }
currentSequence[currentSequence.length - sequence.length + index].value.toLowerCase()
)) { let lastIndex = 0
fullMatches.push({ let matched = true
id: id, for (const item of sequence) {
const nextOffset = currentSequence.slice(lastIndex).findIndex(
x => x.toLowerCase() === item.toLowerCase()
)
if (nextOffset === -1) {
matched = false
break
}
lastIndex += nextOffset
}
if (partial ? lastIndex > 0 : matched) {
matches.push({
id,
sequence, 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)) matches.sort((a, b) => b.sequence.length - a.sequence.length)
fullMatches = fullMatches.filter(x => x.startTime >= this.lastMatchedHotkeyStartTime) if (!matches.length) {
fullMatches = fullMatches.filter(x => x.endTime > this.lastMatchedHotkeyEndTime) return null
const matched = fullMatches[0]?.id
if (matched) {
this.emitHotkeyOn(matched)
this.lastMatchedHotkeyStartTime = fullMatches[0].startTime
this.lastMatchedHotkeyEndTime = fullMatches[0].endTime
} else if (this.pressedHotkey) {
this.emitHotkeyOff(this.pressedHotkey)
} }
}) return matches[0].id
}
}
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.next(nativeEvent)
})
} }
clearCurrentKeystrokes (): void { clearCurrentKeystrokes (): void {
this.currentEvents = [] this.lastKeystrokes = []
} this.pressedKeys.clear()
this.pressedKeyTimestamps.clear()
getCurrentKeySequence (): KeySequenceItem[] { this.pressedKeystroke = null
this.currentEvents = this.currentEvents.filter(x => performance.now() - x.time < KEY_TIMEOUT && x.registrationTime >= this.lastMatchedHotkeyStartTime) this.pressedHotkey = null
return stringifyKeySequence(this.currentEvents)
}
getCurrentFullyMatchedHotkey (): string|null {
return this.pressedHotkey
}
getCurrentPartiallyMatchedHotkeys (): PartialHotkeyMatch[] {
const currentStrokes = this.getCurrentKeySequence().map(x => x.value)
const config = this.getHotkeysConfig()
const result: PartialHotkeyMatch[] = []
for (const id in config) {
for (const sequence of config[id]) {
for (let matchLength = Math.min(currentStrokes.length, sequence.length); matchLength > 0; matchLength--) {
if (sequence.slice(0, matchLength).every(
(x: string, index: number) =>
x.toLowerCase() ===
currentStrokes[currentStrokes.length - matchLength + index].toLowerCase()
)) {
result.push({
matchedLength: matchLength,
id,
strokes: sequence,
})
break
}
}
}
}
return result
} }
getHotkeyDescription (id: string): HotkeyDescription { getHotkeyDescription (id: string): HotkeyDescription {
@@ -238,6 +262,32 @@ export class HotkeysService {
).reduce((a, b) => a.concat(b)) ).reduce((a, b) => a.concat(b))
} }
private processKeystrokes () {
const matched = this.matchActiveHotkey()
this.zone.run(() => {
if (matched) {
this.emitHotkeyOn(matched)
} 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
}
private getHotkeysConfig () { private getHotkeysConfig () {
return this.getHotkeysConfigRecursive(this.config.store.hotkeys) return this.getHotkeysConfigRecursive(this.config.store.hotkeys)
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-type-alias */
export const metaKeyName = { export const metaKeyName = {
darwin: '⌘', darwin: '⌘',
win32: 'Win', win32: 'Win',
@@ -24,40 +25,10 @@ export interface KeyEventData {
const REGEX_LATIN_KEYNAME = /^[A-Za-z]$/ const REGEX_LATIN_KEYNAME = /^[A-Za-z]$/
export interface KeySequenceItem { export type KeyName = string
value: string export type Keystroke = 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()!
export function getKeyName (event: KeyEventData): KeyName {
// eslint-disable-next-line @typescript-eslint/init-declarations // eslint-disable-next-line @typescript-eslint/init-declarations
let key: string let key: string
if (event.key === 'Control') { if (event.key === 'Control') {
@@ -93,19 +64,14 @@ export function stringifyKeySequence (events: KeyEventData[]): KeySequenceItem[]
}[key] ?? key }[key] ?? key
} }
} }
return key
if (event.eventName === 'keydown') { }
pressedKeys.push({
value: key, export function getKeystrokeName (keys: KeyName[]): Keystroke {
firstEvent: event, const strictOrdering: KeyName[] = ['Ctrl', metaKeyName, altKeyName, 'Shift']
lastEvent: event, keys = [
}) ...strictOrdering.map(x => keys.find(k => k === x)).filter(x => !!x) as KeyName[],
} ...keys.filter(k => !strictOrdering.includes(k)),
if (event.eventName === 'keyup') { ]
flushPressedKeys() return keys.join('-')
}
}
flushPressedKeys()
return items
} }

View File

@@ -1,7 +1,7 @@
import { Component, Input } from '@angular/core' import { Component, Input } from '@angular/core'
import { trigger, transition, style, animate } from '@angular/animations' import { trigger, transition, style, animate } from '@angular/animations'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { HotkeysService, BaseComponent } from 'tabby-core' import { HotkeysService, BaseComponent, Keystroke } from 'tabby-core'
const INPUT_TIMEOUT = 1000 const INPUT_TIMEOUT = 1000
@@ -36,7 +36,7 @@ const INPUT_TIMEOUT = 1000
], ],
}) })
export class HotkeyInputModalComponent extends BaseComponent { export class HotkeyInputModalComponent extends BaseComponent {
@Input() value: string[] = [] @Input() value: Keystroke[] = []
@Input() timeoutProgress = 0 @Input() timeoutProgress = 0
private lastKeyEvent: number|null = null private lastKeyEvent: number|null = null
@@ -48,9 +48,9 @@ export class HotkeyInputModalComponent extends BaseComponent {
) { ) {
super() super()
this.hotkeys.clearCurrentKeystrokes() this.hotkeys.clearCurrentKeystrokes()
this.subscribeUntilDestroyed(hotkeys.key, (event) => { this.subscribeUntilDestroyed(hotkeys.keystroke$, (keystroke) => {
this.lastKeyEvent = performance.now() this.lastKeyEvent = performance.now()
this.value = this.hotkeys.getCurrentKeySequence().map(x => x.value) this.value.push(keystroke)
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
}) })

View File

@@ -85,9 +85,9 @@ export class XTermFrontend extends Frontend {
this.xterm.unicode.activeVersion = '11' this.xterm.unicode.activeVersion = '11'
const keyboardEventHandler = (name: string, event: KeyboardEvent) => { const keyboardEventHandler = (name: string, event: KeyboardEvent) => {
this.hotkeysService.pushKeystroke(name, event) this.hotkeysService.pushKeyEvent(name, event)
let ret = true let ret = true
if (this.hotkeysService.getCurrentPartiallyMatchedHotkeys().length !== 0) { if (this.hotkeysService.matchActiveHotkey(true) !== null) {
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
ret = false ret = false

View File

@@ -100,15 +100,13 @@ export default class TerminalModule { // eslint-disable-line @typescript-eslint/
events.forEach(event => { events.forEach(event => {
const oldHandler = hterm.hterm.Keyboard.prototype[event.htermHandler] const oldHandler = hterm.hterm.Keyboard.prototype[event.htermHandler]
hterm.hterm.Keyboard.prototype[event.htermHandler] = function (nativeEvent) { hterm.hterm.Keyboard.prototype[event.htermHandler] = function (nativeEvent) {
hotkeys.pushKeystroke(event.name, nativeEvent) hotkeys.pushKeyEvent(event.name, nativeEvent)
if (hotkeys.getCurrentPartiallyMatchedHotkeys().length === 0) { if (hotkeys.matchActiveHotkey(true) !== null) {
oldHandler.bind(this)(nativeEvent) oldHandler.bind(this)(nativeEvent)
} else { } else {
nativeEvent.stopPropagation() nativeEvent.stopPropagation()
nativeEvent.preventDefault() nativeEvent.preventDefault()
} }
hotkeys.processKeystrokes()
hotkeys.emitKeyEvent(nativeEvent)
} }
}) })
} }