Merge pull request #4440 from treapster/gobble-accept-terminator

Use MinimalKey internally, add terminator key to gobble mode
This commit is contained in:
Oliver Blanthorn 2022-11-03 13:57:56 +01:00 committed by GitHub
commit ab67dea1c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 149 additions and 110 deletions

View file

@ -52,7 +52,7 @@ import * as genericParser from "@src/parsers/genericmode"
import * as perf from "@src/perf"
import state, * as State from "@src/state"
import * as R from "ramda"
import { KeyEventLike } from "@src/lib/keyseq"
import { MinimalKey, minimalKeyFromKeyboardEvent } from "@src/lib/keyseq"
import { TabGroupCompletionSource } from "@src/completions/TabGroup"
/** @hidden **/
@ -78,7 +78,7 @@ const commandline_state = {
* tl;dr TODO: delete this and better resolve race condition
*/
isVisible: false,
keyEvents: new Array<KeyEventLike>(),
keyEvents: new Array<MinimalKey>(),
refresh_completions,
state,
}
@ -186,7 +186,7 @@ commandline_state.clInput.addEventListener(
"keydown",
function (keyevent: KeyboardEvent) {
if (!keyevent.isTrusted) return
commandline_state.keyEvents.push(keyevent)
commandline_state.keyEvents.push(minimalKeyFromKeyboardEvent(keyevent))
const response = keyParser(commandline_state.keyEvents)
if (response.isMatch) {
keyevent.preventDefault()

View file

@ -2,7 +2,12 @@ import { isTextEditable } from "@src/lib/dom"
import { contentState, ModeName } from "@src/content/state_content"
import Logger from "@src/lib/logging"
import * as controller from "@src/lib/controller"
import { KeyEventLike, ParserResponse } from "@src/lib/keyseq"
import {
KeyEventLike,
ParserResponse,
minimalKeyFromKeyboardEvent,
MinimalKey,
} from "@src/lib/keyseq"
import { deepestShadowRoot } from "@src/lib/dom"
import * as hinting from "@src/content/hinting"
@ -97,7 +102,7 @@ export const canceller = new KeyCanceller()
/** Accepts keyevents, resolves them to maps, maps to exstrs, executes exstrs */
function* ParserController() {
const parsers: {
[mode_name in ModeName]: (keys: KeyEventLike[]) => ParserResponse
[mode_name in ModeName]: (keys: MinimalKey[]) => ParserResponse
} = {
normal: keys => generic.parser("nmaps", keys),
insert: keys => generic.parser("imaps", keys),
@ -112,25 +117,35 @@ function* ParserController() {
while (true) {
let exstr = ""
let previousSuffix = null
let keyEvents: KeyEventLike[] = []
let keyEvents: MinimalKey[] = []
try {
while (true) {
const keyevent: KeyEventLike = yield
let shadowRoot = null
let textEditable = false
const shadowRoot =
keyevent instanceof KeyboardEvent
? deepestShadowRoot((keyevent.target as Element).shadowRoot)
: null
if (keyevent instanceof KeyboardEvent) {
shadowRoot = deepestShadowRoot(
(keyevent.target as Element).shadowRoot,
)
textEditable =
shadowRoot === null
? isTextEditable(keyevent.target as Element)
: isTextEditable(shadowRoot.activeElement)
// Accumulate key events. The parser will cut this
// down whenever it's not a valid prefix of a known
// binding, so it can't grow indefinitely unless you
// have a combination of maps that permits bindings of
// unbounded length.
keyEvents.push(minimalKeyFromKeyboardEvent(keyevent))
} else {
keyEvents.push(keyevent)
}
// _just to be safe_, cache this to make the following
// code more thread-safe.
const currentMode = contentState.mode
const textEditable =
keyevent instanceof KeyboardEvent
? shadowRoot === null
? isTextEditable(keyevent.target as Element)
: isTextEditable(shadowRoot.activeElement)
: false
// This code was sort of the cause of the most serious bug in Tridactyl
// to date (March 2018).
@ -153,17 +168,10 @@ function* ParserController() {
const newMode = contentState.mode
if (newMode !== currentMode) {
keyEvents = []
keyEvents = keyEvents.slice(-1)
previousSuffix = null
}
// Accumulate key events. The parser will cut this
// down whenever it's not a valid prefix of a known
// binding, so it can't grow indefinitely unless you
// have a combination of maps that permits bindings of
// unbounded length.
keyEvents.push(keyevent)
const response = (
parsers[contentState.mode] ||
(keys => generic.parser(contentState.mode + "maps", keys))

View file

@ -1192,7 +1192,7 @@ function focusRightHint() {
}
/** @hidden */
export function parser(keys: keyseq.KeyEventLike[]) {
export function parser(keys: keyseq.MinimalKey[]) {
const parsed = keyseq.parse(
keys,
keyseq.mapstrMapToKeyMap(

View file

@ -5334,13 +5334,15 @@ export function run_exstr(...commands: string[]) {
/** Initialize gobble mode.
It will read `nChars` input keys, append them to `endCmd` and execute that
string.
If numKeysOrTerminator is a number, it will read the provided amount of keys,
append them to `endCmd` and execute that string.
If numKeysOrTerminator is a key or key combination like 'k', '<CR>' or '<C-j>',
it will read keys until the provided key is pressed, append them to `endCmd` and
execute that string.
*/
//#content
export async function gobble(nChars: number, endCmd: string) {
return gobbleMode.init(nChars, endCmd)
export async function gobble(numKeysOrTerminator: string, endCmd: string) {
return gobbleMode.init(numKeysOrTerminator, endCmd)
}
// }}}

View file

@ -2,7 +2,7 @@
*
*/
import { mapstrToKeyseq } from "@src/lib/keyseq"
import { canonicaliseMapstr } from "@src/lib/keyseq"
export const mode2maps = new Map([
["normal", "nmaps"],
@ -46,8 +46,7 @@ export function parse_bind_args(...args: string[]): bind_args {
const key = args.shift()
// Convert key to internal representation
const keyseq = mapstrToKeyseq(key)
result.key = keyseq.map(k => k.toMapstr()).join("")
result.key = canonicaliseMapstr(key)
result.excmd = args.join(" ")

View file

@ -371,7 +371,7 @@ export class default_config {
".": "repeat",
"<AS-ArrowUp><AS-ArrowUp><AS-ArrowDown><AS-ArrowDown><AS-ArrowLeft><AS-ArrowRight><AS-ArrowLeft><AS-ArrowRight>ba":
"open https://www.youtube.com/watch?v=M3iOROuTuMA",
"m": "gobble 1 markadd",
m: "gobble 1 markadd",
"`": "gobble 1 markjump",
}
@ -1265,9 +1265,10 @@ const platform_defaults = {
& '%TEMP%/tridactyl_installnative.ps1' -Tag %TAG;\
Remove-Item '%TEMP%/tridactyl_installnative.ps1'"`,
downloadforbiddenchars: "#%&{}\\<>*?/$!'\":@+`|=",
downloadforbiddennames: "CON, PRN, AUX, NUL, COM1, COM2,"
+ "COM3, COM4, COM5, COM6, COM7, COM8, COM9, LPT1,"
+ "LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, LPT9,",
downloadforbiddennames:
"CON, PRN, AUX, NUL, COM1, COM2," +
"COM3, COM4, COM5, COM6, COM7, COM8, COM9, LPT1," +
"LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, LPT9,",
},
linux: {
nmaps: {

View file

@ -38,42 +38,57 @@ export interface KeyModifiers {
shiftKey?: boolean
}
// Format modifiers
const modifiers = new Map([
["A", "altKey"],
["C", "ctrlKey"],
["M", "metaKey"],
["S", "shiftKey"],
])
export class MinimalKey {
readonly altKey = false
readonly ctrlKey = false
readonly metaKey = false
readonly shiftKey = false
translated = false
constructor(readonly key: string, modifiers?: KeyModifiers) {
if (modifiers !== undefined) {
for (const mod of Object.keys(modifiers)) {
if (
this.key.length === 1 &&
this.key !== " " &&
mod === "shiftKey"
)
continue
this[mod] = modifiers[mod]
}
}
}
/** Does this key match a given MinimalKey extending object? */
public match(keyevent) {
// 'in' doesn't include prototypes, so it's safe for this object.
for (const attr in this) {
// Don't check shiftKey for normal keys.
if (attr === "shiftKey" && this.key.length === 1) continue
/** Does this key match another MinimalKey */
public match(keyevent: MinimalKey) {
if (this.key !== keyevent.key) return false
for (const [_, attr] of modifiers.entries()) {
if (this[attr] !== keyevent[attr]) return false
}
return true
}
public translate(keytranslatemap: { [inkey: string]: string }): MinimalKey {
let newkey = keytranslatemap[this.key]
if (newkey === undefined) newkey = this.key
return new MinimalKey(newkey, {
altKey: this.altKey,
ctrlKey: this.ctrlKey,
metaKey: this.metaKey,
shiftKey: this.shiftKey,
})
}
public toMapstr() {
let str = ""
let needsBrackets = this.key.length > 1
// Format modifiers
const modifiers = new Map([
["A", "altKey"],
["C", "ctrlKey"],
["M", "metaKey"],
["S", "shiftKey"],
])
for (const [letter, attr] of modifiers.entries()) {
if (this[attr]) {
str += letter
@ -98,6 +113,9 @@ export class MinimalKey {
return str
}
public isPrintable() {
return this.key.length === 1
}
}
export type KeyEventLike = MinimalKey | KeyboardEvent
@ -110,7 +128,7 @@ type MapTarget = string | ((...args: any[]) => any)
type KeyMap = Map<MinimalKey[], MapTarget>
export interface ParserResponse {
keys?: KeyEventLike[]
keys?: MinimalKey[]
value?: string
exstr?: string
isMatch?: boolean
@ -118,8 +136,8 @@ export interface ParserResponse {
}
function splitNumericPrefix(
keyseq: KeyEventLike[],
): [KeyEventLike[], KeyEventLike[]] {
keyseq: MinimalKey[],
): [MinimalKey[], MinimalKey[]] {
// If the first key is in 1:9, partition all numbers until you reach a non-number.
if (
!hasModifiers(keyseq[0]) &&
@ -148,7 +166,7 @@ export function stripOnlyModifiers(keyseq) {
)
}
export function parse(keyseq: KeyEventLike[], map: KeyMap): ParserResponse {
export function parse(keyseq: MinimalKey[], map: KeyMap): ParserResponse {
// Remove bare modifiers
keyseq = stripOnlyModifiers(keyseq)
@ -156,7 +174,7 @@ export function parse(keyseq: KeyEventLike[], map: KeyMap): ParserResponse {
if (keyseq.length === 0) return { keys: [], isMatch: false }
// Split into numeric prefix and non-numeric suffix
let numericPrefix: KeyEventLike[]
let numericPrefix: MinimalKey[]
;[numericPrefix, keyseq] = splitNumericPrefix(keyseq)
// If keyseq is a prefix of a key in map, proceed, else try dropping keys
@ -198,7 +216,7 @@ export function parse(keyseq: KeyEventLike[], map: KeyMap): ParserResponse {
}
/** True if seq1 is a prefix or equal to seq2 */
function prefixes(seq1: KeyEventLike[], seq2: MinimalKey[]) {
function prefixes(seq1: MinimalKey[], seq2: MinimalKey[]) {
if (seq1.length > seq2.length) {
return false
} else {
@ -210,7 +228,7 @@ function prefixes(seq1: KeyEventLike[], seq2: MinimalKey[]) {
}
/** returns the fragment of `map` that keyseq is a valid prefix of. */
export function completions(keyseq: KeyEventLike[], map: KeyMap): KeyMap {
export function completions(keyseq: MinimalKey[], map: KeyMap): KeyMap {
return new Map(
filter(map.entries(), ([ks, _maptarget]) => prefixes(keyseq, ks)),
)
@ -295,10 +313,8 @@ function expandAliases(key: string) {
export function bracketexprToKey(inputStr) {
if (inputStr.indexOf(">") > 0) {
try {
const [
[modifiers, key],
remainder,
] = bracketexpr_parser.feedUntilError(inputStr)
const [[modifiers, key], remainder] =
bracketexpr_parser.feedUntilError(inputStr)
return [new MinimalKey(expandAliases(key), modifiers), remainder]
} catch (e) {
// No valid bracketExpr
@ -350,6 +366,12 @@ export function mapstrToKeyseq(mapstr: string): MinimalKey[] {
return keyseq
}
export function canonicaliseMapstr(mapstr: string): string {
return mapstrToKeyseq(mapstr)
.map(k => k.toMapstr())
.join("")
}
export const commandKey2jsKey = {
Comma: ",",
Period: ".",
@ -440,7 +462,7 @@ export function keyMap(conf): KeyMap {
// {{{ Utility functions for dealing with KeyboardEvents
export function hasModifiers(keyEvent: KeyEventLike) {
export function hasModifiers(keyEvent: MinimalKey) {
return (
keyEvent.ctrlKey ||
keyEvent.altKey ||
@ -450,16 +472,11 @@ export function hasModifiers(keyEvent: KeyEventLike) {
}
/** shiftKey is true for any capital letter, most numbers, etc. Generally care about other modifiers. */
export function hasNonShiftModifiers(keyEvent: KeyEventLike) {
export function hasNonShiftModifiers(keyEvent: MinimalKey) {
return keyEvent.ctrlKey || keyEvent.altKey || keyEvent.metaKey
}
/** A simple key event is a non-special key (length 1) that is not modified by ctrl, alt, or shift. */
export function isSimpleKey(keyEvent: KeyEventLike) {
return !(keyEvent.key.length > 1 || hasNonShiftModifiers(keyEvent))
}
function numericPrefixToExstrSuffix(numericPrefix: KeyEventLike[]) {
function numericPrefixToExstrSuffix(numericPrefix: MinimalKey[]) {
if (numericPrefix.length > 0) {
return " " + numericPrefix.map(k => k.key).join("")
} else {
@ -473,37 +490,34 @@ function numericPrefixToExstrSuffix(numericPrefix: KeyEventLike[]) {
* translation map must be length-1 strings.
*/
export function translateKeysUsingKeyTranslateMap(
keyEvents: KeyEventLike[],
keyEvents: MinimalKey[],
keytranslatemap: { [inkey: string]: string },
) {
for (let index = 0; index < keyEvents.length; index++) {
const keyEvent = keyEvents[index]
const newkey = keytranslatemap[keyEvent.key]
// KeyboardEvents can't have been translated, MinimalKeys may
// have been. We can't add anything to the MinimalKey without
// breaking a ton of other stuff, so instead we'll just assume
// that the only way we've gotten a MinimalKey is if the key
// has already been translated. We err way on the side of
// safety becase translating anything more than once would
// Translating anything more than once would
// almost certainly mean oscillations and other super-weird
// breakage.
const neverTranslated = keyEvent instanceof KeyboardEvent
if (neverTranslated && newkey !== undefined) {
// We can't update the keyEvent in place. However, the
// entire pipeline works with MinimalKeys all the way
// through, so we just swap the key event out for a new
// MinimalKey with the right key and modifiers copied from
// the original.
keyEvents[index] = new MinimalKey(newkey, {
altKey: keyEvent.altKey,
ctrlKey: keyEvent.ctrlKey,
metaKey: keyEvent.metaKey,
shiftKey: keyEvent.shiftKey,
})
if (!keyEvents[index].translated) {
keyEvents[index] = keyEvents[index].translate(keytranslatemap)
keyEvents[index].translated = true
}
}
}
/**
* Convert keyboardEvent to internal type MinimalKey
* for further use. Key is obtained through layout-independent
* code if config says so.
*/
export function minimalKeyFromKeyboardEvent(
keyEvent: KeyboardEvent,
): MinimalKey {
return new MinimalKey(keyEvent.key, {
altKey: keyEvent.altKey,
ctrlKey: keyEvent.ctrlKey,
metaKey: keyEvent.metaKey,
shiftKey: keyEvent.shiftKey,
})
}
// }}}

View file

@ -1,22 +1,26 @@
import { contentState } from "@src/content/state_content"
import { isSimpleKey, KeyEventLike } from "@src/lib/keyseq"
import { MinimalKey, canonicaliseMapstr } from "@src/lib/keyseq"
/** Simple container for the gobble state. */
class GobbleState {
public numChars = 0
public chars = ""
public numKeysOrTerminator: number | string = 0
public keyCombination = ""
public endCommand = ""
}
let modeState: GobbleState
/** Init gobble mode. After parsing the defined number of input keys, execute
`endCmd` with attached parsed input. `Escape` cancels the mode and returns to
normal mode. */
export function init(numChars: number, endCommand: string) {
/** Init gobble mode. After parsing the defined number of input keys,
* or until provided terminator key, execute `endCmd` with attached parsed input.
* `Escape` cancels the mode and returns to normal mode. */
export function init(numKeysOrTerminator: string, endCommand: string) {
contentState.mode = "gobble"
modeState = new GobbleState()
modeState.numChars = numChars
const number = Number(numKeysOrTerminator)
if (!isNaN(number)) {
modeState.numKeysOrTerminator = number
} else
modeState.numKeysOrTerminator = canonicaliseMapstr(numKeysOrTerminator)
modeState.endCommand = endCommand
}
@ -27,18 +31,29 @@ function reset() {
}
/** Receive keypress. If applicable, execute a command. */
export function parser(keys: KeyEventLike[]) {
export function parser(keys: MinimalKey[]) {
function exec() {
const exstr = modeState.endCommand + " " + modeState.keyCombination
reset()
return { keys: [], exstr }
}
const key = keys[0].key
if (key === "Escape") {
reset()
} else if (isSimpleKey(keys[0])) {
modeState.chars += key
if (modeState.chars.length >= modeState.numChars) {
const exstr = modeState.endCommand + " " + modeState.chars
reset()
return { keys: [], exstr }
}
} else if (
typeof modeState.numKeysOrTerminator === "string" &&
modeState.numKeysOrTerminator === keys[0].toMapstr()
) {
return exec()
} else if (keys[0].isPrintable()) {
modeState.keyCombination += keys[0].toMapstr()
if (
typeof modeState.numKeysOrTerminator === "number" &&
--modeState.numKeysOrTerminator <= 0
)
return exec()
}
return { keys: [], exstr: "", isMatch: true }
}

View file

@ -25,7 +25,7 @@ export function init(endCommand: string, mode = "normal", numCommands = 1) {
}
/** Receive keypress. If applicable, execute a command. */
export function parser(keys: keyseq.KeyEventLike[]) {
export function parser(keys: keyseq.MinimalKey[]) {
keys = keyseq.stripOnlyModifiers(keys)
if (keys.length === 0) return { keys: [], isMatch: false }
const conf = mode2maps.get(modeState.mode) || modeState.mode + "maps"