@f5xc-salesdemos/pi-tui 18.8.2 → 18.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/chord-dispatcher.ts +90 -0
- package/src/chord-parser.ts +66 -0
- package/src/index.ts +17 -0
- package/src/keybindings.ts +65 -4
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/pi-tui",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.9.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
41
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
40
|
+
"@f5xc-salesdemos/pi-natives": "18.9.0",
|
|
41
|
+
"@f5xc-salesdemos/pi-utils": "18.9.0",
|
|
42
42
|
"marked": "^17.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ChordBinding } from "./chord-parser";
|
|
2
|
+
import type { KeyId } from "./keys";
|
|
3
|
+
|
|
4
|
+
export type ChordResult =
|
|
5
|
+
| { kind: "dispatched"; action: string }
|
|
6
|
+
| { kind: "pending"; leader: KeyId }
|
|
7
|
+
| { kind: "passthrough" }
|
|
8
|
+
| { kind: "abandoned" };
|
|
9
|
+
|
|
10
|
+
export interface ChordDispatcherCallbacks {
|
|
11
|
+
onPending?: (leader: KeyId) => void;
|
|
12
|
+
onCleared?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PendingState {
|
|
16
|
+
leader: KeyId;
|
|
17
|
+
timer: ReturnType<typeof setTimeout>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Two-key chord state machine. Stateless across keystrokes except for one
|
|
22
|
+
* optional "pending leader" slot + its timeout timer. Consumers feed KeyIds
|
|
23
|
+
* via feedKey() and act on the returned ChordResult.
|
|
24
|
+
*/
|
|
25
|
+
export class ChordDispatcher {
|
|
26
|
+
readonly #bindings: ChordBinding[];
|
|
27
|
+
readonly #timeoutMs: number;
|
|
28
|
+
readonly #callbacks: ChordDispatcherCallbacks;
|
|
29
|
+
#pending: PendingState | null = null;
|
|
30
|
+
|
|
31
|
+
constructor(bindings: ChordBinding[], timeoutMs: number, callbacks: ChordDispatcherCallbacks = {}) {
|
|
32
|
+
this.#bindings = bindings;
|
|
33
|
+
this.#timeoutMs = timeoutMs;
|
|
34
|
+
this.#callbacks = callbacks;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
feedKey(key: KeyId): ChordResult {
|
|
38
|
+
// Phase 1: pending chord is active — interpret this key as the 2nd stroke.
|
|
39
|
+
if (this.#pending) {
|
|
40
|
+
const match = this.#bindings.find(
|
|
41
|
+
b => b.sequence.length === 2 && b.sequence[0] === this.#pending!.leader && b.sequence[1] === key,
|
|
42
|
+
);
|
|
43
|
+
this.#clearPending();
|
|
44
|
+
if (match) return { kind: "dispatched", action: match.action };
|
|
45
|
+
return { kind: "abandoned" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Phase 2: no pending chord.
|
|
49
|
+
// Single-stroke match wins first.
|
|
50
|
+
for (const b of this.#bindings) {
|
|
51
|
+
if (b.sequence.length === 1 && b.sequence[0] === key) {
|
|
52
|
+
return { kind: "dispatched", action: b.action };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Chord leader?
|
|
56
|
+
const isLeader = this.#bindings.some(b => b.sequence.length === 2 && b.sequence[0] === key);
|
|
57
|
+
if (isLeader) {
|
|
58
|
+
this.#setPending(key);
|
|
59
|
+
return { kind: "pending", leader: key };
|
|
60
|
+
}
|
|
61
|
+
return { kind: "passthrough" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#setPending(leader: KeyId): void {
|
|
65
|
+
const timer = setTimeout(() => this.#timeoutClear(), this.#timeoutMs);
|
|
66
|
+
this.#pending = { leader, timer };
|
|
67
|
+
this.#callbacks.onPending?.(leader);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#clearPending(): void {
|
|
71
|
+
if (!this.#pending) return;
|
|
72
|
+
clearTimeout(this.#pending.timer);
|
|
73
|
+
this.#pending = null;
|
|
74
|
+
this.#callbacks.onCleared?.();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#timeoutClear(): void {
|
|
78
|
+
// Timer fired; timer handle consumed by runtime.
|
|
79
|
+
if (!this.#pending) return;
|
|
80
|
+
this.#pending = null;
|
|
81
|
+
this.#callbacks.onCleared?.();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
dispose(): void {
|
|
85
|
+
if (this.#pending) {
|
|
86
|
+
clearTimeout(this.#pending.timer);
|
|
87
|
+
this.#pending = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { KeyId } from "./keys";
|
|
2
|
+
|
|
3
|
+
export interface ParsedBinding {
|
|
4
|
+
sequence: KeyId[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface BindingParseError {
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly input: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ParseResult = { ok: true; sequence: KeyId[] } | { ok: false; error: BindingParseError };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a binding string into a keystroke sequence.
|
|
16
|
+
*
|
|
17
|
+
* - Whitespace separates keystrokes in a chord.
|
|
18
|
+
* - One keystroke = single-stroke binding; two keystrokes = chord.
|
|
19
|
+
* - v1 rejects 3+ keystrokes.
|
|
20
|
+
* - Individual keystroke validation (known key names + modifier combos) is
|
|
21
|
+
* deferred to KeyId at type check time and at runtime via parseKey on input.
|
|
22
|
+
*/
|
|
23
|
+
export function parseBinding(input: string): ParseResult {
|
|
24
|
+
const trimmed = input.trim();
|
|
25
|
+
if (trimmed.length === 0) {
|
|
26
|
+
return { ok: false, error: { message: "empty binding", input } };
|
|
27
|
+
}
|
|
28
|
+
const tokens = trimmed.split(/\s+/);
|
|
29
|
+
if (tokens.length > 2) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
error: {
|
|
33
|
+
message: `chord bindings support at most 2 keystrokes (got ${tokens.length})`,
|
|
34
|
+
input,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, sequence: tokens as KeyId[] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ChordBinding {
|
|
42
|
+
action: string;
|
|
43
|
+
sequence: KeyId[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type BindingsInput = Record<string, string | string[]>;
|
|
47
|
+
|
|
48
|
+
export type ParseBindingsResult = { ok: true; bindings: ChordBinding[] } | { ok: false; error: BindingParseError };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a map of action → binding string(s) into a flat list of ChordBinding.
|
|
52
|
+
* Array values expand into multiple ChordBindings for the same action.
|
|
53
|
+
* Stops at the first parse error.
|
|
54
|
+
*/
|
|
55
|
+
export function parseBindings(input: BindingsInput): ParseBindingsResult {
|
|
56
|
+
const bindings: ChordBinding[] = [];
|
|
57
|
+
for (const [action, value] of Object.entries(input)) {
|
|
58
|
+
const list = Array.isArray(value) ? value : [value];
|
|
59
|
+
for (const str of list) {
|
|
60
|
+
const parsed = parseBinding(str);
|
|
61
|
+
if (!parsed.ok) return parsed;
|
|
62
|
+
bindings.push({ action, sequence: parsed.sequence });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, bindings };
|
|
66
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
// Autocomplete support
|
|
4
4
|
export * from "./autocomplete";
|
|
5
|
+
// Chord dispatcher
|
|
6
|
+
export {
|
|
7
|
+
ChordDispatcher,
|
|
8
|
+
type ChordDispatcherCallbacks,
|
|
9
|
+
type ChordResult,
|
|
10
|
+
} from "./chord-dispatcher";
|
|
11
|
+
// Chord parser
|
|
12
|
+
export {
|
|
13
|
+
type BindingParseError,
|
|
14
|
+
type BindingsInput,
|
|
15
|
+
type ChordBinding,
|
|
16
|
+
type ParseBindingsResult,
|
|
17
|
+
type ParsedBinding,
|
|
18
|
+
type ParseResult,
|
|
19
|
+
parseBinding,
|
|
20
|
+
parseBindings,
|
|
21
|
+
} from "./chord-parser";
|
|
5
22
|
// Components
|
|
6
23
|
export * from "./components/box";
|
|
7
24
|
export * from "./components/cancellable-loader";
|
package/src/keybindings.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChordBinding, parseBinding } from "./chord-parser";
|
|
1
2
|
import { type KeyId, matchesKey, parseKey } from "./keys";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -46,13 +47,20 @@ export type Keybinding = keyof Keybindings;
|
|
|
46
47
|
// Re-export KeyId from keys.ts
|
|
47
48
|
export type { KeyId };
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Binding string accepted by the config layer. Single-stroke values are
|
|
52
|
+
* KeyId (strict template-literal union); chord-syntax values (e.g.
|
|
53
|
+
* "ctrl+x b") are plain strings validated at runtime by parseBinding.
|
|
54
|
+
*/
|
|
55
|
+
export type BindingString = KeyId | string;
|
|
56
|
+
|
|
49
57
|
export interface KeybindingDefinition {
|
|
50
|
-
defaultKeys:
|
|
58
|
+
defaultKeys: BindingString | BindingString[];
|
|
51
59
|
description?: string;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
export type KeybindingDefinitions = Record<string, KeybindingDefinition>;
|
|
55
|
-
export type KeybindingsConfig = Record<string,
|
|
63
|
+
export type KeybindingsConfig = Record<string, BindingString | BindingString[] | undefined>;
|
|
56
64
|
|
|
57
65
|
export const TUI_KEYBINDINGS = {
|
|
58
66
|
"tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" },
|
|
@@ -164,9 +172,9 @@ const SHIFTED_SYMBOL_KEYS = new Set<string>([
|
|
|
164
172
|
"~",
|
|
165
173
|
]);
|
|
166
174
|
|
|
167
|
-
const normalizeKeyId = (key:
|
|
175
|
+
const normalizeKeyId = (key: BindingString): KeyId => key.toLowerCase() as KeyId;
|
|
168
176
|
|
|
169
|
-
function normalizeKeys(keys:
|
|
177
|
+
function normalizeKeys(keys: BindingString | BindingString[] | undefined): KeyId[] {
|
|
170
178
|
if (keys === undefined) return [];
|
|
171
179
|
const keyList = Array.isArray(keys) ? keys : [keys];
|
|
172
180
|
const seen = new Set<KeyId>();
|
|
@@ -263,6 +271,59 @@ export class KeybindingsManager {
|
|
|
263
271
|
}
|
|
264
272
|
return resolved;
|
|
265
273
|
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Return all bindings (standalone + chord) with their action names, with
|
|
277
|
+
* user overrides already applied (same rules as getResolvedBindings).
|
|
278
|
+
*/
|
|
279
|
+
getChordBindings(): ChordBinding[] {
|
|
280
|
+
const result: ChordBinding[] = [];
|
|
281
|
+
for (const [action, keys] of this.#keysById) {
|
|
282
|
+
for (const key of keys) {
|
|
283
|
+
const parsed = parseBinding(key);
|
|
284
|
+
if (!parsed.ok) continue;
|
|
285
|
+
result.push({ action, sequence: parsed.sequence });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Return conflicts where a key is used BOTH as a chord leader AND as a
|
|
293
|
+
* standalone binding for some other action. Consumers (InputController)
|
|
294
|
+
* should refuse to initialize the dispatcher if any are reported.
|
|
295
|
+
*/
|
|
296
|
+
getChordConflicts(): Array<{
|
|
297
|
+
key: KeyId;
|
|
298
|
+
standaloneActions: string[];
|
|
299
|
+
chordActions: string[];
|
|
300
|
+
}> {
|
|
301
|
+
const standaloneByKey = new Map<KeyId, Set<string>>();
|
|
302
|
+
const leaderByKey = new Map<KeyId, Set<string>>();
|
|
303
|
+
for (const binding of this.getChordBindings()) {
|
|
304
|
+
const [first, second] = binding.sequence;
|
|
305
|
+
if (second === undefined) {
|
|
306
|
+
const set = standaloneByKey.get(first!) ?? new Set<string>();
|
|
307
|
+
set.add(binding.action);
|
|
308
|
+
standaloneByKey.set(first!, set);
|
|
309
|
+
} else {
|
|
310
|
+
const set = leaderByKey.get(first!) ?? new Set<string>();
|
|
311
|
+
set.add(binding.action);
|
|
312
|
+
leaderByKey.set(first!, set);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const conflicts: Array<{ key: KeyId; standaloneActions: string[]; chordActions: string[] }> = [];
|
|
316
|
+
for (const [key, leaders] of leaderByKey) {
|
|
317
|
+
const standalones = standaloneByKey.get(key);
|
|
318
|
+
if (!standalones || standalones.size === 0) continue;
|
|
319
|
+
conflicts.push({
|
|
320
|
+
key,
|
|
321
|
+
standaloneActions: [...standalones].sort(),
|
|
322
|
+
chordActions: [...leaders].sort(),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return conflicts;
|
|
326
|
+
}
|
|
266
327
|
}
|
|
267
328
|
|
|
268
329
|
let globalKeybindings: KeybindingsManager | null = null;
|