@tuidom/core 0.1.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/dist/backend/iTerminalBackend.d.ts +41 -0
- package/dist/backend/iTerminalBackend.js +1 -0
- package/dist/common/colorUtils.d.ts +19 -0
- package/dist/common/colorUtils.js +30 -0
- package/dist/common/displayLine.d.ts +72 -0
- package/dist/common/displayLine.js +190 -0
- package/dist/common/disposable.d.ts +9 -0
- package/dist/common/disposable.js +18 -0
- package/dist/common/geometryPromitives.d.ts +47 -0
- package/dist/common/geometryPromitives.js +115 -0
- package/dist/common/iTerminalSurface.d.ts +68 -0
- package/dist/common/iTerminalSurface.js +8 -0
- package/dist/common/measureTextWidth.d.ts +21 -0
- package/dist/common/measureTextWidth.js +43 -0
- package/dist/common/styleFlags.d.ts +17 -0
- package/dist/common/styleFlags.js +16 -0
- package/dist/common/textTruncation.d.ts +30 -0
- package/dist/common/textTruncation.js +124 -0
- package/dist/common/typingUtils.d.ts +1 -0
- package/dist/common/typingUtils.js +3 -0
- package/dist/common/unicodeWidth.d.ts +18 -0
- package/dist/common/unicodeWidth.js +339 -0
- package/dist/dom/borderStyle.d.ts +31 -0
- package/dist/dom/borderStyle.js +40 -0
- package/dist/dom/compositeElement.d.ts +20 -0
- package/dist/dom/compositeElement.js +44 -0
- package/dist/dom/events/contextMenuEventSource.d.ts +17 -0
- package/dist/dom/events/contextMenuEventSource.js +44 -0
- package/dist/dom/events/focusManager.d.ts +13 -0
- package/dist/dom/events/focusManager.js +56 -0
- package/dist/dom/events/mouseEventDispatcher.d.ts +24 -0
- package/dist/dom/events/mouseEventDispatcher.js +177 -0
- package/dist/dom/events/tuiEventBase.d.ts +25 -0
- package/dist/dom/events/tuiEventBase.js +39 -0
- package/dist/dom/events/tuiFocusEvent.d.ts +6 -0
- package/dist/dom/events/tuiFocusEvent.js +8 -0
- package/dist/dom/events/tuiKeyboardEvent.d.ts +21 -0
- package/dist/dom/events/tuiKeyboardEvent.js +20 -0
- package/dist/dom/events/tuiMouseEvent.d.ts +40 -0
- package/dist/dom/events/tuiMouseEvent.js +32 -0
- package/dist/dom/events/tuiPasteEvent.d.ts +10 -0
- package/dist/dom/events/tuiPasteEvent.js +13 -0
- package/dist/dom/overlayLayer.d.ts +92 -0
- package/dist/dom/overlayLayer.js +343 -0
- package/dist/dom/styles/index.d.ts +4 -0
- package/dist/dom/styles/index.js +2 -0
- package/dist/dom/styles/styleTokens.d.ts +114 -0
- package/dist/dom/styles/styleTokens.js +122 -0
- package/dist/dom/styles/tuiStyle.d.ts +75 -0
- package/dist/dom/styles/tuiStyle.js +121 -0
- package/dist/dom/tuiApplication.d.ts +57 -0
- package/dist/dom/tuiApplication.js +231 -0
- package/dist/dom/tuiElement.d.ts +528 -0
- package/dist/dom/tuiElement.js +1168 -0
- package/dist/dom/tuiSelector.d.ts +9 -0
- package/dist/dom/tuiSelector.js +82 -0
- package/dist/dom/validateTree.d.ts +42 -0
- package/dist/dom/validateTree.js +125 -0
- package/dist/input/convertToken.d.ts +3 -0
- package/dist/input/convertToken.js +114 -0
- package/dist/input/keyEvent.d.ts +46 -0
- package/dist/input/keyEvent.js +26 -0
- package/dist/input/keyInputParser.d.ts +72 -0
- package/dist/input/keyInputParser.js +249 -0
- package/dist/input/mouseTracking.d.ts +18 -0
- package/dist/input/mouseTracking.js +18 -0
- package/dist/input/parseInput.d.ts +13 -0
- package/dist/input/parseInput.js +17 -0
- package/dist/input/rawTerminalToken.d.ts +142 -0
- package/dist/input/rawTerminalToken.js +2 -0
- package/dist/input/serializeKey.d.ts +14 -0
- package/dist/input/serializeKey.js +179 -0
- package/dist/input/serializeMouse.d.ts +26 -0
- package/dist/input/serializeMouse.js +38 -0
- package/dist/input/tokenize.d.ts +59 -0
- package/dist/input/tokenize.js +681 -0
- package/dist/rendering/cell.d.ts +24 -0
- package/dist/rendering/cell.js +46 -0
- package/dist/rendering/damage.d.ts +35 -0
- package/dist/rendering/damage.js +99 -0
- package/dist/rendering/grid.d.ts +49 -0
- package/dist/rendering/grid.js +216 -0
- package/dist/rendering/gridSnapshot.d.ts +33 -0
- package/dist/rendering/gridSnapshot.js +32 -0
- package/dist/rendering/gridToSvg.d.ts +29 -0
- package/dist/rendering/gridToSvg.js +145 -0
- package/dist/rendering/terminalRenderer.d.ts +28 -0
- package/dist/rendering/terminalRenderer.js +161 -0
- package/dist/rendering/terminalScreen.d.ts +25 -0
- package/dist/rendering/terminalScreen.js +50 -0
- package/package.json +29 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TUIElement } from "./tuiElement.js";
|
|
2
|
+
export interface ParsedSelector {
|
|
3
|
+
tag: string | undefined;
|
|
4
|
+
id: string | undefined;
|
|
5
|
+
role: string | undefined;
|
|
6
|
+
}
|
|
7
|
+
export declare function parseSelector(selector: string): ParsedSelector[];
|
|
8
|
+
export declare function querySelector(root: TUIElement, selector: string): TUIElement | null;
|
|
9
|
+
export declare function querySelectorAll(root: TUIElement, selector: string): TUIElement[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
function parseSingleSelector(part) {
|
|
2
|
+
let tag;
|
|
3
|
+
let id;
|
|
4
|
+
let role;
|
|
5
|
+
let remaining = part;
|
|
6
|
+
// Extract #id
|
|
7
|
+
const idMatch = /#([a-zA-Z0-9_-]+)/.exec(remaining);
|
|
8
|
+
if (idMatch) {
|
|
9
|
+
id = idMatch[1];
|
|
10
|
+
remaining = remaining.replace(idMatch[0], "");
|
|
11
|
+
}
|
|
12
|
+
// Extract @role
|
|
13
|
+
const roleMatch = /@([a-zA-Z0-9_-]+)/.exec(remaining);
|
|
14
|
+
if (roleMatch) {
|
|
15
|
+
role = roleMatch[1];
|
|
16
|
+
remaining = remaining.replace(roleMatch[0], "");
|
|
17
|
+
}
|
|
18
|
+
// Whatever is left is the tag (constructor name)
|
|
19
|
+
if (remaining.length > 0) {
|
|
20
|
+
tag = remaining;
|
|
21
|
+
}
|
|
22
|
+
return { tag, id, role };
|
|
23
|
+
}
|
|
24
|
+
function matchesSingleSelector(element, selector) {
|
|
25
|
+
if (selector.tag && element.constructor.name !== selector.tag)
|
|
26
|
+
return false;
|
|
27
|
+
if (selector.id && element.id !== selector.id)
|
|
28
|
+
return false;
|
|
29
|
+
if (selector.role && element.role !== selector.role)
|
|
30
|
+
return false;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
export function parseSelector(selector) {
|
|
34
|
+
const parts = selector.trim().split(/\s+/);
|
|
35
|
+
return parts.map(parseSingleSelector);
|
|
36
|
+
}
|
|
37
|
+
export function querySelector(root, selector) {
|
|
38
|
+
const parsed = parseSelector(selector);
|
|
39
|
+
return queryDescendant(root, parsed, 0);
|
|
40
|
+
}
|
|
41
|
+
export function querySelectorAll(root, selector) {
|
|
42
|
+
const parsed = parseSelector(selector);
|
|
43
|
+
const results = [];
|
|
44
|
+
queryDescendantAll(root, parsed, 0, results);
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
function queryDescendant(element, selectors, depth) {
|
|
48
|
+
for (const child of element.getChildren()) {
|
|
49
|
+
if (matchesSingleSelector(child, selectors[depth])) {
|
|
50
|
+
if (depth === selectors.length - 1)
|
|
51
|
+
return child;
|
|
52
|
+
const found = queryDescendant(child, selectors, depth + 1);
|
|
53
|
+
if (found)
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
/* v8 ignore start -- depth>0 only happens for multi-part selectors (length>1), so the guard is always true; the false side is unreachable */
|
|
57
|
+
if (depth === 0 || selectors.length > 1) {
|
|
58
|
+
const found = queryDescendant(child, selectors, depth);
|
|
59
|
+
if (found)
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
/* v8 ignore stop */
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function queryDescendantAll(element, selectors, depth, results) {
|
|
67
|
+
for (const child of element.getChildren()) {
|
|
68
|
+
if (matchesSingleSelector(child, selectors[depth])) {
|
|
69
|
+
if (depth === selectors.length - 1) {
|
|
70
|
+
results.push(child);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
queryDescendantAll(child, selectors, depth + 1, results);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/* v8 ignore start -- depth>0 only happens for multi-part selectors (length>1), so the guard is always true; the false side is unreachable */
|
|
77
|
+
if (depth === 0 || selectors.length > 1) {
|
|
78
|
+
queryDescendantAll(child, selectors, depth, results);
|
|
79
|
+
}
|
|
80
|
+
/* v8 ignore stop */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TUIElement } from "./tuiElement.js";
|
|
2
|
+
/**
|
|
3
|
+
* Проверка структурных инвариантов дерева элементов. Обходит дерево от корня
|
|
4
|
+
* через `getChildren()` и находит состояния, которые «работают наполовину»:
|
|
5
|
+
* элемент рисуется, но не кликается; получает кадры, но не получает стили.
|
|
6
|
+
* Такие состояния — источник целого класса багов «элемент есть, но не
|
|
7
|
+
* показывается/не реагирует» (#204 и родня), и простыми тестами они не
|
|
8
|
+
* ловятся, потому что каждый отдельный механизм (render, hit-test, стили,
|
|
9
|
+
* фокус) отказывает молча.
|
|
10
|
+
*
|
|
11
|
+
* Инварианты:
|
|
12
|
+
* - **Симметрия parent**: каждый ребёнок из `getChildren()` указывает
|
|
13
|
+
* `getParent()` ровно на свой контейнер. Нарушение = забытый `setParent`
|
|
14
|
+
* (markDirty не доходит до корня, события не всплывают) или устаревшая
|
|
15
|
+
* ссылка после перецепления.
|
|
16
|
+
* - **Достижимость root**: `getRoot()` каждого узла равен корню обхода.
|
|
17
|
+
* Нарушение = элемент прикрепили до укоренения контейнера и нисходящая
|
|
18
|
+
* пропагация его не увидела — `focus()`/`open()` будут молча no-op.
|
|
19
|
+
* - **Единственность прикрепления**: узел встречается в дереве один раз
|
|
20
|
+
* (нет циклов, нет двух родителей, отдающих один элемент).
|
|
21
|
+
* - **Layout-контракт**: размер узла удовлетворяет constraints его последнего
|
|
22
|
+
* `layout()` (Н1, см. LAYOUT.md «Контракт performLayout»). Ловит мутации
|
|
23
|
+
* размера после layout без markDirty — сам layout() ассертит в моменте.
|
|
24
|
+
* Пропускаются узлы с `isLayoutDirty` (офскрин-строки виртуализирующего
|
|
25
|
+
* ListViewElement легитимно несут устаревший layout), скрытые (`hidden`)
|
|
26
|
+
* поддеревья целиком (их никто не раскладывал) и ни разу не разложенные.
|
|
27
|
+
* - **Вложенность**: ребёнок геометрически внутри родителя —
|
|
28
|
+
* `Rect(child) ⊆ Rect(parent)` (Н2, см. LAYOUT.md «Инвариант вложенности»).
|
|
29
|
+
* Исключений нет: «переполнение» (попапы, дропдауны) реализуется переносом в
|
|
30
|
+
* OverlayLayer, а не рисованием за границами. Пропуски те же, что у
|
|
31
|
+
* layout-контракта, плюс пары, где dirty сам родитель (его геометрия stale).
|
|
32
|
+
*
|
|
33
|
+
* Использование: в тестах — автоматически после каждого кадра
|
|
34
|
+
* (`TuiApplication.validateTreeAfterRender`, включает TestApp); в приложении —
|
|
35
|
+
* опционально через тот же флаг (env `VEXX_VALIDATE_TREE=1` в main).
|
|
36
|
+
*/
|
|
37
|
+
export declare function validateTree(root: TUIElement): string[];
|
|
38
|
+
/**
|
|
39
|
+
* Бросает с перечнем нарушений, если дерево невалидно. Вызывается после кадра
|
|
40
|
+
* при включённом `TuiApplication.validateTreeAfterRender`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function assertValidTree(root: TUIElement): void;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Rect } from "../common/geometryPromitives.js";
|
|
2
|
+
/**
|
|
3
|
+
* Проверка структурных инвариантов дерева элементов. Обходит дерево от корня
|
|
4
|
+
* через `getChildren()` и находит состояния, которые «работают наполовину»:
|
|
5
|
+
* элемент рисуется, но не кликается; получает кадры, но не получает стили.
|
|
6
|
+
* Такие состояния — источник целого класса багов «элемент есть, но не
|
|
7
|
+
* показывается/не реагирует» (#204 и родня), и простыми тестами они не
|
|
8
|
+
* ловятся, потому что каждый отдельный механизм (render, hit-test, стили,
|
|
9
|
+
* фокус) отказывает молча.
|
|
10
|
+
*
|
|
11
|
+
* Инварианты:
|
|
12
|
+
* - **Симметрия parent**: каждый ребёнок из `getChildren()` указывает
|
|
13
|
+
* `getParent()` ровно на свой контейнер. Нарушение = забытый `setParent`
|
|
14
|
+
* (markDirty не доходит до корня, события не всплывают) или устаревшая
|
|
15
|
+
* ссылка после перецепления.
|
|
16
|
+
* - **Достижимость root**: `getRoot()` каждого узла равен корню обхода.
|
|
17
|
+
* Нарушение = элемент прикрепили до укоренения контейнера и нисходящая
|
|
18
|
+
* пропагация его не увидела — `focus()`/`open()` будут молча no-op.
|
|
19
|
+
* - **Единственность прикрепления**: узел встречается в дереве один раз
|
|
20
|
+
* (нет циклов, нет двух родителей, отдающих один элемент).
|
|
21
|
+
* - **Layout-контракт**: размер узла удовлетворяет constraints его последнего
|
|
22
|
+
* `layout()` (Н1, см. LAYOUT.md «Контракт performLayout»). Ловит мутации
|
|
23
|
+
* размера после layout без markDirty — сам layout() ассертит в моменте.
|
|
24
|
+
* Пропускаются узлы с `isLayoutDirty` (офскрин-строки виртуализирующего
|
|
25
|
+
* ListViewElement легитимно несут устаревший layout), скрытые (`hidden`)
|
|
26
|
+
* поддеревья целиком (их никто не раскладывал) и ни разу не разложенные.
|
|
27
|
+
* - **Вложенность**: ребёнок геометрически внутри родителя —
|
|
28
|
+
* `Rect(child) ⊆ Rect(parent)` (Н2, см. LAYOUT.md «Инвариант вложенности»).
|
|
29
|
+
* Исключений нет: «переполнение» (попапы, дропдауны) реализуется переносом в
|
|
30
|
+
* OverlayLayer, а не рисованием за границами. Пропуски те же, что у
|
|
31
|
+
* layout-контракта, плюс пары, где dirty сам родитель (его геометрия stale).
|
|
32
|
+
*
|
|
33
|
+
* Использование: в тестах — автоматически после каждого кадра
|
|
34
|
+
* (`TuiApplication.validateTreeAfterRender`, включает TestApp); в приложении —
|
|
35
|
+
* опционально через тот же флаг (env `VEXX_VALIDATE_TREE=1` в main).
|
|
36
|
+
*/
|
|
37
|
+
export function validateTree(root) {
|
|
38
|
+
const violations = [];
|
|
39
|
+
const expectedRoot = root.getRoot();
|
|
40
|
+
if (expectedRoot !== root) {
|
|
41
|
+
violations.push(`корень обхода ${describe(root)} не считает себя корнем: getRoot() → ${describeOrNull(expectedRoot)}`);
|
|
42
|
+
}
|
|
43
|
+
checkLayoutContract(root, violations);
|
|
44
|
+
const visited = new Set();
|
|
45
|
+
// hiddenAncestor: внутри скрытого поддерева геометрия не проверяется — дети
|
|
46
|
+
// скрытого могут быть «чистыми» со stale-layout прошлых кадров.
|
|
47
|
+
const stack = [{ node: root, hiddenAncestor: root.hidden }];
|
|
48
|
+
visited.add(root);
|
|
49
|
+
while (stack.length > 0) {
|
|
50
|
+
const { node, hiddenAncestor } = stack.pop();
|
|
51
|
+
for (const child of node.getChildren()) {
|
|
52
|
+
if (visited.has(child)) {
|
|
53
|
+
violations.push(`${describe(child)} встречается в дереве дважды (второй раз — как ребёнок ${describe(node)})`);
|
|
54
|
+
continue; // не обходим поддерево второй раз
|
|
55
|
+
}
|
|
56
|
+
visited.add(child);
|
|
57
|
+
const parent = child.getParent();
|
|
58
|
+
if (parent !== node) {
|
|
59
|
+
violations.push(`${describe(child)} — ребёнок ${describe(node)} (по getChildren), но getParent() → ${describeOrNull(parent)}`);
|
|
60
|
+
}
|
|
61
|
+
const childRoot = child.getRoot();
|
|
62
|
+
if (childRoot !== root) {
|
|
63
|
+
violations.push(`${describe(child)} не укоренён: getRoot() → ${describeOrNull(childRoot)} (ожидался ${describe(root)})`);
|
|
64
|
+
}
|
|
65
|
+
const childHidden = hiddenAncestor || child.hidden;
|
|
66
|
+
if (!childHidden) {
|
|
67
|
+
checkLayoutContract(child, violations);
|
|
68
|
+
checkContainment(node, child, violations);
|
|
69
|
+
}
|
|
70
|
+
stack.push({ node: child, hiddenAncestor: childHidden });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return violations;
|
|
74
|
+
}
|
|
75
|
+
/** Проверка layout-контракта одного узла (пропуски описаны в docstring выше). */
|
|
76
|
+
function checkLayoutContract(node, violations) {
|
|
77
|
+
if (node.isLayoutDirty)
|
|
78
|
+
return;
|
|
79
|
+
const constraints = node.lastLayoutConstraints;
|
|
80
|
+
if (constraints === null)
|
|
81
|
+
return;
|
|
82
|
+
const size = node.layoutSize; // isLayoutDirty=false → lazy-layout не сработает
|
|
83
|
+
if (!constraints.isSatisfiedBy(size)) {
|
|
84
|
+
violations.push(`${describe(node)} нарушает layout-контракт: размер ${size.width}×${size.height} ` +
|
|
85
|
+
`не удовлетворяет constraints [${constraints.minWidth}..${constraints.maxWidth}]×` +
|
|
86
|
+
`[${constraints.minHeight}..${constraints.maxHeight}] последнего layout()`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Инвариант вложенности (Н2): ребёнок геометрически внутри родителя. */
|
|
90
|
+
function checkContainment(parent, child, violations) {
|
|
91
|
+
if (parent.isLayoutDirty || child.isLayoutDirty)
|
|
92
|
+
return;
|
|
93
|
+
if (parent.lastLayoutConstraints === null || child.lastLayoutConstraints === null)
|
|
94
|
+
return;
|
|
95
|
+
const parentRect = new Rect(parent.globalPosition, parent.layoutSize);
|
|
96
|
+
const childRect = new Rect(child.globalPosition, child.layoutSize);
|
|
97
|
+
const inside = childRect.x >= parentRect.x &&
|
|
98
|
+
childRect.y >= parentRect.y &&
|
|
99
|
+
childRect.right <= parentRect.right &&
|
|
100
|
+
childRect.bottom <= parentRect.bottom;
|
|
101
|
+
if (!inside) {
|
|
102
|
+
violations.push(`${describe(child)} вылезает за родителя ${describe(parent)}: ` +
|
|
103
|
+
`ребёнок [${childRect.x}..${childRect.right})×[${childRect.y}..${childRect.bottom}), ` +
|
|
104
|
+
`родитель [${parentRect.x}..${parentRect.right})×[${parentRect.y}..${parentRect.bottom})`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Бросает с перечнем нарушений, если дерево невалидно. Вызывается после кадра
|
|
109
|
+
* при включённом `TuiApplication.validateTreeAfterRender`.
|
|
110
|
+
*/
|
|
111
|
+
export function assertValidTree(root) {
|
|
112
|
+
const violations = validateTree(root);
|
|
113
|
+
if (violations.length > 0) {
|
|
114
|
+
throw new Error(`Дерево TUIDom нарушает инварианты (${violations.length}):\n- ${violations.join("\n- ")}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function describe(element) {
|
|
118
|
+
const name = element.constructor.name;
|
|
119
|
+
const id = element.id !== undefined ? `#${element.id}` : "";
|
|
120
|
+
const role = element.role !== undefined ? `[role=${element.role}]` : "";
|
|
121
|
+
return `${name}${id}${role}`;
|
|
122
|
+
}
|
|
123
|
+
function describeOrNull(element) {
|
|
124
|
+
return element === null ? "null" : describe(element);
|
|
125
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createKeyPressEvent } from "./keyEvent.js";
|
|
2
|
+
/**
|
|
3
|
+
* Map Kitty event type number to TUI event type string.
|
|
4
|
+
*
|
|
5
|
+
* Browser-aligned semantics: a held key fires repeated `keydown` events
|
|
6
|
+
* (with `repeat: true`), not `keypress`. We follow the same model so
|
|
7
|
+
* keydown listeners (e.g. the workbench for arrows/Backspace) auto-repeat.
|
|
8
|
+
*
|
|
9
|
+
* - 0 (not specified) → "keydown" (default is press per spec)
|
|
10
|
+
* - 1 (press) → "keydown"
|
|
11
|
+
* - 2 (repeat) → "keydown" (held key — also marked via the caller as a repeat)
|
|
12
|
+
* - 3 (release) → "keyup"
|
|
13
|
+
*/
|
|
14
|
+
function kittyEventType(eventType) {
|
|
15
|
+
switch (eventType) {
|
|
16
|
+
case 3:
|
|
17
|
+
return "keyup";
|
|
18
|
+
default:
|
|
19
|
+
return "keydown";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Физическая клавиша для control-кода: буквы дают `KeyA`…`KeyZ`, а пробел и
|
|
24
|
+
* символы из 0x1c–0x1f — свои `code` из UI Events, как в браузере (`Key\` и
|
|
25
|
+
* `Key6` там не существуют).
|
|
26
|
+
*/
|
|
27
|
+
function ctrlCharCode(letter) {
|
|
28
|
+
switch (letter) {
|
|
29
|
+
case " ":
|
|
30
|
+
return "Space";
|
|
31
|
+
case "\\":
|
|
32
|
+
return "Backslash";
|
|
33
|
+
case "]":
|
|
34
|
+
return "BracketRight";
|
|
35
|
+
case "6":
|
|
36
|
+
return "Digit6";
|
|
37
|
+
case "/":
|
|
38
|
+
return "Slash";
|
|
39
|
+
default:
|
|
40
|
+
return `Key${letter.toUpperCase()}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function convertTokenToKeyPressEvent(token) {
|
|
44
|
+
switch (token.kind) {
|
|
45
|
+
case "csi-u": {
|
|
46
|
+
let key = token.key;
|
|
47
|
+
if (token.shiftedKey !== undefined) {
|
|
48
|
+
key = String.fromCodePoint(token.shiftedKey);
|
|
49
|
+
}
|
|
50
|
+
else if (token.shiftKey && key.length === 1 && key !== key.toUpperCase()) {
|
|
51
|
+
key = key.toUpperCase();
|
|
52
|
+
}
|
|
53
|
+
return createKeyPressEvent(key, token.raw, {
|
|
54
|
+
type: kittyEventType(token.eventType),
|
|
55
|
+
code: token.code,
|
|
56
|
+
shiftKey: token.shiftKey,
|
|
57
|
+
altKey: token.altKey,
|
|
58
|
+
ctrlKey: token.ctrlKey,
|
|
59
|
+
metaKey: token.metaKey,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
case "csi-letter":
|
|
63
|
+
return createKeyPressEvent(token.key, token.raw, {
|
|
64
|
+
type: kittyEventType(token.eventType),
|
|
65
|
+
shiftKey: token.shiftKey,
|
|
66
|
+
altKey: token.altKey,
|
|
67
|
+
ctrlKey: token.ctrlKey,
|
|
68
|
+
metaKey: token.metaKey,
|
|
69
|
+
});
|
|
70
|
+
case "csi-tilde":
|
|
71
|
+
return createKeyPressEvent(token.key, token.raw, {
|
|
72
|
+
type: kittyEventType(token.eventType),
|
|
73
|
+
shiftKey: token.shiftKey,
|
|
74
|
+
altKey: token.altKey,
|
|
75
|
+
ctrlKey: token.ctrlKey,
|
|
76
|
+
metaKey: token.metaKey,
|
|
77
|
+
});
|
|
78
|
+
case "ss3":
|
|
79
|
+
return createKeyPressEvent(token.key, token.raw);
|
|
80
|
+
case "pua":
|
|
81
|
+
return createKeyPressEvent(token.key, token.raw, {
|
|
82
|
+
type: "keydown",
|
|
83
|
+
code: token.code,
|
|
84
|
+
});
|
|
85
|
+
case "esc-char":
|
|
86
|
+
return createKeyPressEvent(token.char, token.raw, { altKey: true });
|
|
87
|
+
case "esc-control":
|
|
88
|
+
return createKeyPressEvent(token.letter, token.raw, {
|
|
89
|
+
altKey: true,
|
|
90
|
+
ctrlKey: true,
|
|
91
|
+
code: `Key${token.letter.toUpperCase()}`,
|
|
92
|
+
});
|
|
93
|
+
case "esc-special":
|
|
94
|
+
return createKeyPressEvent(token.key, token.raw, { altKey: true });
|
|
95
|
+
case "standalone-esc":
|
|
96
|
+
return createKeyPressEvent("Escape", token.raw);
|
|
97
|
+
case "char":
|
|
98
|
+
return createKeyPressEvent(token.char, token.raw);
|
|
99
|
+
case "special-key":
|
|
100
|
+
return createKeyPressEvent(token.key, token.raw);
|
|
101
|
+
case "ctrl-char":
|
|
102
|
+
return createKeyPressEvent(token.letter, token.raw, {
|
|
103
|
+
ctrlKey: true,
|
|
104
|
+
code: ctrlCharCode(token.letter),
|
|
105
|
+
});
|
|
106
|
+
case "unknown-byte":
|
|
107
|
+
return createKeyPressEvent(`<0x${token.byte.toString(16).padStart(2, "0")}>`, token.raw);
|
|
108
|
+
/* v8 ignore next 4 -- exhaustiveness guard: RawKeyToken is a closed union, no other kind is constructible */
|
|
109
|
+
default: {
|
|
110
|
+
const exhaustive = token;
|
|
111
|
+
throw new Error(`Unhandled token kind: ${exhaustive.kind}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI event types — web-style keyboard events with individual modifier flags.
|
|
3
|
+
*
|
|
4
|
+
* KeyPressEvent follows the DOM KeyboardEvent naming conventions:
|
|
5
|
+
* - `key` — the value produced by the key ("a", "Enter", "ArrowUp")
|
|
6
|
+
* - `code` — the physical key identifier ("KeyA", "Enter", "ArrowUp")
|
|
7
|
+
* - Boolean modifier flags: ctrlKey, shiftKey, altKey, metaKey
|
|
8
|
+
*
|
|
9
|
+
* Discriminated union `TUIEvent` is extensible for future event types (click, focus, etc.)
|
|
10
|
+
*/
|
|
11
|
+
export interface KeyPressEvent {
|
|
12
|
+
/**
|
|
13
|
+
* Event type, following DOM KeyboardEvent naming:
|
|
14
|
+
* - "keypress" — synthesized companion event for printable input (legacy)
|
|
15
|
+
* - "keydown" — key press or auto-repeat (Kitty event type 1 or 2)
|
|
16
|
+
* - "keyup" — key release (Kitty event type 3)
|
|
17
|
+
*/
|
|
18
|
+
readonly type: "keypress" | "keydown" | "keyup";
|
|
19
|
+
/**
|
|
20
|
+
* Key value, following the DOM KeyboardEvent.key naming convention.
|
|
21
|
+
*
|
|
22
|
+
* For printable characters: the character itself ("a", "A", "1", "!")
|
|
23
|
+
* For Ctrl+letter: just the lowercase letter ("c", not "Ctrl+C")
|
|
24
|
+
* For special keys: DOM name ("Enter", "ArrowUp", "F1", "Escape", etc.)
|
|
25
|
+
* For modifier keys: "Shift", "Control", "Alt", "Meta" (Kitty protocol)
|
|
26
|
+
*/
|
|
27
|
+
readonly key: string;
|
|
28
|
+
/**
|
|
29
|
+
* Physical key code, following the DOM KeyboardEvent.code naming convention.
|
|
30
|
+
*
|
|
31
|
+
* "KeyA", "Digit1", "Space", "Enter", "ArrowUp", "F1", etc.
|
|
32
|
+
* For modifier keys: "ShiftLeft", "ControlLeft", "AltLeft", "MetaLeft", etc.
|
|
33
|
+
*/
|
|
34
|
+
readonly code: string;
|
|
35
|
+
readonly ctrlKey: boolean;
|
|
36
|
+
readonly shiftKey: boolean;
|
|
37
|
+
readonly altKey: boolean;
|
|
38
|
+
readonly metaKey: boolean;
|
|
39
|
+
/** Original raw bytes from the terminal (for debugging) */
|
|
40
|
+
readonly raw: string;
|
|
41
|
+
}
|
|
42
|
+
/** Discriminated union of all TUI events (extensible for click, focus, etc.) */
|
|
43
|
+
export type TUIEvent = KeyPressEvent;
|
|
44
|
+
/** Helper to create a KeyPressEvent with sensible defaults */
|
|
45
|
+
export declare function createKeyPressEvent(key: string, raw: string, overrides?: Partial<KeyPressEvent>): KeyPressEvent;
|
|
46
|
+
export { inferCode } from "./tokenize.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI event types — web-style keyboard events with individual modifier flags.
|
|
3
|
+
*
|
|
4
|
+
* KeyPressEvent follows the DOM KeyboardEvent naming conventions:
|
|
5
|
+
* - `key` — the value produced by the key ("a", "Enter", "ArrowUp")
|
|
6
|
+
* - `code` — the physical key identifier ("KeyA", "Enter", "ArrowUp")
|
|
7
|
+
* - Boolean modifier flags: ctrlKey, shiftKey, altKey, metaKey
|
|
8
|
+
*
|
|
9
|
+
* Discriminated union `TUIEvent` is extensible for future event types (click, focus, etc.)
|
|
10
|
+
*/
|
|
11
|
+
import { inferCode } from "./tokenize.js";
|
|
12
|
+
/** Helper to create a KeyPressEvent with sensible defaults */
|
|
13
|
+
export function createKeyPressEvent(key, raw, overrides) {
|
|
14
|
+
return {
|
|
15
|
+
type: overrides?.type ?? "keydown",
|
|
16
|
+
key,
|
|
17
|
+
code: overrides?.code ?? inferCode(key),
|
|
18
|
+
ctrlKey: overrides?.ctrlKey ?? false,
|
|
19
|
+
shiftKey: overrides?.shiftKey ?? false,
|
|
20
|
+
altKey: overrides?.altKey ?? false,
|
|
21
|
+
metaKey: overrides?.metaKey ?? false,
|
|
22
|
+
raw,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// inferCode re-exported from TokenParsing layer
|
|
26
|
+
export { inferCode } from "./tokenize.js";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type KeyPressEvent } from "./keyEvent.js";
|
|
2
|
+
import type { DeviceReportToken, MouseToken, OscToken } from "./rawTerminalToken.js";
|
|
3
|
+
/**
|
|
4
|
+
* Stateful keyboard input parser — browser-like KeyboardEvent model.
|
|
5
|
+
*
|
|
6
|
+
* Normalizes both legacy terminal input and Kitty protocol into a uniform
|
|
7
|
+
* event sequence matching the DOM KeyboardEvent spec:
|
|
8
|
+
*
|
|
9
|
+
* Normal keys:
|
|
10
|
+
* keydown → keypress (legacy: no keyup since protocol doesn't send it)
|
|
11
|
+
* keydown → keypress → keyup (Kitty: full lifecycle)
|
|
12
|
+
* keydown → keypress → keydown → keypress → ... → keyup (Kitty hold/auto-repeat)
|
|
13
|
+
*
|
|
14
|
+
* Modifier-only keys (Shift, Ctrl, Alt, Meta):
|
|
15
|
+
* keydown → keyup (no keypress — same as browser)
|
|
16
|
+
*
|
|
17
|
+
* Orphaned keyup (macOS Cmd+Arrow — release without prior press):
|
|
18
|
+
* synthesizes keydown + keypress before the keyup
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* const parser = new KeyInputParser();
|
|
22
|
+
* stdin.on("data", (chunk) => {
|
|
23
|
+
* const events = parser.parse(chunk);
|
|
24
|
+
* });
|
|
25
|
+
*/
|
|
26
|
+
interface InputStreams {
|
|
27
|
+
keys: KeyPressEvent[];
|
|
28
|
+
mouse: MouseToken[];
|
|
29
|
+
osc: OscToken[];
|
|
30
|
+
deviceReports: DeviceReportToken[];
|
|
31
|
+
/** Text blocks delivered via bracketed paste (already newline-normalized). */
|
|
32
|
+
paste: string[];
|
|
33
|
+
}
|
|
34
|
+
export declare class KeyInputParser {
|
|
35
|
+
private readonly pressedKeys;
|
|
36
|
+
/** Tail of the previous chunk that was cut mid-escape-sequence; "" when none. */
|
|
37
|
+
private pending;
|
|
38
|
+
/** True while accumulating bracketed-paste content (between ESC[200~ and ESC[201~). */
|
|
39
|
+
private pasting;
|
|
40
|
+
/** Pasted text accumulated so far, awaiting the ESC[201~ end marker. */
|
|
41
|
+
private pasteBuffer;
|
|
42
|
+
/**
|
|
43
|
+
* Parse a chunk of raw terminal input into browser-like keyboard events.
|
|
44
|
+
*/
|
|
45
|
+
parse(data: string): KeyPressEvent[];
|
|
46
|
+
/**
|
|
47
|
+
* Parse raw terminal input, returning both keyboard events and mouse tokens.
|
|
48
|
+
*/
|
|
49
|
+
parseWithMouse(data: string): InputStreams;
|
|
50
|
+
/** True when a partial escape sequence is buffered, awaiting the rest of its bytes. */
|
|
51
|
+
hasPending(): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Force-process any buffered partial sequence as-is (a lone ESC becomes the
|
|
54
|
+
* Escape key). Callers use a short timeout so a real Escape keypress isn't
|
|
55
|
+
* held hostage waiting for a continuation that never comes.
|
|
56
|
+
*
|
|
57
|
+
* While a paste is in flight (ESC[200~ seen, ESC[201~ not yet), this is a no-op:
|
|
58
|
+
* the held tail is a split end marker, not a stuck escape — keep accumulating so
|
|
59
|
+
* the paste isn't corrupted or truncated.
|
|
60
|
+
*/
|
|
61
|
+
flush(): InputStreams;
|
|
62
|
+
/**
|
|
63
|
+
* Reassemble the stream across reads, splitting out bracketed-paste blocks so
|
|
64
|
+
* their literal text bypasses key tokenization entirely. Normal segments keep the
|
|
65
|
+
* existing behavior: prepend any buffered tail, then hold back a fresh incomplete
|
|
66
|
+
* tail so an escape sequence cut across stdin reads is reassembled next chunk.
|
|
67
|
+
*/
|
|
68
|
+
private ingest;
|
|
69
|
+
private tokenizeInto;
|
|
70
|
+
private processKeyEvents;
|
|
71
|
+
}
|
|
72
|
+
export {};
|