@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,1168 @@
|
|
|
1
|
+
import { DEFAULT_COLOR } from "../common/colorUtils.js";
|
|
2
|
+
import { DisplayLine } from "../common/displayLine.js";
|
|
3
|
+
import { BoxConstraints, Offset, Point, Rect, Size } from "../common/geometryPromitives.js";
|
|
4
|
+
import { TerminalScreen } from "../rendering/terminalScreen.js";
|
|
5
|
+
import { BORDER_ROUNDED } from "./borderStyle.js";
|
|
6
|
+
import { EventPhase, TUIEventBase } from "./events/tuiEventBase.js";
|
|
7
|
+
import { TUIKeyboardEvent } from "./events/tuiKeyboardEvent.js";
|
|
8
|
+
import { ROOT_VAR_SCOPE } from "./styles/styleTokens.js";
|
|
9
|
+
import { extendVarScope, mergeStyleVariants, resolveStyleColor, ROOT_RESOLVED_STYLE, ROOT_STYLE_CONTEXT, styleEquals, } from "./styles/tuiStyle.js";
|
|
10
|
+
import { querySelector, querySelectorAll } from "./tuiSelector.js";
|
|
11
|
+
const MAX_COORD = 100_000;
|
|
12
|
+
const INFINITE_CLIP = new Rect(new Point(0, 0), new Size(MAX_COORD, MAX_COORD));
|
|
13
|
+
export class RenderContext {
|
|
14
|
+
canvas;
|
|
15
|
+
offset;
|
|
16
|
+
clipRect;
|
|
17
|
+
constructor(canvas, offset = new Offset(0, 0), clipRect = INFINITE_CLIP) {
|
|
18
|
+
this.canvas = canvas;
|
|
19
|
+
this.offset = offset;
|
|
20
|
+
this.clipRect = clipRect;
|
|
21
|
+
}
|
|
22
|
+
withOffset(extra) {
|
|
23
|
+
return new RenderContext(this.canvas, new Offset(this.offset.dx + extra.dx, this.offset.dy + extra.dy), this.clipRect);
|
|
24
|
+
}
|
|
25
|
+
withClip(rect) {
|
|
26
|
+
return new RenderContext(this.canvas, this.offset, this.clipRect.intersect(rect));
|
|
27
|
+
}
|
|
28
|
+
setCell(x, y, cell) {
|
|
29
|
+
const screenX = x + this.offset.dx;
|
|
30
|
+
const screenY = y + this.offset.dy;
|
|
31
|
+
if (!this.clipRect.containsPoint(new Point(screenX, screenY)))
|
|
32
|
+
return;
|
|
33
|
+
this.canvas.setCell(new Point(screenX, screenY), cell);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Читает уже отрисованную ячейку в локальных координатах (тот же оффсет и
|
|
37
|
+
* клип, что у {@link setCell}). Возвращает null вне клипа или вне экрана.
|
|
38
|
+
* Нужен пост-обработке вроде оверлея выделения в списках: прочитать ячейку,
|
|
39
|
+
* решить по её текущим цветам и патчнуть через {@link setCell}.
|
|
40
|
+
*/
|
|
41
|
+
getCell(x, y) {
|
|
42
|
+
const screenX = x + this.offset.dx;
|
|
43
|
+
const screenY = y + this.offset.dy;
|
|
44
|
+
if (!this.clipRect.containsPoint(new Point(screenX, screenY)))
|
|
45
|
+
return null;
|
|
46
|
+
if (screenX < 0 || screenY < 0 || screenX >= this.canvas.width || screenY >= this.canvas.height)
|
|
47
|
+
return null;
|
|
48
|
+
return this.canvas.getCell(new Point(screenX, screenY));
|
|
49
|
+
}
|
|
50
|
+
setCursorPosition(x, y) {
|
|
51
|
+
const screenX = x + this.offset.dx;
|
|
52
|
+
const screenY = y + this.offset.dy;
|
|
53
|
+
if (!this.clipRect.containsPoint(new Point(screenX, screenY)))
|
|
54
|
+
return;
|
|
55
|
+
this.canvas.setCursorPosition(new Point(screenX, screenY));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Render a text string at (x, y), handling wide chars, tabs, combining marks and emoji.
|
|
59
|
+
* Each column within [startCol, startCol + maxWidth) is rendered.
|
|
60
|
+
*
|
|
61
|
+
* @param x Left screen column (local coordinates)
|
|
62
|
+
* @param y Screen row (local coordinates)
|
|
63
|
+
* @param text Raw text to render
|
|
64
|
+
* @param style Optional cell style (fg, bg, style flags) applied to every cell
|
|
65
|
+
* @param options tabSize (default 4) and maxWidth (default: no limit);
|
|
66
|
+
* displayLine — готовый DisplayLine, чтобы не сегментировать text заново
|
|
67
|
+
* на каждом кадре; вызывающий гарантирует, что он построен из тех же
|
|
68
|
+
* text/tabSize
|
|
69
|
+
* @returns Number of display columns written
|
|
70
|
+
*/
|
|
71
|
+
drawText(x, y, text, style, options) {
|
|
72
|
+
const dl = options?.displayLine ?? new DisplayLine(text, options?.tabSize);
|
|
73
|
+
const maxWidth = options?.maxWidth ?? dl.displayWidth;
|
|
74
|
+
let col = 0;
|
|
75
|
+
while (col < maxWidth) {
|
|
76
|
+
const char = dl.charAtColumn(col);
|
|
77
|
+
/* v8 ignore start -- unreachable: the loop always advances col past a wide char's continuation cell, so charAtColumn never returns "" here */
|
|
78
|
+
if (char === "") {
|
|
79
|
+
col++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
/* v8 ignore stop */
|
|
83
|
+
const slot = dl.graphemeAtColumn(col);
|
|
84
|
+
const w = slot ? slot.displayWidth : 1;
|
|
85
|
+
const slotStyle = slot && options?.getStyle ? options.getStyle(slot.offset) : undefined;
|
|
86
|
+
const resolvedStyle = slotStyle !== undefined ? { ...style, ...slotStyle } : style;
|
|
87
|
+
if (w === 2 && col + 1 >= maxWidth) {
|
|
88
|
+
this.setCell(x + col, y, { char: " ", width: 1, ...resolvedStyle });
|
|
89
|
+
col++;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
this.setCell(x + col, y, { char, width: w, ...resolvedStyle });
|
|
93
|
+
col += w;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return col;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Отрисовывает прямоугольную рамку box-drawing глифами — единый хелпер для
|
|
100
|
+
* всех бордер-виджетов (см. {@link BorderStyle}). Рисует углы, верхнюю/нижнюю
|
|
101
|
+
* горизонтали и боковые вертикали; строки из `separators` (offset от верха
|
|
102
|
+
* рамки, 1-based относительно `y`) рисуются как T-коннекторы `├───┤`.
|
|
103
|
+
*
|
|
104
|
+
* Координаты локальные (как у {@link drawText}). Клиппинг/оффсет применяются
|
|
105
|
+
* через {@link setCell}.
|
|
106
|
+
*
|
|
107
|
+
* @param x Левый столбец рамки
|
|
108
|
+
* @param y Верхняя строка рамки
|
|
109
|
+
* @param width Ширина рамки в столбцах (>= 2)
|
|
110
|
+
* @param height Высота рамки в строках (>= 2)
|
|
111
|
+
* @param options fg/bg, пресет `style` (по умолчанию {@link BORDER_ROUNDED} —
|
|
112
|
+
* канонический стиль оверлеев Vexx), `fill` (залить фон внутри
|
|
113
|
+
* рамки), `separators` (ряды-разделители)
|
|
114
|
+
*/
|
|
115
|
+
drawBox(x, y, width, height, options = {}) {
|
|
116
|
+
const style = options.style ?? BORDER_ROUNDED;
|
|
117
|
+
const fg = options.fg;
|
|
118
|
+
const bg = options.bg;
|
|
119
|
+
const right = x + width - 1;
|
|
120
|
+
const bottom = y + height - 1;
|
|
121
|
+
if (options.fill === true) {
|
|
122
|
+
for (let yy = y; yy <= bottom; yy++) {
|
|
123
|
+
for (let xx = x; xx <= right; xx++) {
|
|
124
|
+
this.setCell(xx, yy, { char: " ", fg, bg });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const separators = new Set(options.separators);
|
|
129
|
+
// Top border.
|
|
130
|
+
this.setCell(x, y, { char: style.topLeft, fg, bg });
|
|
131
|
+
this.setCell(right, y, { char: style.topRight, fg, bg });
|
|
132
|
+
for (let xx = x + 1; xx < right; xx++)
|
|
133
|
+
this.setCell(xx, y, { char: style.horizontal, fg, bg });
|
|
134
|
+
// Side borders (+ separator T-connectors).
|
|
135
|
+
for (let yy = y + 1; yy < bottom; yy++) {
|
|
136
|
+
if (separators.has(yy - y)) {
|
|
137
|
+
this.setCell(x, yy, { char: style.leftJoint, fg, bg });
|
|
138
|
+
this.setCell(right, yy, { char: style.rightJoint, fg, bg });
|
|
139
|
+
for (let xx = x + 1; xx < right; xx++)
|
|
140
|
+
this.setCell(xx, yy, { char: style.horizontal, fg, bg });
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
this.setCell(x, yy, { char: style.vertical, fg, bg });
|
|
144
|
+
this.setCell(right, yy, { char: style.vertical, fg, bg });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Bottom border.
|
|
148
|
+
this.setCell(x, bottom, { char: style.bottomLeft, fg, bg });
|
|
149
|
+
this.setCell(right, bottom, { char: style.bottomRight, fg, bg });
|
|
150
|
+
for (let xx = x + 1; xx < right; xx++)
|
|
151
|
+
this.setCell(xx, bottom, { char: style.horizontal, fg, bg });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export class TUIElement {
|
|
155
|
+
allocatedSize = new Size(80, 24);
|
|
156
|
+
dirty = false;
|
|
157
|
+
layoutStyle = undefined;
|
|
158
|
+
layoutState = undefined;
|
|
159
|
+
// Identity
|
|
160
|
+
id = undefined;
|
|
161
|
+
role = undefined;
|
|
162
|
+
// Участие в Tab-обходе и фокусе по клику. Порядок обхода — это порядок
|
|
163
|
+
// детей в дереве (как z-порядок и хит-тест); числового приоритета в духе
|
|
164
|
+
// DOM tabindex нет намеренно — он был бы вторым источником порядка.
|
|
165
|
+
focusable = false;
|
|
166
|
+
// Pointer capture (opt-in): while a button is held on this element, the dispatcher
|
|
167
|
+
// routes subsequent move/release events here even if the cursor leaves its bounds.
|
|
168
|
+
capturesPointer = false;
|
|
169
|
+
// Coordinate system
|
|
170
|
+
localPosition = new Offset(0, 0);
|
|
171
|
+
isLayoutDirty = true;
|
|
172
|
+
// ─── Damage-tracking отрисовки (см. docs/LAYOUT.md) ───
|
|
173
|
+
// Сам элемент просил перерисовку (его markDirty). Снимается в collectDamage.
|
|
174
|
+
isPaintDirty = true;
|
|
175
|
+
// В поддереве есть paint-dirty потомок — путь для damage-обхода (аналог
|
|
176
|
+
// subtreeStyleDirty). Снимается в collectDamage.
|
|
177
|
+
hasPaintDirtyDescendant = false;
|
|
178
|
+
// Экранный rect на момент последнего damage-обхода; null — не рисовался
|
|
179
|
+
// или отцеплён. Даёт old-rect повреждение при переезде/скрытии/отцеплении.
|
|
180
|
+
lastPaintedRect = null;
|
|
181
|
+
// Только на корне: rect'ы поддеревьев, отцеплённых с последнего кадра.
|
|
182
|
+
pendingDetachDamage = [];
|
|
183
|
+
_parent = null;
|
|
184
|
+
// Якорь дерева: выставляется setAsRoot() (BodyElement, тестовые корни).
|
|
185
|
+
// Сам root НЕ кэшируется — getRoot() выводит его из цепочки родителей.
|
|
186
|
+
isRootAnchor = false;
|
|
187
|
+
// Callback invoked when markDirty reaches the root — used by TuiApplication to schedule a render
|
|
188
|
+
requestRenderCallback = null;
|
|
189
|
+
// Focus manager — set only on root element
|
|
190
|
+
focusManager = null;
|
|
191
|
+
// ─── Style system ───
|
|
192
|
+
styleValue = {};
|
|
193
|
+
resolvedStyleValue = ROOT_RESOLVED_STYLE;
|
|
194
|
+
isStyleDirty = true;
|
|
195
|
+
subtreeStyleDirty = false;
|
|
196
|
+
// Сырые цвета ПОСЛЕ when-merge (до резолва сентинелов) — отвечают на
|
|
197
|
+
// вопрос «задан ли цвет собственным стилем» (заливка фона, инспектор).
|
|
198
|
+
appliedFgValue;
|
|
199
|
+
appliedBgValue;
|
|
200
|
+
// Активные состояния (hover/focus ведёт ядро, прочие — виджеты). Lazy:
|
|
201
|
+
// у подавляющего большинства элементов состояний нет.
|
|
202
|
+
styleStatesSet = null;
|
|
203
|
+
// Собственная таблица токенов (обычно только у корня — хост кладёт тему).
|
|
204
|
+
styleVarsValue = null;
|
|
205
|
+
// Ближайший резолвленный var-scope — источник styleVar(). До первого
|
|
206
|
+
// резолва — дефолты tuidom.
|
|
207
|
+
varScopeRef = ROOT_VAR_SCOPE;
|
|
208
|
+
// Контекст, переданный детям на последнем резолве. Валиден, пока элемент
|
|
209
|
+
// чист: любая смена входа (стиль/состояние предка) дирявит всё поддерево.
|
|
210
|
+
childStyleContext = ROOT_STYLE_CONTEXT;
|
|
211
|
+
get style() {
|
|
212
|
+
return this.styleValue;
|
|
213
|
+
}
|
|
214
|
+
set style(value) {
|
|
215
|
+
if (styleEquals(this.styleValue, value))
|
|
216
|
+
return;
|
|
217
|
+
this.styleValue = value;
|
|
218
|
+
this.markStyleDirty();
|
|
219
|
+
}
|
|
220
|
+
// Event listener storage — supports any event type + capture flag
|
|
221
|
+
_listeners = new Map();
|
|
222
|
+
/**
|
|
223
|
+
* Allocated visible area on screen, set by parent container via layout().
|
|
224
|
+
* Lazy fallback: if layout is dirty, triggers layout with loose constraints.
|
|
225
|
+
*/
|
|
226
|
+
get layoutSize() {
|
|
227
|
+
if (this.isLayoutDirty) {
|
|
228
|
+
const constraints = BoxConstraints.loose(this.allocatedSize);
|
|
229
|
+
this.layout(constraints);
|
|
230
|
+
}
|
|
231
|
+
return this.allocatedSize;
|
|
232
|
+
}
|
|
233
|
+
get isFocused() {
|
|
234
|
+
const fm = this.getRoot()?.focusManager ?? null;
|
|
235
|
+
return fm !== null && fm.activeElement === this;
|
|
236
|
+
}
|
|
237
|
+
getParent() {
|
|
238
|
+
return this._parent;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Абсолютная позиция элемента на экране — **производная** от цепочки
|
|
242
|
+
* родителей: `parent.globalPosition + localPosition`. Раньше это было поле,
|
|
243
|
+
* которое каждый контейнер обязан был выставлять руками параллельно с
|
|
244
|
+
* `localPosition` (LAYOUT.md честно писал «в корректном состоянии они
|
|
245
|
+
* равны») — забытая запись давала элемент, который рисуется, но не
|
|
246
|
+
* кликается. Теперь рассинхрон невозможен по построению.
|
|
247
|
+
*
|
|
248
|
+
* У отсоединённого элемента (parent=null) равна `localPosition` — так
|
|
249
|
+
* standalone-рендер в тестах может позиционировать элемент напрямую.
|
|
250
|
+
*/
|
|
251
|
+
get globalPosition() {
|
|
252
|
+
const parent = this._parent;
|
|
253
|
+
if (parent === null) {
|
|
254
|
+
return new Point(this.localPosition.dx, this.localPosition.dy);
|
|
255
|
+
}
|
|
256
|
+
const parentGlobal = parent.globalPosition;
|
|
257
|
+
return new Point(parentGlobal.x + this.localPosition.dx, parentGlobal.y + this.localPosition.dy);
|
|
258
|
+
}
|
|
259
|
+
// ─── Владение детьми ───
|
|
260
|
+
//
|
|
261
|
+
// Список детей принадлежит базовому классу; топология меняется ТОЛЬКО через
|
|
262
|
+
// appendChild/insertChild/removeChild/replaceChild/setChildren — они же
|
|
263
|
+
// атомарно поддерживают обратную ссылку parent. getChildren() не
|
|
264
|
+
// переопределяется: «ребёнок в списке, но parent не выставлен» (и наоборот)
|
|
265
|
+
// непредставимы. Порядок детей — это z-порядок хит-теста (последний сверху)
|
|
266
|
+
// и порядок Tab-обхода; контейнер задаёт его порядком вставки/setChildren.
|
|
267
|
+
childrenList = [];
|
|
268
|
+
getChildren() {
|
|
269
|
+
return this.childrenList;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Видимость (аналог display:none): скрытый элемент ОСТАЁТСЯ в дереве —
|
|
273
|
+
* root и каскад стилей до него доходят, — но выпадает из hit-теста и
|
|
274
|
+
* Tab-обхода (базовые обходы), а контейнер не раскладывает и не рисует его.
|
|
275
|
+
* Это разводит «структуру» и «что сейчас видно», которые раньше смешивал
|
|
276
|
+
* getChildren(): контейнеры исключали скрытых детей из структуры и потом
|
|
277
|
+
* руками чинили пропагацию при показе (источник семейства багов #204).
|
|
278
|
+
*/
|
|
279
|
+
get hidden() {
|
|
280
|
+
return this.hiddenValue;
|
|
281
|
+
}
|
|
282
|
+
set hidden(value) {
|
|
283
|
+
if (this.hiddenValue === value)
|
|
284
|
+
return;
|
|
285
|
+
this.hiddenValue = value;
|
|
286
|
+
// Фокус НЕ трогаем: куда уходит фокус при скрытии — политика уровня
|
|
287
|
+
// workbench (PanelFocusContribution возвращает его в редактор, оверлей
|
|
288
|
+
// восстанавливает savedFocus). База лишь гарантирует, что Tab-обход и
|
|
289
|
+
// hit-test в скрытое не заходят.
|
|
290
|
+
this.markDirty();
|
|
291
|
+
}
|
|
292
|
+
hiddenValue = false;
|
|
293
|
+
/** Прикрепляет ребёнка в конец списка (снимая с прежнего родителя). */
|
|
294
|
+
appendChild(child) {
|
|
295
|
+
this.insertChild(this.childrenList.length, child);
|
|
296
|
+
}
|
|
297
|
+
/** Прикрепляет ребёнка на позицию index (снимая с прежнего родителя). */
|
|
298
|
+
insertChild(index, child) {
|
|
299
|
+
if (child === this) {
|
|
300
|
+
throw new Error("TUIElement: элемент не может быть собственным ребёнком");
|
|
301
|
+
}
|
|
302
|
+
child._parent?.removeChild(child);
|
|
303
|
+
this.childrenList.splice(index, 0, child);
|
|
304
|
+
child.setParent(this);
|
|
305
|
+
this.markDirty();
|
|
306
|
+
}
|
|
307
|
+
/** Отцепляет ребёнка (no-op, если он не наш). */
|
|
308
|
+
removeChild(child) {
|
|
309
|
+
const index = this.childrenList.indexOf(child);
|
|
310
|
+
if (index === -1)
|
|
311
|
+
return;
|
|
312
|
+
this.childrenList.splice(index, 1);
|
|
313
|
+
child.setParent(null);
|
|
314
|
+
this.markDirty();
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Заменяет ребёнка, сохраняя позицию в списке — а значит z-порядок и место
|
|
318
|
+
* в Tab-обходе (важно слотовым контейнерам: content меняется, а overlay
|
|
319
|
+
* обязан остаться поверх).
|
|
320
|
+
*/
|
|
321
|
+
replaceChild(oldChild, newChild) {
|
|
322
|
+
if (oldChild === newChild)
|
|
323
|
+
return;
|
|
324
|
+
const index = this.childrenList.indexOf(oldChild);
|
|
325
|
+
if (index === -1) {
|
|
326
|
+
this.appendChild(newChild);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
newChild._parent?.removeChild(newChild);
|
|
330
|
+
oldChild.setParent(null);
|
|
331
|
+
this.childrenList[index] = newChild;
|
|
332
|
+
newChild.setParent(this);
|
|
333
|
+
this.markDirty();
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Декларативно приводит список детей к заданному (слотовые контейнеры
|
|
337
|
+
* пересобирают канонический порядок одним вызовом). Лишние отцепляются,
|
|
338
|
+
* новые прикрепляются, порядок — как в next.
|
|
339
|
+
*/
|
|
340
|
+
setChildren(next) {
|
|
341
|
+
const nextSet = new Set(next);
|
|
342
|
+
if (nextSet.size !== next.length) {
|
|
343
|
+
throw new Error("TUIElement.setChildren: один элемент дважды в списке");
|
|
344
|
+
}
|
|
345
|
+
for (const child of this.childrenList) {
|
|
346
|
+
if (!nextSet.has(child)) {
|
|
347
|
+
child.setParent(null);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const child of next) {
|
|
351
|
+
if (child._parent !== this) {
|
|
352
|
+
child._parent?.removeChild(child);
|
|
353
|
+
child.setParent(this);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
this.childrenList = [...next];
|
|
357
|
+
this.markDirty();
|
|
358
|
+
}
|
|
359
|
+
/** Гасит фокус, если activeElement — этот элемент или его потомок. */
|
|
360
|
+
releaseFocusIfInside() {
|
|
361
|
+
const fm = this.getRoot()?.focusManager ?? null;
|
|
362
|
+
let node = fm?.activeElement ?? null;
|
|
363
|
+
while (node !== null && node !== this) {
|
|
364
|
+
node = node.getParent();
|
|
365
|
+
}
|
|
366
|
+
if (node === this) {
|
|
367
|
+
fm.setFocus(null);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Observable state for the inspector, self-described by the widget. The base
|
|
372
|
+
* returns `undefined` (no state to report); interactive widgets override to
|
|
373
|
+
* expose what a test would otherwise have to infer from rendered cells — an
|
|
374
|
+
* editor's cursor/selection/readonly, a panel's active tab, a quick-pick's
|
|
375
|
+
* items. Must be a plain JSON-serialisable snapshot, not live internals:
|
|
376
|
+
* it crosses the inspector wire and is a public contract (test it).
|
|
377
|
+
*/
|
|
378
|
+
inspectState() {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Builds the path from root to this element (inclusive on both ends).
|
|
383
|
+
*/
|
|
384
|
+
getAncestorPath() {
|
|
385
|
+
const path = [];
|
|
386
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
387
|
+
let current = this;
|
|
388
|
+
while (current !== null) {
|
|
389
|
+
path.push(current);
|
|
390
|
+
current = current._parent;
|
|
391
|
+
}
|
|
392
|
+
path.reverse();
|
|
393
|
+
return path;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Порядок Tab-обхода поддерева: фокусируемые (focusable) в глубину.
|
|
397
|
+
* Скрытые (hidden) поддеревья пропускаются целиком — Tab не должен уводить
|
|
398
|
+
* фокус в невидимый инпут (закрытый find-виджет, неактивная вкладка).
|
|
399
|
+
*/
|
|
400
|
+
getDepthFirstFocusableOrder() {
|
|
401
|
+
if (this.hidden)
|
|
402
|
+
return [];
|
|
403
|
+
const result = [];
|
|
404
|
+
if (this.focusable)
|
|
405
|
+
result.push(this);
|
|
406
|
+
for (const child of this.getChildren()) {
|
|
407
|
+
result.push(...child.getDepthFirstFocusableOrder());
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
addEventListener(type, handler, options) {
|
|
412
|
+
const capture = options?.capture ?? false;
|
|
413
|
+
let entries = this._listeners.get(type);
|
|
414
|
+
if (!entries) {
|
|
415
|
+
entries = [];
|
|
416
|
+
this._listeners.set(type, entries);
|
|
417
|
+
}
|
|
418
|
+
entries.push({ handler, capture });
|
|
419
|
+
}
|
|
420
|
+
removeEventListener(type, handler, options) {
|
|
421
|
+
const capture = options?.capture ?? false;
|
|
422
|
+
const entries = this._listeners.get(type);
|
|
423
|
+
if (!entries)
|
|
424
|
+
return;
|
|
425
|
+
const index = entries.findIndex((e) => e.handler === handler && e.capture === capture);
|
|
426
|
+
if (index !== -1) {
|
|
427
|
+
entries.splice(index, 1);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Dispatches event with capture → target → bubble phases (DOM-like).
|
|
432
|
+
* Returns true if preventDefault() was NOT called.
|
|
433
|
+
*/
|
|
434
|
+
dispatchEvent(event) {
|
|
435
|
+
event.target = this;
|
|
436
|
+
// Build path from root → ... → parent (excluding target)
|
|
437
|
+
const path = [];
|
|
438
|
+
let current = this._parent;
|
|
439
|
+
while (current !== null) {
|
|
440
|
+
path.push(current);
|
|
441
|
+
current = current._parent;
|
|
442
|
+
}
|
|
443
|
+
path.reverse(); // root first
|
|
444
|
+
// Capture phase
|
|
445
|
+
event.eventPhase = EventPhase.CAPTURING;
|
|
446
|
+
for (const el of path) {
|
|
447
|
+
event.currentTarget = el;
|
|
448
|
+
this._invokeListeners(el, event, true);
|
|
449
|
+
if (event.propagationStopped)
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
// Target phase
|
|
453
|
+
if (!event.propagationStopped) {
|
|
454
|
+
event.eventPhase = EventPhase.AT_TARGET;
|
|
455
|
+
event.currentTarget = this;
|
|
456
|
+
this._invokeListeners(this, event, null); // both capture and bubble listeners
|
|
457
|
+
}
|
|
458
|
+
// Bubble phase
|
|
459
|
+
if (!event.propagationStopped && event.bubbles) {
|
|
460
|
+
event.eventPhase = EventPhase.BUBBLING;
|
|
461
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
462
|
+
event.currentTarget = path[i];
|
|
463
|
+
this._invokeListeners(path[i], event, false);
|
|
464
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- side effect from handler
|
|
465
|
+
if (event.propagationStopped)
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
event.eventPhase = EventPhase.NONE;
|
|
470
|
+
event.currentTarget = null;
|
|
471
|
+
// Default action — analogous to Web DOM default actions.
|
|
472
|
+
// Runs on the target element after all propagation phases.
|
|
473
|
+
// Cancelled by preventDefault() from any listener.
|
|
474
|
+
if (!event.defaultPrevented) {
|
|
475
|
+
this.performDefaultAction(event);
|
|
476
|
+
}
|
|
477
|
+
return !event.defaultPrevented;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Override in subclasses to define built-in element behavior (like opening a menu on click).
|
|
481
|
+
* Called after all capture/target/bubble listeners. Skipped if preventDefault() was called.
|
|
482
|
+
* Analogous to Web DOM default actions (e.g. <a> navigation, <input> text entry).
|
|
483
|
+
*/
|
|
484
|
+
performDefaultAction(event) {
|
|
485
|
+
if (event.type === "mousedown" && this.focusable) {
|
|
486
|
+
this.focus();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Invoke listeners on an element for the given event.
|
|
491
|
+
* captureFilter: true = only capture, false = only bubble, null = both (target phase)
|
|
492
|
+
*/
|
|
493
|
+
_invokeListeners(el, event, captureFilter) {
|
|
494
|
+
const entries = el._listeners.get(event.type);
|
|
495
|
+
if (!entries)
|
|
496
|
+
return;
|
|
497
|
+
// Snapshot to avoid mutation during iteration
|
|
498
|
+
const snapshot = entries.slice();
|
|
499
|
+
for (const entry of snapshot) {
|
|
500
|
+
if (captureFilter === null || entry.capture === captureFilter) {
|
|
501
|
+
entry.handler(event);
|
|
502
|
+
if (event.immediatePropagationStopped)
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// ─── Style system ───
|
|
508
|
+
get resolvedStyle() {
|
|
509
|
+
return this.resolvedStyleValue;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Forces a style re-resolution of this element and its whole subtree (and
|
|
513
|
+
* schedules a render). Public so a container can refresh a subtree it just
|
|
514
|
+
* re-attached — e.g. a panel that was excluded from `getChildren()` while
|
|
515
|
+
* hidden and thus missed style propagation.
|
|
516
|
+
*/
|
|
517
|
+
markStyleDirty() {
|
|
518
|
+
this.markStyleSubtree();
|
|
519
|
+
this.markSubtreeStyleDirtyUp();
|
|
520
|
+
this.markDirty();
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* isStyleDirty вглубь по поддереву — БЕЗ подъёма вверх и без markDirty.
|
|
524
|
+
* Подъём достаточен один раз от вершины каскада (внутренним узлам
|
|
525
|
+
* subtreeStyleDirty не нужен — они и так isStyleDirty); прежняя рекурсия
|
|
526
|
+
* через markStyleDirty гоняла markDirty до корня из каждого потомка,
|
|
527
|
+
* O(N×глубина) на каскад.
|
|
528
|
+
*/
|
|
529
|
+
markStyleSubtree() {
|
|
530
|
+
this.isStyleDirty = true;
|
|
531
|
+
for (const child of this.getChildren()) {
|
|
532
|
+
child.markStyleSubtree();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
markSubtreeStyleDirtyUp() {
|
|
536
|
+
let current = this._parent;
|
|
537
|
+
while (current && !current.subtreeStyleDirty) {
|
|
538
|
+
current.subtreeStyleDirty = true;
|
|
539
|
+
current = current._parent;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
performStyleResolution(context) {
|
|
543
|
+
if (!this.isStyleDirty && !this.subtreeStyleDirty)
|
|
544
|
+
return;
|
|
545
|
+
if (this.isStyleDirty) {
|
|
546
|
+
const applied = mergeStyleVariants(this.style, (selector) => this.isStyleSelectorActive(selector, context.ancestorStates));
|
|
547
|
+
this.appliedFgValue = applied.fg;
|
|
548
|
+
this.appliedBgValue = applied.bg;
|
|
549
|
+
const vars = this.styleVarsValue !== null ? extendVarScope(context.vars, this.styleVarsValue) : context.vars;
|
|
550
|
+
this.varScopeRef = vars;
|
|
551
|
+
const describe = () => this.describeForStyleError();
|
|
552
|
+
const fg = applied.fg !== undefined
|
|
553
|
+
? resolveStyleColor(applied.fg, context.fg, context.bg, vars, describe)
|
|
554
|
+
: context.fg;
|
|
555
|
+
const bg = applied.bg !== undefined
|
|
556
|
+
? resolveStyleColor(applied.bg, context.fg, context.bg, vars, describe)
|
|
557
|
+
: context.bg;
|
|
558
|
+
this.resolvedStyleValue = { fg, bg };
|
|
559
|
+
this.childStyleContext = this.buildChildStyleContext(context);
|
|
560
|
+
}
|
|
561
|
+
this.isStyleDirty = false;
|
|
562
|
+
this.subtreeStyleDirty = false;
|
|
563
|
+
this.performChildrenStyleResolution(this.childStyleContext);
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Спуск стилевого прохода в детей. Переопределяется виртуализирующим
|
|
567
|
+
* контейнером, чтобы резолвить только видимое окно строк (зеркально
|
|
568
|
+
* hitTestChildren/getDepthFirstFocusableOrder); офскрин-строки остаются
|
|
569
|
+
* style-dirty и дорезолвливаются, когда въезжают в окно — см.
|
|
570
|
+
* {@link markSubtreeStyleDirty}.
|
|
571
|
+
*/
|
|
572
|
+
performChildrenStyleResolution(context) {
|
|
573
|
+
for (const child of this.getChildren()) {
|
|
574
|
+
child.performStyleResolution(context);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* «У потомков могут быть неразрезолвленные стили»: subtreeStyleDirty здесь
|
|
579
|
+
* и вверх до корня, БЕЗ пометки самих детей и без markDirty. Для
|
|
580
|
+
* виртуализирующего контейнера, чей performLayout сместил окно: следующий
|
|
581
|
+
* стилевой проход обязан зайти внутрь и дорезолвить въехавшие строки
|
|
582
|
+
* (чистые отсеются ранним выходом performStyleResolution).
|
|
583
|
+
*/
|
|
584
|
+
markSubtreeStyleDirty() {
|
|
585
|
+
this.subtreeStyleDirty = true;
|
|
586
|
+
this.markSubtreeStyleDirtyUp();
|
|
587
|
+
}
|
|
588
|
+
isStyleSelectorActive(selector, ancestorStates) {
|
|
589
|
+
if (selector.startsWith("in:")) {
|
|
590
|
+
const state = selector.slice(3);
|
|
591
|
+
return this.styleStatesSet?.has(state) === true || ancestorStates.has(state);
|
|
592
|
+
}
|
|
593
|
+
return this.styleStatesSet?.has(selector) === true;
|
|
594
|
+
}
|
|
595
|
+
buildChildStyleContext(context) {
|
|
596
|
+
const { fg, bg } = this.resolvedStyleValue;
|
|
597
|
+
let ancestorStates = context.ancestorStates;
|
|
598
|
+
if (this.styleStatesSet !== null && this.styleStatesSet.size > 0) {
|
|
599
|
+
const union = new Set(context.ancestorStates);
|
|
600
|
+
for (const state of this.styleStatesSet) {
|
|
601
|
+
union.add(state);
|
|
602
|
+
}
|
|
603
|
+
ancestorStates = union;
|
|
604
|
+
}
|
|
605
|
+
return { fg, bg, vars: this.varScopeRef, ancestorStates };
|
|
606
|
+
}
|
|
607
|
+
describeForStyleError() {
|
|
608
|
+
const id = this.id !== undefined ? `#${this.id}` : "";
|
|
609
|
+
return `${this.constructor.name}${id}`;
|
|
610
|
+
}
|
|
611
|
+
// ─── Переменные стиля (токены) ───
|
|
612
|
+
/**
|
|
613
|
+
* Кладёт таблицу токен→число, каскадирующую в поддерево ПОВЕРХ таблиц
|
|
614
|
+
* предков и дефолтов tuidom (STYLE_TOKEN_DEFAULTS). Обычное место — корень:
|
|
615
|
+
* хост транслирует сюда палитру темы одним вызовом (hot-swap = повторный
|
|
616
|
+
* вызов). Таблица заменяется целиком, null — снимает. Значения — только
|
|
617
|
+
* конкретные числа (packed RGB | DEFAULT_COLOR); сентинелы INHERITED_*
|
|
618
|
+
* нелегальны.
|
|
619
|
+
*/
|
|
620
|
+
setStyleVars(vars) {
|
|
621
|
+
if (vars === this.styleVarsValue)
|
|
622
|
+
return;
|
|
623
|
+
if (vars !== null) {
|
|
624
|
+
for (const key of Object.keys(vars)) {
|
|
625
|
+
if (vars[key] < DEFAULT_COLOR) {
|
|
626
|
+
throw new Error(`${this.describeForStyleError()}.setStyleVars: токен "${key}" содержит сентинел/некорректное значение ${vars[key]} — таблицы принимают только конкретные цвета`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
this.styleVarsValue = vars;
|
|
631
|
+
this.markStyleDirty();
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Читает токен из ближайшего резолвленного var-scope — для painter-виджетов,
|
|
635
|
+
* рисующих несколько цветов в custom render. Валидно после резолва стилей
|
|
636
|
+
* (render всегда после него в кадре); до первого резолва видит дефолты
|
|
637
|
+
* tuidom. Незнакомый токен — throw; передан fallback (аналог второго
|
|
638
|
+
* аргумента CSS var()) — возвращается он. Fallback — для токенов, чьё
|
|
639
|
+
* отсутствие ЛЕГАЛЬНО и означает «взять из каскада» (editorGutter.background
|
|
640
|
+
* → фон редактора), а не страховка от опечаток.
|
|
641
|
+
*/
|
|
642
|
+
styleVar(name, fallback) {
|
|
643
|
+
const value = this.varScopeRef[name];
|
|
644
|
+
if (typeof value !== "number") {
|
|
645
|
+
if (fallback !== undefined)
|
|
646
|
+
return fallback;
|
|
647
|
+
throw new Error(`${this.describeForStyleError()}.styleVar: неизвестный цветовой токен "${name}"`);
|
|
648
|
+
}
|
|
649
|
+
return value;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Резолвит StyleColor (число | сентинел INHERITED_* | имя токена) в
|
|
653
|
+
* конкретный цвет в контексте ЭТОГО элемента (его resolvedStyle и
|
|
654
|
+
* var-scope). Для painter-виджетов, принимающих цвета данными
|
|
655
|
+
* (посимвольные стили TextLabel, iconColor строк дерева): данные могут
|
|
656
|
+
* ссылаться на токены и переживать смену темы без пере-пуша.
|
|
657
|
+
*/
|
|
658
|
+
resolveColor(color) {
|
|
659
|
+
const { fg, bg } = this.resolvedStyleValue;
|
|
660
|
+
return resolveStyleColor(color, fg, bg, this.varScopeRef, () => this.describeForStyleError());
|
|
661
|
+
}
|
|
662
|
+
// ─── Состояния стиля ───
|
|
663
|
+
/**
|
|
664
|
+
* Ставит/снимает состояние стиля. hover и focus ведёт ядро (диспатчер
|
|
665
|
+
* мыши и менеджер фокуса); произвольные строковые состояния ("selected",
|
|
666
|
+
* "checked", …) виджеты ставят сами. Смена состояния перерезолвит стиль
|
|
667
|
+
* элемента и поддерева: дети наследуют РЕЗУЛЬТАТ родителя с учётом его
|
|
668
|
+
* состояний, а `in:`-селекторы потомков видят состояния предков.
|
|
669
|
+
*/
|
|
670
|
+
setStyleState(state, active) {
|
|
671
|
+
if (this.applyStyleState(state, active)) {
|
|
672
|
+
this.markStyleDirty();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Как {@link setStyleState}, но для вызова из performLayout уже идущего
|
|
677
|
+
* кадра (виртуализирующие контейнеры синхронизируют selected/hover строк в
|
|
678
|
+
* layout). Стилевой проход идёт сразу после layout и потребит флаги, а
|
|
679
|
+
* markDirty здесь лишь оставлял бы корень layout-грязным ПОСЛЕ кадра — и
|
|
680
|
+
* следующее событие ввода рендерило бы пустой кадр (dirty-гейт
|
|
681
|
+
* TuiApplication).
|
|
682
|
+
*/
|
|
683
|
+
setStyleStateDuringLayout(state, active) {
|
|
684
|
+
if (this.applyStyleState(state, active)) {
|
|
685
|
+
this.markStyleSubtree();
|
|
686
|
+
this.markSubtreeStyleDirtyUp();
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/** Мутация набора состояний; true — значение реально изменилось. */
|
|
690
|
+
applyStyleState(state, active) {
|
|
691
|
+
const current = this.styleStatesSet?.has(state) === true;
|
|
692
|
+
if (current === active)
|
|
693
|
+
return false;
|
|
694
|
+
if (active) {
|
|
695
|
+
(this.styleStatesSet ??= new Set()).add(state);
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
this.styleStatesSet?.delete(state);
|
|
699
|
+
}
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
hasStyleState(state) {
|
|
703
|
+
return this.styleStatesSet?.has(state) === true;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Состояние активно на самом элементе ИЛИ на любом предке — рантайм-двойник
|
|
707
|
+
* `in:`-селектора для кода, который решает не цветом, а геометрией
|
|
708
|
+
* (инлайн-кнопка строки списка раскрывается, только когда строка активна).
|
|
709
|
+
* Селектор в `when` даёт ту же семантику декларативно и дешевле (готовый
|
|
710
|
+
* `ancestorStates` из контекста резолва); этот метод — для тех, кому
|
|
711
|
+
* состояние нужно ДО стилевого прохода, прямо в performLayout.
|
|
712
|
+
*/
|
|
713
|
+
hasStyleStateWithin(state) {
|
|
714
|
+
let node = this;
|
|
715
|
+
while (node !== null) {
|
|
716
|
+
if (node.styleStatesSet?.has(state) === true)
|
|
717
|
+
return true;
|
|
718
|
+
node = node._parent;
|
|
719
|
+
}
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
/** Активные состояния (порядок вставки) — инспектор/тесты. */
|
|
723
|
+
get activeStyleStates() {
|
|
724
|
+
return this.styleStatesSet !== null ? [...this.styleStatesSet] : [];
|
|
725
|
+
}
|
|
726
|
+
// ─── Focus convenience ───
|
|
727
|
+
focus() {
|
|
728
|
+
const fm = this.getRoot()?.focusManager ?? null;
|
|
729
|
+
if (fm) {
|
|
730
|
+
fm.setFocus(this);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
blur() {
|
|
734
|
+
const fm = this.getRoot()?.focusManager ?? null;
|
|
735
|
+
if (fm?.activeElement === this) {
|
|
736
|
+
fm.setFocus(null);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Marks this element and ancestors as dirty.
|
|
741
|
+
* Call this when layout-affecting properties change.
|
|
742
|
+
*
|
|
743
|
+
* When propagation reaches the root (no parent), fires the
|
|
744
|
+
* requestRenderCallback so TuiApplication can schedule a deferred render.
|
|
745
|
+
* Batching is handled by TuiApplication.scheduleRender().
|
|
746
|
+
*/
|
|
747
|
+
markDirty() {
|
|
748
|
+
// Paint-dirty — ТОЛЬКО сам элемент: повреждается его rect, а не вся
|
|
749
|
+
// цепочка предков (иначе любой markDirty = полноэкранный damage).
|
|
750
|
+
this.isPaintDirty = true;
|
|
751
|
+
this.isLayoutDirty = true;
|
|
752
|
+
let top = this;
|
|
753
|
+
for (let current = this._parent; current !== null; current = current._parent) {
|
|
754
|
+
current.isLayoutDirty = true;
|
|
755
|
+
current.hasPaintDirtyDescendant = true;
|
|
756
|
+
top = current;
|
|
757
|
+
}
|
|
758
|
+
top.requestRenderCallback?.();
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Пост-layout damage-обход (pre-order): собирает в sink повреждённые
|
|
762
|
+
* экранные области — rect'ы paint-dirty элементов и old∪new переехавших /
|
|
763
|
+
* изменивших размер / скрывшихся — и актуализирует lastPaintedRect.
|
|
764
|
+
* Спуск только по путям hasPaintDirtyDescendant или под переехавшим
|
|
765
|
+
* предком; устоявшееся поддерево стоит одну проверку флагов.
|
|
766
|
+
*
|
|
767
|
+
* Не заходит в скрытые и в не разложенные этим кадром поддеревья
|
|
768
|
+
* (isLayoutDirty после полного layout корня — виртуализация: контейнер их
|
|
769
|
+
* не раскладывал ⇒ не рисует ⇒ на экране их нет; чтение layoutSize там
|
|
770
|
+
* запустило бы lazy-layout с мусорными constraints).
|
|
771
|
+
*/
|
|
772
|
+
collectDamage(sink, parentOrigin) {
|
|
773
|
+
if (this.hidden || this.isLayoutDirty) {
|
|
774
|
+
if (this.lastPaintedRect !== null) {
|
|
775
|
+
// Скрылся (или выпал из раскладки) — место под ним перерисовать.
|
|
776
|
+
sink.add(this.lastPaintedRect);
|
|
777
|
+
this.clearPaintedRects();
|
|
778
|
+
}
|
|
779
|
+
this.isPaintDirty = false;
|
|
780
|
+
this.hasPaintDirtyDescendant = false;
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const rect = new Rect(new Point(parentOrigin.x + this.localPosition.dx, parentOrigin.y + this.localPosition.dy), this.allocatedSize);
|
|
784
|
+
const old = this.lastPaintedRect;
|
|
785
|
+
const moved = old === null ||
|
|
786
|
+
old.x !== rect.x ||
|
|
787
|
+
old.y !== rect.y ||
|
|
788
|
+
old.width !== rect.width ||
|
|
789
|
+
old.height !== rect.height;
|
|
790
|
+
const atomic = this.paintsSubtreeAtomically;
|
|
791
|
+
if (this.isPaintDirty || moved || (atomic && this.hasPaintDirtyDescendant)) {
|
|
792
|
+
sink.add(rect);
|
|
793
|
+
if (old !== null && moved)
|
|
794
|
+
sink.add(old);
|
|
795
|
+
}
|
|
796
|
+
// Спуск: найти paint-dirty потомков и/или обновить их lastPaintedRect
|
|
797
|
+
// после переезда предка (их экранные rect'ы сменились все разом).
|
|
798
|
+
const descend = !atomic && (moved || this.hasPaintDirtyDescendant);
|
|
799
|
+
this.lastPaintedRect = rect;
|
|
800
|
+
this.isPaintDirty = false;
|
|
801
|
+
this.hasPaintDirtyDescendant = false;
|
|
802
|
+
if (descend)
|
|
803
|
+
this.collectChildrenDamage(sink, rect.origin);
|
|
804
|
+
}
|
|
805
|
+
/** Обход детей damage-сбора — seam для контейнеров с нестандартной структурой. */
|
|
806
|
+
collectChildrenDamage(sink, origin) {
|
|
807
|
+
for (const child of this.childrenList)
|
|
808
|
+
child.collectDamage(sink, origin);
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* true — поддерево рисуется как одно целое: любой paint-dirty потомок
|
|
812
|
+
* повреждает весь rect элемента, damage-обход внутрь не заходит. Для
|
|
813
|
+
* виртуализирующих контейнеров (ListViewElement: тысячи строк-детей с
|
|
814
|
+
* протухшими офскрин-позициями не итерируются) и контейнеров, рисующих
|
|
815
|
+
* собственный хром по состоянию ребёнка вне его rect'а (ScrollBarDecorator:
|
|
816
|
+
* бегунок в колонке за пределами ребёнка).
|
|
817
|
+
*/
|
|
818
|
+
get paintsSubtreeAtomically() {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
/** Рекурсивно забывает lastPaintedRect поддерева (отцепление/скрытие). */
|
|
822
|
+
clearPaintedRects() {
|
|
823
|
+
this.lastPaintedRect = null;
|
|
824
|
+
for (const child of this.childrenList)
|
|
825
|
+
child.clearPaintedRects();
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Только для TuiApplication: забрать rect'ы поддеревьев, отцеплённых от
|
|
829
|
+
* этого корня с прошлого кадра (закрытие оверлея, смена вкладки).
|
|
830
|
+
*/
|
|
831
|
+
takePendingDetachDamage() {
|
|
832
|
+
if (this.pendingDetachDamage.length === 0)
|
|
833
|
+
return this.pendingDetachDamage;
|
|
834
|
+
const out = this.pendingDetachDamage;
|
|
835
|
+
this.pendingDetachDamage = [];
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Внутренний сеттер обратной ссылки — вызывается ТОЛЬКО из
|
|
840
|
+
* appendChild/insertChild/removeChild/replaceChild/setChildren, поэтому
|
|
841
|
+
* список детей и parent меняются строго вместе. Отцепление (parent=null)
|
|
842
|
+
* гасит фокус, если он был внутри отцепляемого поддерева — иначе
|
|
843
|
+
* клавиатура продолжала бы уходить в элемент, которого больше нет на
|
|
844
|
+
* экране. После смены зовёт {@link onDidChangeParent} (хук для виджетов,
|
|
845
|
+
* вешающих слушатели на родителя, — MenuBarElement).
|
|
846
|
+
*/
|
|
847
|
+
setParent(parent) {
|
|
848
|
+
const oldRoot = this.getRoot(); // снимок ДО мутации — по ещё живой цепочке
|
|
849
|
+
if (parent === null && this._parent !== null) {
|
|
850
|
+
this.releaseFocusIfInside();
|
|
851
|
+
// Отцепление — место, где поддерево рисовалось, надо перерисовать.
|
|
852
|
+
// Rect потомков вложены в наш (Н2) — достаточно верхнего. Запись
|
|
853
|
+
// на СТАРЫЙ корень: только он знает экранные координаты кадра.
|
|
854
|
+
if (this.lastPaintedRect !== null && oldRoot !== null) {
|
|
855
|
+
oldRoot.pendingDetachDamage.push(this.lastPaintedRect);
|
|
856
|
+
}
|
|
857
|
+
this.clearPaintedRects();
|
|
858
|
+
}
|
|
859
|
+
const oldParent = this._parent;
|
|
860
|
+
this._parent = parent;
|
|
861
|
+
if (parent) {
|
|
862
|
+
// Безусловно: даже чистое поддерево обязано пере-резолвиться в
|
|
863
|
+
// контексте нового родителя (другой каскад/состояния предков).
|
|
864
|
+
this.markStyleDirty();
|
|
865
|
+
}
|
|
866
|
+
this.onDidChangeParent(oldParent, parent);
|
|
867
|
+
const newRoot = this.getRoot();
|
|
868
|
+
if (oldRoot !== newRoot) {
|
|
869
|
+
if (oldRoot !== null)
|
|
870
|
+
this.fireDidDisconnect();
|
|
871
|
+
if (newRoot !== null)
|
|
872
|
+
this.fireDidConnect(newRoot);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Хук смены родителя: вызывается после каждого перецепления. Базовая
|
|
877
|
+
* реализация пуста; переопределяется вместо запрещённого override
|
|
878
|
+
* setParent. Для доступа к КОРНЮ используйте {@link onDidConnect} — на
|
|
879
|
+
* момент этого хука поддерево может быть ещё не укоренено.
|
|
880
|
+
*/
|
|
881
|
+
onDidChangeParent(_oldParent, _newParent) {
|
|
882
|
+
// Базовая реализация ничего не делает.
|
|
883
|
+
}
|
|
884
|
+
// ─── Подключение к дереву (аналог DOM connectedCallback) ───
|
|
885
|
+
//
|
|
886
|
+
// «Прикреплён к родителю» ≠ «подключён к укоренённому дереву»: поддерево
|
|
887
|
+
// может собираться отвязанно и укорениться позже (или наоборот). Хуки ниже
|
|
888
|
+
// сообщают каждому узлу перемещаемого поддерева о смене укоренённости —
|
|
889
|
+
// это пропагация СОБЫТИЯ, не состояния (getRoot() остаётся производным,
|
|
890
|
+
// протухать нечему; ср. кэш root, удалённый в #214).
|
|
891
|
+
/**
|
|
892
|
+
* Поддерево подключилось к укоренённому дереву: внутри хука
|
|
893
|
+
* `getRoot() === root`. Перенос между родителями (даже внутри одного
|
|
894
|
+
* дерева) — это всегда пара disconnect → connect: будьте идемпотентны.
|
|
895
|
+
* Скрытые (hidden) узлы получают хук наравне с видимыми — подключение
|
|
896
|
+
* не зависит от видимости. НЕ полагайтесь на `root.focusManager` — он
|
|
897
|
+
* появляется позже (TuiApplication.run). Хук не должен бросать; мутации
|
|
898
|
+
* разрешены только в собственном поддереве. Если хук удаляет узел из ещё
|
|
899
|
+
* не обойдённой части дерева, тот получит disconnect без предшествовавшего
|
|
900
|
+
* connect-уведомления (подключение — факт топологии, уведомления
|
|
901
|
+
* догоняют).
|
|
902
|
+
*/
|
|
903
|
+
onDidConnect(_root) {
|
|
904
|
+
// Базовая реализация ничего не делает.
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Поддерево отключилось от укоренённого дерева: внутри хука
|
|
908
|
+
* `getRoot() === null`. Прежний root хук хранит сам, если нужен для
|
|
909
|
+
* отписки (DOM-прецедент: disconnectedCallback тоже без аргументов).
|
|
910
|
+
*/
|
|
911
|
+
onDidDisconnect() {
|
|
912
|
+
// Базовая реализация ничего не делает.
|
|
913
|
+
}
|
|
914
|
+
/** Подключён ли элемент к укоренённому дереву. */
|
|
915
|
+
get isConnected() {
|
|
916
|
+
return this.getRoot() !== null;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Pre-order обход поддерева (родитель раньше детей — DOM tree order).
|
|
920
|
+
* Снимок детей берётся ДО вызова хука узла, перед рекурсией проверяется
|
|
921
|
+
* актуальность связи: ребёнок, добавленный хуком, получит свой connect
|
|
922
|
+
* через собственный setParent; удалённый — уже получил disconnect и
|
|
923
|
+
* пропускается.
|
|
924
|
+
*/
|
|
925
|
+
fireDidConnect(root) {
|
|
926
|
+
const children = [...this.getChildren()];
|
|
927
|
+
this.onDidConnect(root);
|
|
928
|
+
for (const child of children) {
|
|
929
|
+
if (child.getParent() === this)
|
|
930
|
+
child.fireDidConnect(root);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
fireDidDisconnect() {
|
|
934
|
+
const children = [...this.getChildren()];
|
|
935
|
+
this.onDidDisconnect();
|
|
936
|
+
for (const child of children) {
|
|
937
|
+
if (child.getParent() === this)
|
|
938
|
+
child.fireDidDisconnect();
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Корень дерева — **производный** от цепочки родителей: прогулка вверх до
|
|
943
|
+
* вершины; если вершина — якорь (setAsRoot), это и есть корень, иначе
|
|
944
|
+
* поддерево отсоединено и корня нет. Раньше root был кэшем, который
|
|
945
|
+
* пропагировался вниз через getChildren() при setParent — контейнеры,
|
|
946
|
+
* прячущие детей из getChildren() (неактивные вкладки), оставляли их с
|
|
947
|
+
* протухшим null-root навсегда (семейство багов #204: focus()/open()
|
|
948
|
+
* молча не работали). Живая цепочка родителей протухнуть не может.
|
|
949
|
+
*/
|
|
950
|
+
getRoot() {
|
|
951
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
952
|
+
let current = this;
|
|
953
|
+
while (current._parent !== null) {
|
|
954
|
+
current = current._parent;
|
|
955
|
+
}
|
|
956
|
+
return current.isRootAnchor ? current : null;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Ближайший overlay-слой вверх по дереву (попапы, контекстные меню,
|
|
960
|
+
* докнутые виджеты). Элементы-хосты слоёв (BodyElement, OverlayHostElement)
|
|
961
|
+
* переопределяют и возвращают свой слой.
|
|
962
|
+
*/
|
|
963
|
+
getOverlayLayer() {
|
|
964
|
+
return this.getParent()?.getOverlayLayer() ?? null;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Sets this element as the root (used for testing and by BodyElement).
|
|
968
|
+
* Помечает элемент якорем — getRoot() признаёт корнем только вершину
|
|
969
|
+
* цепочки с этой меткой. Уже собранное поддерево получает
|
|
970
|
+
* {@link onDidConnect} (поздний setAsRoot легален); повторный вызов —
|
|
971
|
+
* no-op; якорь на узле с родителем запрещён (getRoot() не видит якорь в
|
|
972
|
+
* середине цепочки — сработал бы только после detach, миной).
|
|
973
|
+
*/
|
|
974
|
+
setAsRoot() {
|
|
975
|
+
if (this.isRootAnchor)
|
|
976
|
+
return;
|
|
977
|
+
if (this._parent !== null) {
|
|
978
|
+
throw new Error("TUIElement.setAsRoot: якорь корня допустим только на вершине цепочки (без родителя)");
|
|
979
|
+
}
|
|
980
|
+
this.isRootAnchor = true;
|
|
981
|
+
this.fireDidConnect(this);
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Sets a callback to be invoked when markDirty() reaches the root element.
|
|
985
|
+
* Used by TuiApplication to schedule async re-renders.
|
|
986
|
+
*/
|
|
987
|
+
setRequestRenderCallback(callback) {
|
|
988
|
+
this.requestRenderCallback = callback;
|
|
989
|
+
}
|
|
990
|
+
// ─── Intrinsic Size API ───
|
|
991
|
+
getMinIntrinsicWidth(_height) {
|
|
992
|
+
return 0;
|
|
993
|
+
}
|
|
994
|
+
getMaxIntrinsicWidth(_height) {
|
|
995
|
+
return 0;
|
|
996
|
+
}
|
|
997
|
+
getMinIntrinsicHeight(_width) {
|
|
998
|
+
return 0;
|
|
999
|
+
}
|
|
1000
|
+
getMaxIntrinsicHeight(_width) {
|
|
1001
|
+
return 0;
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Хелпер контейнера: позиционирует ребёнка (localPosition) и прогоняет его
|
|
1005
|
+
* layout — одна строка вместо ритуала из двух-трёх записей. globalPosition
|
|
1006
|
+
* не трогает: он производный.
|
|
1007
|
+
*/
|
|
1008
|
+
layoutChild(child, x, y, constraints) {
|
|
1009
|
+
child.localPosition = new Offset(x, y);
|
|
1010
|
+
return child.layout(constraints);
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Единственный публичный вход в layout — НЕ переопределять (переопределяется
|
|
1014
|
+
* {@link performLayout}). Запоминает входные constraints (их читает
|
|
1015
|
+
* геометрическая проверка validateTree) и следит за контрактом: размер после
|
|
1016
|
+
* layout обязан удовлетворять constraints. «Контент не влез» — вопрос
|
|
1017
|
+
* отрисовки (клип), а не геометрии; занимать меньше выделенного можно только
|
|
1018
|
+
* под loose-constraints родителя. См. docs/LAYOUT.md, «Контракт performLayout».
|
|
1019
|
+
*/
|
|
1020
|
+
layout(constraints) {
|
|
1021
|
+
this.lastConstraintsValue = constraints;
|
|
1022
|
+
const result = this.performLayout(constraints);
|
|
1023
|
+
if (!constraints.isSatisfiedBy(result)) {
|
|
1024
|
+
throw new Error(`${this.constructor.name}: performLayout вернул ${result.width}×${result.height}, ` +
|
|
1025
|
+
`нарушив constraints [${constraints.minWidth}..${constraints.maxWidth}]×` +
|
|
1026
|
+
`[${constraints.minHeight}..${constraints.maxHeight}]`);
|
|
1027
|
+
}
|
|
1028
|
+
if (result.width !== this.allocatedSize.width || result.height !== this.allocatedSize.height) {
|
|
1029
|
+
throw new Error(`${this.constructor.name}: performLayout вернул ${result.width}×${result.height}, ` +
|
|
1030
|
+
`но записал allocatedSize ${this.allocatedSize.width}×${this.allocatedSize.height}`);
|
|
1031
|
+
}
|
|
1032
|
+
return result;
|
|
1033
|
+
}
|
|
1034
|
+
/** Constraints последнего layout() — null, если layout ещё не вызывался. */
|
|
1035
|
+
get lastLayoutConstraints() {
|
|
1036
|
+
return this.lastConstraintsValue;
|
|
1037
|
+
}
|
|
1038
|
+
lastConstraintsValue = null;
|
|
1039
|
+
/**
|
|
1040
|
+
* Переопределяемая реализация layout: применяет constraints к выделенной
|
|
1041
|
+
* области. Вызывается ТОЛЬКО из {@link layout} — снаружи зовите layout().
|
|
1042
|
+
*/
|
|
1043
|
+
performLayout(constraints) {
|
|
1044
|
+
const resultSize = constraints.constrain(this.allocatedSize);
|
|
1045
|
+
this.allocatedSize = resultSize;
|
|
1046
|
+
this.isLayoutDirty = false;
|
|
1047
|
+
return resultSize;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Дефолт: залить собственный фон (если он задан собственным стилем) и
|
|
1051
|
+
* отрисовать детей. Контейнер, которому нужно собственное полотно (рамка,
|
|
1052
|
+
* заголовок), рисует его и зовёт {@link renderChildren}; полностью
|
|
1053
|
+
* кастомный рендер (виртуализация, скролл-сдвиг) переопределяет метод
|
|
1054
|
+
* целиком — и тогда сам зовёт {@link paintOwnBackground} первой строкой,
|
|
1055
|
+
* если хочет фон от каскада.
|
|
1056
|
+
*/
|
|
1057
|
+
render(context) {
|
|
1058
|
+
this.paintOwnBackground(context);
|
|
1059
|
+
this.renderChildren(context);
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Заливает прямоугольник элемента resolvedStyle.bg, если bg задан
|
|
1063
|
+
* СОБСТВЕННЫМ стилем — базой или сработавшим when-вариантом. Элементы без
|
|
1064
|
+
* собственного bg прозрачны (как в CSS). Сентинелы INHERITED_* тоже
|
|
1065
|
+
* считаются «задан»: это намеренная перезаливка цветом родителя (INHERITED_BG)
|
|
1066
|
+
* или инверсия (INHERITED_FG). Выход за границы невозможен — контекст
|
|
1067
|
+
* элемента уже клипован родителем.
|
|
1068
|
+
*/
|
|
1069
|
+
paintOwnBackground(context) {
|
|
1070
|
+
if (this.appliedBgValue === undefined)
|
|
1071
|
+
return;
|
|
1072
|
+
const { fg, bg } = this.resolvedStyleValue;
|
|
1073
|
+
const { width, height } = this.layoutSize;
|
|
1074
|
+
for (let y = 0; y < height; y++) {
|
|
1075
|
+
for (let x = 0; x < width; x++) {
|
|
1076
|
+
context.setCell(x, y, { char: " ", fg, bg });
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
/** true, если фон задан собственным стилем (см. {@link paintOwnBackground}). */
|
|
1081
|
+
get hasOwnBackground() {
|
|
1082
|
+
return this.appliedBgValue !== undefined;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Сырые цвета после when-merge, до резолва токенов/сентинелов: что элемент
|
|
1086
|
+
* «попросил сам» (undefined = наследует). Для инспектора и тестов.
|
|
1087
|
+
*/
|
|
1088
|
+
get appliedStyle() {
|
|
1089
|
+
return { fg: this.appliedFgValue, bg: this.appliedBgValue };
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Каноничная отрисовка детей: каждый видимый ребёнок получает контекст со
|
|
1093
|
+
* сдвигом на свою localPosition и клипом по своим границам (дети не рисуют
|
|
1094
|
+
* за пределами выделенной области). Скрытые (hidden) пропускаются. Это тот
|
|
1095
|
+
* самый цикл, который раньше был скопирован в десяток контейнеров.
|
|
1096
|
+
*/
|
|
1097
|
+
renderChildren(context) {
|
|
1098
|
+
for (const child of this.getChildren()) {
|
|
1099
|
+
if (child.hidden)
|
|
1100
|
+
continue;
|
|
1101
|
+
const offset = new Offset(child.localPosition.dx, child.localPosition.dy);
|
|
1102
|
+
const clip = new Rect(child.globalPosition, child.layoutSize);
|
|
1103
|
+
const childContext = context.withOffset(offset).withClip(clip);
|
|
1104
|
+
// Пустой клип — ребёнок целиком вне отрисовываемой области: пропуск
|
|
1105
|
+
// всего поддерева, включая side-эффекты его render.
|
|
1106
|
+
if (childContext.clipRect.isEmpty)
|
|
1107
|
+
continue;
|
|
1108
|
+
child.render(childContext);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
// ─── Hit-testing ───
|
|
1112
|
+
//
|
|
1113
|
+
// Правило системы (Н6): рендер и Tab обходят детей ВПЕРЁД, хит-тест — тем
|
|
1114
|
+
// же списком НАЗАД (последний нарисован сверху). Правило зашито в шаблон
|
|
1115
|
+
// ниже и не переопределяется; контейнер объявляет отличия только в двух
|
|
1116
|
+
// симметричных точках — {@link hitTestChildren} (зеркало
|
|
1117
|
+
// {@link renderChildren}) и {@link hitTestSelf}.
|
|
1118
|
+
/**
|
|
1119
|
+
* Финальный шаблон хит-теста — НЕ переопределять (кастомизация — через
|
|
1120
|
+
* {@link hitTestChildren}/{@link hitTestSelf}): скрытое не кликается,
|
|
1121
|
+
* вне собственных границ хита нет (инвариант вложенности Н2 гарантирует,
|
|
1122
|
+
* что и у детей его там нет), дети опрашиваются в обратном порядке
|
|
1123
|
+
* отрисовки, затем — сам элемент.
|
|
1124
|
+
*/
|
|
1125
|
+
elementFromPoint(point) {
|
|
1126
|
+
if (this.hidden)
|
|
1127
|
+
return null; // скрытое не кликается
|
|
1128
|
+
const bounds = new Rect(this.globalPosition, this.layoutSize);
|
|
1129
|
+
if (!bounds.containsPoint(point))
|
|
1130
|
+
return null;
|
|
1131
|
+
const hit = this.hitTestChildren(point);
|
|
1132
|
+
if (hit)
|
|
1133
|
+
return hit;
|
|
1134
|
+
return this.hitTestSelf(point) ? this : null;
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Хит-тест детей — зеркало {@link renderChildren}: тот же список, обратный
|
|
1138
|
+
* порядок (верхний — первый), скрытых пропускает сам elementFromPoint
|
|
1139
|
+
* ребёнка. Переопределение — для контейнеров с осознанно другой политикой:
|
|
1140
|
+
* презентационные строки ListViewElement (`null` — мышь у контейнера),
|
|
1141
|
+
* modal-хвост OverlayLayer.
|
|
1142
|
+
*/
|
|
1143
|
+
hitTestChildren(point) {
|
|
1144
|
+
const children = this.getChildren();
|
|
1145
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
1146
|
+
const hit = children[i].elementFromPoint(point);
|
|
1147
|
+
if (hit)
|
|
1148
|
+
return hit;
|
|
1149
|
+
}
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Берёт ли элемент точку на себя, когда никто из детей её не взял.
|
|
1154
|
+
* Дефолт — да (непрозрачный бокс). `false` — прозрачный для кликов
|
|
1155
|
+
* контейнер: точка проваливается к элементам ПОД ним (OverlayLayer без
|
|
1156
|
+
* попапа в этой точке).
|
|
1157
|
+
*/
|
|
1158
|
+
hitTestSelf(_point) {
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
1161
|
+
// ─── Query API (querySelector / querySelectorAll) ───
|
|
1162
|
+
querySelector(selector) {
|
|
1163
|
+
return querySelector(this, selector);
|
|
1164
|
+
}
|
|
1165
|
+
querySelectorAll(selector) {
|
|
1166
|
+
return querySelectorAll(this, selector);
|
|
1167
|
+
}
|
|
1168
|
+
}
|