@tuidom/core 0.2.1 → 0.3.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.
@@ -27,6 +27,16 @@ export interface ITerminalBackend {
27
27
  * reply within the probe window). Fire-and-forget — callers must not block on it.
28
28
  */
29
29
  probeKeyboardProtocol(onResult: (supported: boolean) => void): void;
30
+ /**
31
+ * Asynchronously ask the terminal for its name and version (XTVERSION: `CSI > 0 q`
32
+ * → `DCS > | <name(version)> ST`). `onResult` fires exactly once with the reported
33
+ * string as-is (e.g. `"iTerm2 3.5.0"`, `"kitty(0.35.2)"`, `"WezTerm 20240203…"`), or
34
+ * `undefined` if the terminal doesn't answer within the probe window. The reply is
35
+ * consumed by the backend and never reaches `onInput`. Inside tmux the query goes
36
+ * to tmux itself (like the other probes), so the answer is tmux's own (`"tmux 3.4"`).
37
+ * Fire-and-forget — callers must not block on it.
38
+ */
39
+ probeTerminalVersion(onResult: (nameAndVersion: string | undefined) => void): void;
30
40
  /**
31
41
  * Render a frame: receive the current grid and cursor position.
32
42
  * The backend decides how to output it (ANSI diffing, simple copy, etc.).
@@ -158,8 +158,14 @@ export class TuiApplication {
158
158
  target.dispatchEvent(contextMenuEvent);
159
159
  }
160
160
  }
161
- // Tab focus cycling (default behavior if not prevented, only on keydown)
162
- if (notPrevented && event.key === "Tab" && event.type === "keydown" && this.focusManager) {
161
+ // Tab focus cycling (default behavior if not prevented, only on keydown).
162
+ // Обход фокуса — только у голого Tab и Shift+Tab: Ctrl+Tab, Alt+Tab и Meta+Tab
163
+ // это отдельные аккорды, и приложение вправе повесить на них своё. Если бы
164
+ // мы крутили кольцо и на них, то нерезолвнувшийся аккорд приложения (его
165
+ // `when` не прошёл — значит preventDefault никто не позвал) уводил бы фокус
166
+ // на случайный элемент. Shift здесь не модификатор аккорда, а направление.
167
+ const plainTab = !event.ctrlKey && !event.altKey && !event.metaKey;
168
+ if (notPrevented && event.key === "Tab" && event.type === "keydown" && plainTab && this.focusManager) {
163
169
  const direction = event.shiftKey ? "backward" : "forward";
164
170
  this.focusManager.cycleFocus(direction);
165
171
  }
@@ -379,7 +379,9 @@ export class TUIElement {
379
379
  /** Гасит фокус, если activeElement — этот элемент или его потомок. */
380
380
  releaseFocusIfInside() {
381
381
  const fm = this.getRoot()?.focusManager ?? null;
382
- let node = fm?.activeElement ?? null;
382
+ if (fm === null)
383
+ return;
384
+ let node = fm.activeElement ?? null;
383
385
  while (node !== null && node !== this) {
384
386
  node = node.getParent();
385
387
  }
@@ -731,6 +733,7 @@ export class TUIElement {
731
733
  * состояние нужно ДО стилевого прохода, прямо в performLayout.
732
734
  */
733
735
  hasStyleStateWithin(state) {
736
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
734
737
  let node = this;
735
738
  while (node !== null) {
736
739
  if (node.styleStatesSet?.has(state) === true)
@@ -769,6 +772,7 @@ export class TUIElement {
769
772
  // цепочка предков (иначе любой markDirty = полноэкранный damage).
770
773
  this.isPaintDirty = true;
771
774
  this.isLayoutDirty = true;
775
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
772
776
  let top = this;
773
777
  for (let current = this._parent; current !== null; current = current._parent) {
774
778
  current.isLayoutDirty = true;
@@ -802,7 +806,11 @@ export class TUIElement {
802
806
  }
803
807
  const rect = new Rect(new Point(parentOrigin.x + this.localPosition.dx, parentOrigin.y + this.localPosition.dy), this.allocatedSize);
804
808
  const old = this.lastPaintedRect;
805
- const moved = old === null ||
809
+ // Ещё не рисовался — тоже moved. Optional chain здесь не подходит: после
810
+ // первого `old?.x` тип уже сужен, и остальные `old?.` стали бы лишними.
811
+ const moved =
812
+ // eslint-disable-next-line @typescript-eslint/prefer-optional-chain
813
+ old === null ||
806
814
  old.x !== rect.x ||
807
815
  old.y !== rect.y ||
808
816
  old.width !== rect.width ||
@@ -835,6 +843,9 @@ export class TUIElement {
835
843
  * собственный хром по состоянию ребёнка вне его rect'а (ScrollBarDecorator:
836
844
  * бегунок в колонке за пределами ребёнка).
837
845
  */
846
+ // Именно геттер, не readonly-поле: подклассы переопределяют его геттером
847
+ // (ListViewElement, ScrollContainerElement), а поле базы затенило бы их прототипный аксессор.
848
+ // eslint-disable-next-line @typescript-eslint/class-literal-property-style
838
849
  get paintsSubtreeAtomically() {
839
850
  return false;
840
851
  }
@@ -1,6 +1,6 @@
1
1
  import { convertTokenToKeyPressEvent } from "./convertToken.js";
2
2
  import { createKeyPressEvent } from "./keyEvent.js";
3
- import { parseCSI, parseOSC, tokenize } from "./tokenize.js";
3
+ import { parseCSI, parseOSC, parseXtversionReply, tokenize, XTVERSION_REPLY_PREFIX } from "./tokenize.js";
4
4
  /**
5
5
  * Index where an *incomplete* trailing escape sequence begins, or -1 if `data`
6
6
  * ends on a clean token boundary.
@@ -16,6 +16,12 @@ import { parseCSI, parseOSC, tokenize } from "./tokenize.js";
16
16
  * incomplete — anything before it is already followed by more bytes.
17
17
  */
18
18
  function incompleteTailStart(data) {
19
+ // Ответ XTVERSION (`ESC P > | … ESC \\`) — единственная строка, внутри которой
20
+ // может стоять «последний» ESC (от ST, разрезанного между чтениями), поэтому тело
21
+ // без терминатора проверяем раньше, чем ищем последний ESC.
22
+ const dcs = data.lastIndexOf(XTVERSION_REPLY_PREFIX);
23
+ if (dcs !== -1 && parseXtversionReply(data, dcs) === null)
24
+ return dcs;
19
25
  const esc = data.lastIndexOf("\x1b");
20
26
  if (esc === -1)
21
27
  return -1;
@@ -30,6 +36,10 @@ function incompleteTailStart(data) {
30
36
  return parseOSC(data, esc) === null ? esc : -1; // ESC ]
31
37
  if (next === 0x4f)
32
38
  return esc + 2 >= data.length ? esc : -1; // ESC O (SS3) needs its final letter
39
+ // ESC P: незавершённое начало ответа XTVERSION (`ESC P >`) ждём. Хвост, уже
40
+ // разошедшийся с префиксом, — обычный Alt+Shift+P (ESC + символ), он завершён.
41
+ if (next === 0x50)
42
+ return XTVERSION_REPLY_PREFIX.startsWith(data.slice(esc)) ? esc : -1;
33
43
  // ESC + printable / control / special is complete once the second byte is present (it is).
34
44
  return -1;
35
45
  }
@@ -111,18 +111,24 @@ export interface OscToken {
111
111
  readonly raw: string;
112
112
  }
113
113
  /**
114
- * A private-mode CSI response to one of our capability probes — NOT a key event.
114
+ * A response to one of our capability probes — NOT a key event.
115
115
  * - "kitty-flags": reply to the Kitty keyboard-protocol query `CSI ? u` → `CSI ? <flags> u`.
116
116
  * - "da1": reply to Primary Device Attributes `CSI c` → `CSI ? <attrs> c`.
117
- * These begin with the private marker `?`, which never appears in a real key event.
117
+ * - "xtversion": reply to XTVERSION `CSI > 0 q` → `DCS > | <name(version)> ST`.
118
+ * The CSI replies begin with the private marker `?`, which never appears in a real key
119
+ * event; the XTVERSION reply is a DCS string (`ESC P > |`), not a key either.
118
120
  */
119
121
  export interface DeviceReportToken {
120
122
  readonly kind: "device-report";
121
- readonly report: "kitty-flags" | "da1";
122
- /** Raw parameter bytes including the leading `?` (e.g. "?15" or "?62;1;6"). */
123
+ readonly report: DeviceReportKind;
124
+ /**
125
+ * For CSI replies — raw parameter bytes including the leading `?` (e.g. "?15" or
126
+ * "?62;1;6"). For "xtversion" — the payload between `DCS > |` and ST (e.g. "iTerm2 3.5.0").
127
+ */
123
128
  readonly params: string;
124
129
  readonly raw: string;
125
130
  }
131
+ export type DeviceReportKind = "kitty-flags" | "da1" | "xtversion";
126
132
  export type MouseButton = "left" | "middle" | "right" | "none";
127
133
  export type MouseAction = "press" | "release" | "move" | "scroll-up" | "scroll-down" | "scroll-left" | "scroll-right";
128
134
  export interface MouseToken {
@@ -8,7 +8,10 @@
8
8
  * serializeKey('Ctrl+ArrowUp') → '\x1b[1;5A'
9
9
  * serializeKey('F5') → '\x1b[15~'
10
10
  * serializeKey('Alt+a') → '\x1ba'
11
+ * serializeKey('Meta+s') → '\x1b[115;9u'
12
+ * serializeKey('Ctrl+Shift+a') → '\x1b[97;6u'
11
13
  *
12
14
  * Supports modifier prefixes: Ctrl+, Shift+, Alt+, Meta+ (combinable).
15
+ * Meta — это super-бит kitty (8): так приходит Cmd на маке.
13
16
  */
14
17
  export declare function serializeKey(name: string): string;
@@ -8,9 +8,13 @@
8
8
  * serializeKey('Ctrl+ArrowUp') → '\x1b[1;5A'
9
9
  * serializeKey('F5') → '\x1b[15~'
10
10
  * serializeKey('Alt+a') → '\x1ba'
11
+ * serializeKey('Meta+s') → '\x1b[115;9u'
12
+ * serializeKey('Ctrl+Shift+a') → '\x1b[97;6u'
11
13
  *
12
14
  * Supports modifier prefixes: Ctrl+, Shift+, Alt+, Meta+ (combinable).
15
+ * Meta — это super-бит kitty (8): так приходит Cmd на маке.
13
16
  */
17
+ import { kittyCodepointMap } from "./tokenize.js";
14
18
  /** Simple special keys (no modifiers, no CSI) */
15
19
  const simpleSpecialKeys = {
16
20
  Enter: "\x0d",
@@ -73,6 +77,24 @@ function encodeModifier(ctrl, shift, alt, meta) {
73
77
  mod += 8;
74
78
  return mod;
75
79
  }
80
+ /**
81
+ * Codepoint для общей CSI u-формы, если `ch` — один печатный символ, который
82
+ * parseInput вернёт как есть: не управляющий и не занятый kitty под
83
+ * функциональную клавишу (PUA). Буква сворачивается в нижний регистр — так
84
+ * kitty шлёт базовую клавишу; регистр в событии даёт Shift-бит.
85
+ */
86
+ function printableCodepoint(ch) {
87
+ const lower = ch.toLowerCase();
88
+ const codepoint = lower.codePointAt(0);
89
+ // Пусто («Meta+»), имя клавиши («Foo») или буква, чей lowercase — не один символ («İ»).
90
+ if (codepoint === undefined || String.fromCodePoint(codepoint) !== lower)
91
+ return undefined;
92
+ if (codepoint < 0x20 || (codepoint >= 0x7f && codepoint <= 0x9f))
93
+ return undefined;
94
+ if (kittyCodepointMap[codepoint] !== undefined)
95
+ return undefined;
96
+ return codepoint;
97
+ }
76
98
  export function serializeKey(name) {
77
99
  // Parse modifier prefixes: "Ctrl+Shift+ArrowUp" → modifiers + "ArrowUp"
78
100
  let ctrl = false;
@@ -175,5 +197,15 @@ export function serializeKey(name) {
175
197
  if (!hasModifiers && remaining.length === 1) {
176
198
  return remaining;
177
199
  }
200
+ // Печатный символ с любым другим набором модификаторов — общая форма kitty
201
+ // CSI <codepoint>;<mod>u. Сюда доходят только комбинации, у которых нет
202
+ // legacy-кодировки (ветки выше их уже вернули): Meta+s, Ctrl+Shift+a, Shift+1…
203
+ // Как и kitty, шлём codepoint базовой клавиши: буква — в нижнем регистре, а
204
+ // регистр задаёт только Shift-префикс (Meta+S ≡ Meta+s, как Ctrl+A ≡ Ctrl+a).
205
+ const codepoint = printableCodepoint(remaining === "Space" ? " " : remaining);
206
+ if (codepoint !== undefined) {
207
+ const mod = encodeModifier(ctrl, shift, alt, meta);
208
+ return `\x1b[${codepoint.toString()};${mod.toString()}u`;
209
+ }
178
210
  throw new Error(`serializeKey: unknown key name "${name}". Add it to the mapping.`);
179
211
  }
@@ -56,4 +56,17 @@ interface OSCTokenResult {
56
56
  * Returns null if the sequence is not complete.
57
57
  */
58
58
  export declare function parseOSC(data: string, start: number): OSCTokenResult | null;
59
+ /**
60
+ * Начало ответа на XTVERSION (`CSI > 0 q`): `DCS > |`. Разбираем только его: голое
61
+ * `ESC P` — это ещё и Alt+Shift+P legacy-терминала, так что произвольный DCS не ловим.
62
+ */
63
+ export declare const XTVERSION_REPLY_PREFIX = "\u001BP>|";
64
+ /**
65
+ * Parse an XTVERSION reply `ESC P > | <text> ESC \` starting at data[start] = ESC.
66
+ * Returns null if the string terminator (ST) hasn't arrived yet.
67
+ */
68
+ export declare function parseXtversionReply(data: string, start: number): {
69
+ token: DeviceReportToken;
70
+ nextIndex: number;
71
+ } | null;
59
72
  export {};
@@ -57,6 +57,18 @@ export function tokenize(data) {
57
57
  tokens.push({ kind: "standalone-esc", raw: data[i] });
58
58
  i++;
59
59
  }
60
+ else if (next === 0x50 && data.startsWith(XTVERSION_REPLY_PREFIX, i)) {
61
+ // DCS-ответ на XTVERSION: \x1bP>|<name(version)>\x1b\\. Без ST (обрезан, и
62
+ // KeyInputParser его уже не ждёт) — падаем в ESC+символ, как было до разбора DCS.
63
+ const dcsResult = parseXtversionReply(data, i);
64
+ if (dcsResult) {
65
+ tokens.push(dcsResult.token);
66
+ i = dcsResult.nextIndex;
67
+ continue;
68
+ }
69
+ tokens.push({ kind: "esc-char", char: data[i + 1], charCode: next, raw: data.slice(i, i + 2) });
70
+ i += 2;
71
+ }
60
72
  else if (next === 0x4f) {
61
73
  // SS3 sequence: \x1bO<letter>
62
74
  if (i + 2 < data.length) {
@@ -679,3 +691,29 @@ function buildOscResult(body, raw, nextIndex) {
679
691
  nextIndex,
680
692
  };
681
693
  }
694
+ // ─── DCS: XTVERSION reply ───
695
+ /**
696
+ * Начало ответа на XTVERSION (`CSI > 0 q`): `DCS > |`. Разбираем только его: голое
697
+ * `ESC P` — это ещё и Alt+Shift+P legacy-терминала, так что произвольный DCS не ловим.
698
+ */
699
+ export const XTVERSION_REPLY_PREFIX = "\x1bP>|";
700
+ /**
701
+ * Parse an XTVERSION reply `ESC P > | <text> ESC \` starting at data[start] = ESC.
702
+ * Returns null if the string terminator (ST) hasn't arrived yet.
703
+ */
704
+ export function parseXtversionReply(data, start) {
705
+ const bodyStart = start + XTVERSION_REPLY_PREFIX.length;
706
+ const end = data.indexOf("\x1b\\", bodyStart);
707
+ if (end === -1)
708
+ return null;
709
+ const nextIndex = end + 2;
710
+ return {
711
+ token: {
712
+ kind: "device-report",
713
+ report: "xtversion",
714
+ params: data.slice(bodyStart, end),
715
+ raw: data.slice(start, nextIndex),
716
+ },
717
+ nextIndex,
718
+ };
719
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuidom/core",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Experimental terminal UI DOM — core: element tree, flex layout, grid rendering, input parsing. API is unstable.",
6
6
  "license": "MIT",