@tuidom/elements 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.
Files changed (89) hide show
  1. package/dist/body/bodyElement.d.ts +33 -0
  2. package/dist/body/bodyElement.js +87 -0
  3. package/dist/button/buttonElement.d.ts +18 -0
  4. package/dist/button/buttonElement.js +68 -0
  5. package/dist/completionlist/completionDetailsElement.d.ts +49 -0
  6. package/dist/completionlist/completionDetailsElement.js +165 -0
  7. package/dist/completionlist/completionItemKindIcon.d.ts +2 -0
  8. package/dist/completionlist/completionItemKindIcon.js +43 -0
  9. package/dist/completionlist/completionListElement.d.ts +102 -0
  10. package/dist/completionlist/completionListElement.js +309 -0
  11. package/dist/completionlist/completionWidgetElement.d.ts +41 -0
  12. package/dist/completionlist/completionWidgetElement.js +102 -0
  13. package/dist/contextview/contextMenuController.d.ts +23 -0
  14. package/dist/contextview/contextMenuController.js +75 -0
  15. package/dist/contextview/overlayHostElement.d.ts +23 -0
  16. package/dist/contextview/overlayHostElement.js +47 -0
  17. package/dist/editorgroup/editorTabItemElement.d.ts +41 -0
  18. package/dist/editorgroup/editorTabItemElement.js +186 -0
  19. package/dist/editorgroup/editorTabStripElement.d.ts +33 -0
  20. package/dist/editorgroup/editorTabStripElement.js +118 -0
  21. package/dist/editorpart/editorPartElement.d.ts +95 -0
  22. package/dist/editorpart/editorPartElement.js +285 -0
  23. package/dist/inputbox/inputElement.d.ts +34 -0
  24. package/dist/inputbox/inputElement.js +207 -0
  25. package/dist/inputbox/inputState.d.ts +69 -0
  26. package/dist/inputbox/inputState.js +326 -0
  27. package/dist/layout/boxContainerElement.d.ts +27 -0
  28. package/dist/layout/boxContainerElement.js +121 -0
  29. package/dist/layout/boxElement.d.ts +4 -0
  30. package/dist/layout/boxElement.js +30 -0
  31. package/dist/layout/fillerElement.d.ts +12 -0
  32. package/dist/layout/fillerElement.js +16 -0
  33. package/dist/layout/fitContentElement.d.ts +18 -0
  34. package/dist/layout/fitContentElement.js +46 -0
  35. package/dist/layout/hFlexElement.d.ts +26 -0
  36. package/dist/layout/hFlexElement.js +134 -0
  37. package/dist/layout/paddingContainerElement.d.ts +30 -0
  38. package/dist/layout/paddingContainerElement.js +88 -0
  39. package/dist/layout/sizedBoxElement.d.ts +30 -0
  40. package/dist/layout/sizedBoxElement.js +74 -0
  41. package/dist/layout/vFlexElement.d.ts +32 -0
  42. package/dist/layout/vFlexElement.js +136 -0
  43. package/dist/layout/vStackElement.d.ts +18 -0
  44. package/dist/layout/vStackElement.js +74 -0
  45. package/dist/list/listViewElement.d.ts +232 -0
  46. package/dist/list/listViewElement.js +944 -0
  47. package/dist/menu/menuBarElement.d.ts +47 -0
  48. package/dist/menu/menuBarElement.js +285 -0
  49. package/dist/menu/menuBarItemElement.d.ts +20 -0
  50. package/dist/menu/menuBarItemElement.js +68 -0
  51. package/dist/menu/popupMenuElement.d.ts +81 -0
  52. package/dist/menu/popupMenuElement.js +318 -0
  53. package/dist/menu/popupMenuItemElement.d.ts +24 -0
  54. package/dist/menu/popupMenuItemElement.js +89 -0
  55. package/dist/panel/panelContainerElement.d.ts +64 -0
  56. package/dist/panel/panelContainerElement.js +219 -0
  57. package/dist/quickpick/quickPickElement.d.ts +161 -0
  58. package/dist/quickpick/quickPickElement.js +561 -0
  59. package/dist/sash/sashElement.d.ts +30 -0
  60. package/dist/sash/sashElement.js +78 -0
  61. package/dist/scrollbar/iScrollable.d.ts +8 -0
  62. package/dist/scrollbar/iScrollable.js +1 -0
  63. package/dist/scrollbar/scrollBarRenderer.d.ts +36 -0
  64. package/dist/scrollbar/scrollBarRenderer.js +76 -0
  65. package/dist/scrollbar/scrollContainerElement.d.ts +39 -0
  66. package/dist/scrollbar/scrollContainerElement.js +91 -0
  67. package/dist/scrollbar/scrollViewport.d.ts +28 -0
  68. package/dist/scrollbar/scrollViewport.js +65 -0
  69. package/dist/scrollbar/scrollableElement.d.ts +22 -0
  70. package/dist/scrollbar/scrollableElement.js +42 -0
  71. package/dist/selectbox/selectBoxElement.d.ts +48 -0
  72. package/dist/selectbox/selectBoxElement.js +161 -0
  73. package/dist/terminal/encodeKeyForPty.d.ts +3 -0
  74. package/dist/terminal/encodeKeyForPty.js +56 -0
  75. package/dist/terminal/terminalViewElement.d.ts +17 -0
  76. package/dist/terminal/terminalViewElement.js +172 -0
  77. package/dist/text/textBlockElement.d.ts +13 -0
  78. package/dist/text/textBlockElement.js +35 -0
  79. package/dist/text/textLabelElement.d.ts +28 -0
  80. package/dist/text/textLabelElement.js +77 -0
  81. package/dist/titledpanel/titledPanelElement.d.ts +14 -0
  82. package/dist/titledpanel/titledPanelElement.js +40 -0
  83. package/dist/tree/iTreeDataProvider.d.ts +19 -0
  84. package/dist/tree/iTreeDataProvider.js +1 -0
  85. package/dist/tree/treeViewElement.d.ts +116 -0
  86. package/dist/tree/treeViewElement.js +748 -0
  87. package/dist/workbenchlayout/workbenchLayoutElement.d.ts +60 -0
  88. package/dist/workbenchlayout/workbenchLayoutElement.js +203 -0
  89. package/package.json +33 -0
@@ -0,0 +1,285 @@
1
+ import { BoxConstraints, Size } from "@tuidom/core/common/geometryPromitives";
2
+ import { TUIElement } from "@tuidom/core/dom/tuiElement";
3
+ import { SashElement } from "../sash/sashElement.js";
4
+ /** Минимальная ширина группы в колонках — узже таб-стрип и код нечитаемы. */
5
+ export const MIN_GROUP_MAIN_COLS = 20;
6
+ /** Минимальная высота группы в рядах — таб-стрип + пара строк кода. */
7
+ export const MIN_GROUP_MAIN_ROWS = 5;
8
+ /**
9
+ * Полоса групп редакторов по одной оси (аналог grid-вью `EditorPart` VS Code,
10
+ * сведённого к одному ряду/колонке): N вью групп + N−1 сашей между ними.
11
+ * Пропорции — нормированные веса, переживают ресайз терминала; политику весов
12
+ * при сплите/схлопывании задаёт владелец (`EditorPartComponent`) через
13
+ * {@link setViews}. Саши лежат ПОВЕРХ граничной ячейки (пуш последними —
14
+ * hit-test): в columns — поверх первой колонки следующей группы, в rows —
15
+ * поверх ПОСЛЕДНЕЙ строки предыдущей: первая строка нижней группы это её
16
+ * табы, и наложение отняло бы у них мышь, а нижняя строка редактора менее
17
+ * ценна. Каждый саш укладывается явно в каждом layout.
18
+ *
19
+ * Деградация «не влезаем»: контейнер уже, чем N×минимум, — display-only равное
20
+ * деление без мутации весов; при возврате размера пропорции восстанавливаются.
21
+ * Отказ в НОВОМ сплите при нехватке места — {@link canFit}, решает владелец.
22
+ */
23
+ export class EditorPartElement extends TUIElement {
24
+ views = [];
25
+ /** Нормированные доли групп; длина всегда равна `views.length`. */
26
+ weightsValue = [];
27
+ sashes = [];
28
+ orientationValue = "columns";
29
+ maximizedIndexValue = null;
30
+ /** Основные размеры групп из последнего layout — база для drag-математики. */
31
+ lastSizes = [];
32
+ /**
33
+ * Смена весов действием пользователя (drag саша, nudge/even команды) — для
34
+ * персиста. Плейн-колбэк (TUIDom без DI); НЕ вызывается из performLayout.
35
+ */
36
+ onDidChangeWeights;
37
+ /** Ось полосы. Смена сохраняет веса (US-19: тумблер не теряет пропорций). */
38
+ get orientation() {
39
+ return this.orientationValue;
40
+ }
41
+ set orientation(value) {
42
+ if (this.orientationValue === value)
43
+ return;
44
+ this.orientationValue = value;
45
+ this.rebuildSashes();
46
+ this.markDirty();
47
+ }
48
+ /** Нормированные доли групп (копия). */
49
+ get weights() {
50
+ return [...this.weightsValue];
51
+ }
52
+ /** Индекс максимизированной группы, либо `null` (обычная полоса). */
53
+ get maximizedIndex() {
54
+ return this.maximizedIndexValue;
55
+ }
56
+ set maximizedIndex(value) {
57
+ const clamped = value !== null && value >= 0 && value < this.views.length ? value : null;
58
+ if (this.maximizedIndexValue === clamped)
59
+ return;
60
+ this.maximizedIndexValue = clamped;
61
+ this.syncChildren();
62
+ this.markDirty();
63
+ }
64
+ /**
65
+ * Задаёт состав полосы и доли. Политика долей — у владельца (сплит делит
66
+ * долю активной группы пополам, join отдаёт долю соседу и т.п.); элемент
67
+ * лишь нормирует. Пустые/невалидные веса → равные доли. Максимизация
68
+ * сбрасывается: состав сменился, старый индекс — про другую полосу.
69
+ */
70
+ setViews(views, weights) {
71
+ this.views = [...views];
72
+ const raw = weights !== undefined && weights.length === views.length ? [...weights] : [];
73
+ this.weightsValue = this.normalizeWeights(raw);
74
+ this.maximizedIndexValue = null;
75
+ this.rebuildSashes();
76
+ this.markDirty();
77
+ }
78
+ /** Выравнивает доли (Reset Editor Group Sizes). */
79
+ evenWeights() {
80
+ if (this.views.length === 0)
81
+ return;
82
+ this.weightsValue = this.normalizeWeights([]);
83
+ this.markDirty();
84
+ this.onDidChangeWeights?.();
85
+ }
86
+ /**
87
+ * Задаёт доли программно (рестор персиста). Невалидные/чужой длины веса
88
+ * нормализуются в равные; событие не файрится — это не действие пользователя.
89
+ */
90
+ setWeights(weights) {
91
+ this.weightsValue = this.normalizeWeights([...weights]);
92
+ this.markDirty();
93
+ }
94
+ /**
95
+ * Меняет основной размер группы `index` на `deltaCells` ячеек за счёт соседа
96
+ * справа/снизу (последняя группа растёт за счёт соседа слева/сверху) —
97
+ * increase/decreaseEditorWidth/Height. Кламп по минимуму обеих.
98
+ */
99
+ nudgeWeight(index, deltaCells) {
100
+ if (this.views.length < 2 || index < 0 || index >= this.views.length)
101
+ return;
102
+ const neighbor = index < this.views.length - 1 ? index + 1 : index - 1;
103
+ const sizes = this.currentSizes();
104
+ if (sizes === null)
105
+ return;
106
+ const min = this.minMainSize();
107
+ const pair = sizes[index] + sizes[neighbor];
108
+ const target = Math.max(min, Math.min(sizes[index] + deltaCells, pair - min));
109
+ sizes[index] = target;
110
+ sizes[neighbor] = pair - target;
111
+ this.weightsValue = this.normalizeWeights(sizes);
112
+ this.markDirty();
113
+ this.onDidChangeWeights?.();
114
+ }
115
+ /** Влезет ли полоса из `count` групп в текущий основной размер. */
116
+ canFit(count) {
117
+ const main = this.mainAxisSize();
118
+ // До первого layout судить не по чему — не отказываем (рестор решает сам).
119
+ if (main <= 0)
120
+ return true;
121
+ return count * this.minMainSize() <= main;
122
+ }
123
+ inspectState() {
124
+ return {
125
+ orientation: this.orientationValue,
126
+ weights: this.weightsValue.map((w) => Number(w.toFixed(4))),
127
+ maximizedIndex: this.maximizedIndexValue,
128
+ groupCount: this.views.length,
129
+ };
130
+ }
131
+ /** Основной размер полосы по текущей оси (из последнего layout). */
132
+ mainAxisSize() {
133
+ return this.orientationValue === "columns" ? this.layoutSize.width : this.layoutSize.height;
134
+ }
135
+ minMainSize() {
136
+ return this.orientationValue === "columns" ? MIN_GROUP_MAIN_COLS : MIN_GROUP_MAIN_ROWS;
137
+ }
138
+ /** Текущие основные размеры групп; `null` до первого layout. */
139
+ currentSizes() {
140
+ if (this.lastSizes.length !== this.views.length)
141
+ return null;
142
+ return [...this.lastSizes];
143
+ }
144
+ /** Нормирует произвольные положительные величины в доли; пусто/мусор → равные. */
145
+ normalizeWeights(raw) {
146
+ const count = this.views.length;
147
+ if (count === 0)
148
+ return [];
149
+ const positive = raw.length === count && raw.every((w) => Number.isFinite(w) && w > 0);
150
+ if (!positive)
151
+ return new Array(count).fill(1 / count);
152
+ const total = raw.reduce((sum, w) => sum + w, 0);
153
+ return raw.map((w) => w / total);
154
+ }
155
+ /** Пересоздаёт N−1 сашей под текущие состав и ось; вешает drag-обработчики. */
156
+ rebuildSashes() {
157
+ const sashOrientation = this.orientationValue === "columns" ? "vertical" : "horizontal";
158
+ this.sashes = this.views.slice(1).map((_, i) => {
159
+ const sash = new SashElement(sashOrientation);
160
+ sash.onDrag = (boundaryScreen) => {
161
+ this.handleSashDrag(i, boundaryScreen);
162
+ };
163
+ return sash;
164
+ });
165
+ this.syncChildren();
166
+ }
167
+ /** Дети: вью групп, затем саши (поверх границ — hit-test); максимизация прячет прочее. */
168
+ syncChildren() {
169
+ const maximized = this.maximizedIndexValue;
170
+ for (const [i, view] of this.views.entries()) {
171
+ view.hidden = maximized !== null && i !== maximized;
172
+ }
173
+ for (const sash of this.sashes) {
174
+ sash.hidden = maximized !== null;
175
+ }
176
+ this.setChildren([...this.views, ...this.sashes]);
177
+ }
178
+ /**
179
+ * Drag саша `index` (граница групп index/index+1): абсолютная координата →
180
+ * новый размер левой/верхней группы, кламп по минимуму пары, веса — из
181
+ * фактических размеров. Веса остальных групп не трогаются.
182
+ */
183
+ handleSashDrag(index, boundaryScreen) {
184
+ const sizes = this.currentSizes();
185
+ if (sizes === null)
186
+ return;
187
+ const origin = this.orientationValue === "columns" ? this.globalPosition.x : this.globalPosition.y;
188
+ let offset = 0;
189
+ for (let i = 0; i < index; i++)
190
+ offset += sizes[i];
191
+ const min = this.minMainSize();
192
+ const pair = sizes[index] + sizes[index + 1];
193
+ // В rows саш лежит на последней строке верхней группы (не на первой
194
+ // нижней) — курсор на строке Y означает размер верхней Y+1.
195
+ const inset = this.orientationValue === "rows" ? 1 : 0;
196
+ const target = Math.max(min, Math.min(boundaryScreen - origin - offset + inset, pair - min));
197
+ sizes[index] = target;
198
+ sizes[index + 1] = pair - target;
199
+ this.weightsValue = this.normalizeWeights(sizes);
200
+ this.markDirty();
201
+ this.onDidChangeWeights?.();
202
+ }
203
+ /**
204
+ * Целочисленные основные размеры из долей: floor + остатки самым большим
205
+ * дробным частям (сумма всегда равна `mainSize`), затем min-клампы за счёт
206
+ * самых широких. Полоса уже N×минимума — display-only равное деление, веса
207
+ * не мутируются (US-25: пропорции восстановятся при возврате размера).
208
+ */
209
+ computeSizes(mainSize) {
210
+ const count = this.views.length;
211
+ /* v8 ignore start -- performLayout не зовёт с пустой полосой */
212
+ if (count === 0)
213
+ return [];
214
+ /* v8 ignore stop */
215
+ const min = this.minMainSize();
216
+ if (mainSize < count * min) {
217
+ return this.distributeEvenly(mainSize, count);
218
+ }
219
+ const raw = this.weightsValue.map((w) => w * mainSize);
220
+ const sizes = raw.map((r) => Math.floor(r));
221
+ let remainder = mainSize - sizes.reduce((sum, s) => sum + s, 0);
222
+ const byFraction = raw.map((r, i) => ({ i, frac: r - Math.floor(r) })).sort((a, b) => b.frac - a.frac);
223
+ for (const { i } of byFraction) {
224
+ if (remainder <= 0)
225
+ break;
226
+ sizes[i]++;
227
+ remainder--;
228
+ }
229
+ // Min-клампы: добираем дефицит у самых больших (влезаем по проверке выше).
230
+ for (let i = 0; i < count; i++) {
231
+ while (sizes[i] < min) {
232
+ const largest = sizes.indexOf(Math.max(...sizes));
233
+ /* v8 ignore start -- защитный выход: mainSize >= count*min гарантирует донора */
234
+ if (largest === i || sizes[largest] <= min)
235
+ break;
236
+ /* v8 ignore stop */
237
+ sizes[largest]--;
238
+ sizes[i]++;
239
+ }
240
+ }
241
+ return sizes;
242
+ }
243
+ distributeEvenly(mainSize, count) {
244
+ const base = Math.floor(mainSize / count);
245
+ const sizes = new Array(count).fill(base);
246
+ for (let i = 0; i < mainSize - base * count; i++)
247
+ sizes[i]++;
248
+ return sizes;
249
+ }
250
+ performLayout(constraints) {
251
+ const containerSize = super.performLayout(constraints);
252
+ if (this.views.length === 0) {
253
+ this.lastSizes = [];
254
+ return containerSize;
255
+ }
256
+ const maximized = this.maximizedIndexValue;
257
+ if (maximized !== null) {
258
+ // Максимизированная группа занимает всё; остальные скрыты (hidden в
259
+ // syncChildren), их layout не трогаем — размеры вернутся с полосой.
260
+ this.layoutChild(this.views[maximized], 0, 0, BoxConstraints.tight(containerSize));
261
+ return containerSize;
262
+ }
263
+ const columns = this.orientationValue === "columns";
264
+ const mainSize = columns ? containerSize.width : containerSize.height;
265
+ const sizes = this.computeSizes(mainSize);
266
+ this.lastSizes = [...sizes];
267
+ let offset = 0;
268
+ for (const [i, view] of this.views.entries()) {
269
+ const size = columns ? new Size(sizes[i], containerSize.height) : new Size(containerSize.width, sizes[i]);
270
+ this.layoutChild(view, columns ? offset : 0, columns ? 0 : offset, BoxConstraints.tight(size));
271
+ offset += sizes[i];
272
+ // Саш i на границе групп i/i+1: в columns — поверх первой колонки
273
+ // следующей группы, в rows — поверх последней строки предыдущей
274
+ // (первая строка нижней это её табы). Явная укладка каждый layout:
275
+ // ленивый layoutSize со старым боксом ломает hit-test (контракт
276
+ // WorkbenchLayoutElement).
277
+ if (i < this.sashes.length) {
278
+ const sashSize = columns ? new Size(1, containerSize.height) : new Size(containerSize.width, 1);
279
+ const sashMain = columns ? offset : Math.max(0, offset - 1);
280
+ this.layoutChild(this.sashes[i], columns ? sashMain : 0, columns ? 0 : sashMain, BoxConstraints.tight(sashSize));
281
+ }
282
+ }
283
+ return containerSize;
284
+ }
285
+ }
@@ -0,0 +1,34 @@
1
+ import { BoxConstraints, Size } from "@tuidom/core/common/geometryPromitives";
2
+ import type { TUIEventBase } from "@tuidom/core/dom/events/tuiEventBase";
3
+ import { RenderContext, TUIElement } from "@tuidom/core/dom/tuiElement";
4
+ import { InputState } from "./inputState.js";
5
+ /**
6
+ * Single-line text input widget.
7
+ *
8
+ * By default rendered without a border — just a plain editable text line.
9
+ * Set `showBorder = true` to get a Unicode box-drawing frame around it.
10
+ * The border colour changes between focused (#007FD4) and unfocused (#3C3C3C).
11
+ *
12
+ * Usage:
13
+ * const input = new InputElement();
14
+ * input.placeholder = "Search…";
15
+ * input.onChange = value => console.log(value);
16
+ */
17
+ export declare class InputElement extends TUIElement {
18
+ readonly inputState: InputState;
19
+ placeholder: string | undefined;
20
+ showBorder: boolean;
21
+ onChange: ((value: string) => void) | undefined;
22
+ private scrollX;
23
+ constructor(inputState?: InputState);
24
+ /** Наблюдаемость для инспектора/e2e: текст, курсор, выделение, плейсхолдер. */
25
+ inspectState(): Record<string, unknown>;
26
+ getMinIntrinsicWidth(_height: number): number;
27
+ getMinIntrinsicHeight(_width: number): number;
28
+ getMaxIntrinsicHeight(_width: number): number;
29
+ protected performLayout(constraints: BoxConstraints): Size;
30
+ render(context: RenderContext): void;
31
+ private renderTextWithSelection;
32
+ private renderBorder;
33
+ protected performDefaultAction(event: TUIEventBase): void;
34
+ }
@@ -0,0 +1,207 @@
1
+ import { DisplayLine } from "@tuidom/core/common/displayLine";
2
+ import { BoxConstraints, Point, Rect, Size } from "@tuidom/core/common/geometryPromitives";
3
+ import { TUIKeyboardEvent } from "@tuidom/core/dom/events/tuiKeyboardEvent";
4
+ import { RenderContext, TUIElement } from "@tuidom/core/dom/tuiElement";
5
+ import { InputState } from "./inputState.js";
6
+ /**
7
+ * Single-line text input widget.
8
+ *
9
+ * By default rendered without a border — just a plain editable text line.
10
+ * Set `showBorder = true` to get a Unicode box-drawing frame around it.
11
+ * The border colour changes between focused (#007FD4) and unfocused (#3C3C3C).
12
+ *
13
+ * Usage:
14
+ * const input = new InputElement();
15
+ * input.placeholder = "Search…";
16
+ * input.onChange = value => console.log(value);
17
+ */
18
+ export class InputElement extends TUIElement {
19
+ inputState;
20
+ placeholder = undefined;
21
+ showBorder = false;
22
+ onChange = undefined;
23
+ scrollX = 0;
24
+ constructor(inputState) {
25
+ super();
26
+ this.inputState = inputState ?? new InputState();
27
+ this.focusable = true;
28
+ // Цвета — токены темы (Н3); рамка в фокусе перекрашивается тем, что
29
+ // ядро ставит focus-состояние и перерезолвит стиль (render дёргается).
30
+ this.style = { fg: "input.foreground", bg: "input.background" };
31
+ }
32
+ /** Наблюдаемость для инспектора/e2e: текст, курсор, выделение, плейсхолдер. */
33
+ inspectState() {
34
+ return {
35
+ value: this.inputState.value,
36
+ cursorOffset: this.inputState.cursorOffset,
37
+ hasSelection: this.inputState.hasSelection,
38
+ showsPlaceholder: this.inputState.value.length === 0 && this.placeholder !== undefined,
39
+ };
40
+ }
41
+ // ─── Layout ─────────────────────────────────────────────────────────────
42
+ getMinIntrinsicWidth(_height) {
43
+ return this.showBorder ? 5 : 3;
44
+ }
45
+ getMinIntrinsicHeight(_width) {
46
+ return this.showBorder ? 3 : 1;
47
+ }
48
+ getMaxIntrinsicHeight(_width) {
49
+ return this.showBorder ? 3 : 1;
50
+ }
51
+ performLayout(constraints) {
52
+ const height = this.getMaxIntrinsicHeight(0);
53
+ const width = Number.isFinite(constraints.maxWidth) ? constraints.maxWidth : Math.max(constraints.minWidth, 20);
54
+ return super.performLayout(BoxConstraints.tight(constraints.constrain(new Size(width, height))));
55
+ }
56
+ // ─── Render ─────────────────────────────────────────────────────────────
57
+ render(context) {
58
+ const w = this.layoutSize.width;
59
+ const h = this.layoutSize.height;
60
+ const text = this.inputState.text;
61
+ const cursorOffset = this.inputState.cursorOffset;
62
+ const focused = this.isFocused;
63
+ const contentXStart = this.showBorder ? 1 : 0;
64
+ const contentY = this.showBorder ? 1 : 0;
65
+ const contentWidth = w - (this.showBorder ? 2 : 0);
66
+ if (contentWidth <= 0)
67
+ return;
68
+ // Update horizontal scroll to keep cursor visible
69
+ const dl = new DisplayLine(text);
70
+ const cursorCol = dl.offsetToColumn(cursorOffset);
71
+ if (cursorCol < this.scrollX) {
72
+ this.scrollX = cursorCol;
73
+ }
74
+ else if (cursorCol >= this.scrollX + contentWidth) {
75
+ this.scrollX = cursorCol - contentWidth + 1;
76
+ }
77
+ // Draw border
78
+ if (this.showBorder) {
79
+ this.renderBorder(context, w, h, focused);
80
+ }
81
+ const { fg, bg } = this.resolvedStyle;
82
+ // Fill content row background
83
+ for (let x = contentXStart; x < contentXStart + contentWidth; x++) {
84
+ context.setCell(x, contentY, { char: " ", fg, bg });
85
+ }
86
+ // Content area clip (screen-space coordinates)
87
+ const contentClip = new Rect(new Point(this.globalPosition.x + contentXStart, this.globalPosition.y + contentY), new Size(contentWidth, 1));
88
+ const textContext = context.withClip(contentClip);
89
+ // Draw text or placeholder
90
+ if (text.length === 0 && this.placeholder !== undefined) {
91
+ textContext.drawText(contentXStart, contentY, this.placeholder, {
92
+ fg: this.styleVar("input.placeholderForeground"),
93
+ bg,
94
+ });
95
+ }
96
+ else if (this.inputState.hasSelection) {
97
+ this.renderTextWithSelection(textContext, dl, text, contentXStart, contentY);
98
+ }
99
+ else {
100
+ textContext.drawText(contentXStart - this.scrollX, contentY, text, { fg, bg });
101
+ }
102
+ // Hardware cursor
103
+ if (focused) {
104
+ const cursorX = contentXStart + (cursorCol - this.scrollX);
105
+ /* v8 ignore start -- scrollX was just adjusted above to keep cursorCol within [scrollX, scrollX+contentWidth), so cursorX is always in bounds here; the false side is unreachable */
106
+ if (cursorX >= contentXStart && cursorX < contentXStart + contentWidth) {
107
+ context.setCursorPosition(cursorX, contentY);
108
+ }
109
+ /* v8 ignore stop */
110
+ }
111
+ }
112
+ renderTextWithSelection(context, dl, text, contentXStart, contentY) {
113
+ const selStart = this.inputState.selectionStart;
114
+ const selEnd = this.inputState.selectionEnd;
115
+ const before = text.slice(0, selStart);
116
+ const selected = text.slice(selStart, selEnd);
117
+ const after = text.slice(selEnd);
118
+ const selStartCol = dl.offsetToColumn(selStart);
119
+ const selEndCol = dl.offsetToColumn(selEnd);
120
+ const { fg, bg } = this.resolvedStyle;
121
+ if (before.length > 0) {
122
+ context.drawText(contentXStart - this.scrollX, contentY, before, { fg, bg });
123
+ }
124
+ /* v8 ignore start -- this branch only runs when inputState.hasSelection (selStart !== selEnd), so the slice is always non-empty; the false side is unreachable */
125
+ if (selected.length > 0) {
126
+ context.drawText(contentXStart + selStartCol - this.scrollX, contentY, selected, {
127
+ fg,
128
+ bg: this.styleVar("input.selectionBackground"),
129
+ });
130
+ }
131
+ /* v8 ignore stop */
132
+ // Fill selection background for empty tail columns up to selEndCol (handles wide chars)
133
+ for (let col = selStartCol; col < selEndCol; col++) {
134
+ const x = contentXStart + col - this.scrollX;
135
+ // Overwrite only cells that didn't get text (will be spaces from background fill)
136
+ // drawText already covered the chars, this handles any residual slots
137
+ void x;
138
+ }
139
+ if (after.length > 0) {
140
+ context.drawText(contentXStart + selEndCol - this.scrollX, contentY, after, { fg, bg });
141
+ }
142
+ }
143
+ renderBorder(context, w, h, focused) {
144
+ const borderFg = this.styleVar(focused ? "focusBorder" : "input.border");
145
+ context.drawBox(0, 0, w, h, { fg: borderFg, bg: this.resolvedStyle.bg });
146
+ }
147
+ // ─── Input ──────────────────────────────────────────────────────────────
148
+ performDefaultAction(event) {
149
+ if (event.type === "paste") {
150
+ // Single-line field: flatten any newlines from the pasted block into spaces.
151
+ const text = event.text.replace(/\n/g, " ");
152
+ if (text !== "") {
153
+ event.preventDefault();
154
+ this.inputState.insert(text);
155
+ this.onChange?.(this.inputState.value);
156
+ this.markDirty();
157
+ }
158
+ return;
159
+ }
160
+ if (event.type !== "keydown") {
161
+ // Non-keydown events (notably mousedown) fall through to the base
162
+ // handler, which focuses a tabbable element — so a click focuses the
163
+ // field. Without this, the input could only be focused programmatically.
164
+ super.performDefaultAction(event);
165
+ return;
166
+ }
167
+ const keyEvent = event;
168
+ if (keyEvent.key === "Backspace") {
169
+ event.preventDefault();
170
+ this.inputState.deleteLeft();
171
+ this.onChange?.(this.inputState.value);
172
+ this.markDirty();
173
+ }
174
+ else if (keyEvent.key === "Delete") {
175
+ event.preventDefault();
176
+ this.inputState.deleteRight();
177
+ this.onChange?.(this.inputState.value);
178
+ this.markDirty();
179
+ }
180
+ else if (keyEvent.key === "ArrowLeft") {
181
+ event.preventDefault();
182
+ this.inputState.moveCursorLeft();
183
+ this.markDirty();
184
+ }
185
+ else if (keyEvent.key === "ArrowRight") {
186
+ event.preventDefault();
187
+ this.inputState.moveCursorRight();
188
+ this.markDirty();
189
+ }
190
+ else if (keyEvent.key === "Home") {
191
+ event.preventDefault();
192
+ this.inputState.moveCursorToStart();
193
+ this.markDirty();
194
+ }
195
+ else if (keyEvent.key === "End") {
196
+ event.preventDefault();
197
+ this.inputState.moveCursorToEnd();
198
+ this.markDirty();
199
+ }
200
+ else if (keyEvent.key.length === 1 && !keyEvent.ctrlKey && !keyEvent.altKey) {
201
+ event.preventDefault();
202
+ this.inputState.insert(keyEvent.key);
203
+ this.onChange?.(this.inputState.value);
204
+ this.markDirty();
205
+ }
206
+ }
207
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Pure state model for a single-line text input.
3
+ * Tracks the text and cursor position (as a grapheme-aware offset).
4
+ * No UI dependencies.
5
+ */
6
+ export declare class InputState {
7
+ private textValue;
8
+ private cursorOffsetValue;
9
+ private anchorOffset;
10
+ private undoStack;
11
+ private redoStack;
12
+ private lastEditKind;
13
+ get text(): string;
14
+ get cursorOffset(): number;
15
+ get value(): string;
16
+ set value(v: string);
17
+ get canUndo(): boolean;
18
+ get canRedo(): boolean;
19
+ undo(): void;
20
+ redo(): void;
21
+ private snapshot;
22
+ private restore;
23
+ /** Push a snapshot of the current state and invalidate the redo stack. */
24
+ private pushUndo;
25
+ /** Start a brand-new undo group: snapshot now, and don't coalesce the next edit into it. */
26
+ private breakUndo;
27
+ /** Record an edit that may coalesce with the previous one when it is of the same kind. */
28
+ private recordEdit;
29
+ get hasSelection(): boolean;
30
+ get selectionStart(): number;
31
+ get selectionEnd(): number;
32
+ get selectedText(): string;
33
+ clearSelection(): void;
34
+ selectAll(): void;
35
+ selectLeft(): void;
36
+ selectRight(): void;
37
+ selectToStart(): void;
38
+ selectToEnd(): void;
39
+ selectWordLeft(): void;
40
+ selectWordRight(): void;
41
+ private deleteSelection;
42
+ /** Insert text at the current cursor position (replaces selection if any). */
43
+ insert(chars: string): void;
44
+ /** Delete the grapheme cluster immediately to the left of the cursor (Backspace). */
45
+ deleteLeft(): void;
46
+ /** Delete the grapheme cluster immediately to the right of the cursor (Delete key). */
47
+ deleteRight(): void;
48
+ /** Move cursor one grapheme to the left (ArrowLeft). */
49
+ moveCursorLeft(): void;
50
+ /** Move cursor one grapheme to the right (ArrowRight). */
51
+ moveCursorRight(): void;
52
+ /** Move cursor to the beginning of the line (Home). */
53
+ moveCursorToStart(): void;
54
+ /** Move cursor to the end of the line (End). */
55
+ moveCursorToEnd(): void;
56
+ /** Move cursor to the start of the previous word (Ctrl+ArrowLeft). */
57
+ moveCursorWordLeft(): void;
58
+ /** Move cursor to the end of the next word (Ctrl+ArrowRight). */
59
+ moveCursorWordRight(): void;
60
+ /** Delete from cursor to the previous word boundary (Ctrl+Backspace). */
61
+ deleteWordLeft(): void;
62
+ /** Delete from cursor to the next word boundary (Ctrl+Delete). */
63
+ deleteWordRight(): void;
64
+ private moveCursorLeftRaw;
65
+ private moveCursorRightRaw;
66
+ private wordBoundaryLeft;
67
+ private wordBoundaryRight;
68
+ private static isWordChar;
69
+ }