@teammates/consolonia 0.2.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 (104) hide show
  1. package/README.md +48 -0
  2. package/dist/__tests__/ansi.test.d.ts +1 -0
  3. package/dist/__tests__/ansi.test.js +520 -0
  4. package/dist/__tests__/chat-view.test.d.ts +4 -0
  5. package/dist/__tests__/chat-view.test.js +480 -0
  6. package/dist/__tests__/drawing.test.d.ts +4 -0
  7. package/dist/__tests__/drawing.test.js +426 -0
  8. package/dist/__tests__/input.test.d.ts +5 -0
  9. package/dist/__tests__/input.test.js +911 -0
  10. package/dist/__tests__/layout.test.d.ts +4 -0
  11. package/dist/__tests__/layout.test.js +689 -0
  12. package/dist/__tests__/pixel.test.d.ts +1 -0
  13. package/dist/__tests__/pixel.test.js +674 -0
  14. package/dist/__tests__/render.test.d.ts +1 -0
  15. package/dist/__tests__/render.test.js +400 -0
  16. package/dist/__tests__/styled.test.d.ts +4 -0
  17. package/dist/__tests__/styled.test.js +149 -0
  18. package/dist/__tests__/widgets.test.d.ts +5 -0
  19. package/dist/__tests__/widgets.test.js +924 -0
  20. package/dist/ansi/esc.d.ts +61 -0
  21. package/dist/ansi/esc.js +85 -0
  22. package/dist/ansi/output.d.ts +66 -0
  23. package/dist/ansi/output.js +192 -0
  24. package/dist/ansi/strip.d.ts +16 -0
  25. package/dist/ansi/strip.js +74 -0
  26. package/dist/app.d.ts +68 -0
  27. package/dist/app.js +297 -0
  28. package/dist/drawing/clip.d.ts +23 -0
  29. package/dist/drawing/clip.js +67 -0
  30. package/dist/drawing/context.d.ts +77 -0
  31. package/dist/drawing/context.js +275 -0
  32. package/dist/index.d.ts +48 -0
  33. package/dist/index.js +63 -0
  34. package/dist/input/escape-matcher.d.ts +27 -0
  35. package/dist/input/escape-matcher.js +253 -0
  36. package/dist/input/events.d.ts +49 -0
  37. package/dist/input/events.js +17 -0
  38. package/dist/input/index.d.ts +15 -0
  39. package/dist/input/index.js +14 -0
  40. package/dist/input/matcher.d.ts +23 -0
  41. package/dist/input/matcher.js +14 -0
  42. package/dist/input/mouse-matcher.d.ts +27 -0
  43. package/dist/input/mouse-matcher.js +142 -0
  44. package/dist/input/paste-matcher.d.ts +23 -0
  45. package/dist/input/paste-matcher.js +104 -0
  46. package/dist/input/processor.d.ts +51 -0
  47. package/dist/input/processor.js +145 -0
  48. package/dist/input/raw-mode.d.ts +13 -0
  49. package/dist/input/raw-mode.js +24 -0
  50. package/dist/input/text-matcher.d.ts +14 -0
  51. package/dist/input/text-matcher.js +32 -0
  52. package/dist/layout/box.d.ts +33 -0
  53. package/dist/layout/box.js +92 -0
  54. package/dist/layout/column.d.ts +21 -0
  55. package/dist/layout/column.js +90 -0
  56. package/dist/layout/control.d.ts +73 -0
  57. package/dist/layout/control.js +215 -0
  58. package/dist/layout/row.d.ts +21 -0
  59. package/dist/layout/row.js +95 -0
  60. package/dist/layout/stack.d.ts +18 -0
  61. package/dist/layout/stack.js +64 -0
  62. package/dist/layout/types.d.ts +27 -0
  63. package/dist/layout/types.js +4 -0
  64. package/dist/pixel/background.d.ts +16 -0
  65. package/dist/pixel/background.js +16 -0
  66. package/dist/pixel/box-pattern.d.ts +38 -0
  67. package/dist/pixel/box-pattern.js +57 -0
  68. package/dist/pixel/buffer.d.ts +25 -0
  69. package/dist/pixel/buffer.js +51 -0
  70. package/dist/pixel/color.d.ts +48 -0
  71. package/dist/pixel/color.js +92 -0
  72. package/dist/pixel/foreground.d.ts +31 -0
  73. package/dist/pixel/foreground.js +64 -0
  74. package/dist/pixel/pixel.d.ts +21 -0
  75. package/dist/pixel/pixel.js +38 -0
  76. package/dist/pixel/symbol.d.ts +38 -0
  77. package/dist/pixel/symbol.js +192 -0
  78. package/dist/render/regions.d.ts +54 -0
  79. package/dist/render/regions.js +102 -0
  80. package/dist/render/render-target.d.ts +42 -0
  81. package/dist/render/render-target.js +118 -0
  82. package/dist/styled.d.ts +113 -0
  83. package/dist/styled.js +176 -0
  84. package/dist/widgets/border.d.ts +34 -0
  85. package/dist/widgets/border.js +121 -0
  86. package/dist/widgets/chat-view.d.ts +239 -0
  87. package/dist/widgets/chat-view.js +993 -0
  88. package/dist/widgets/interview.d.ts +87 -0
  89. package/dist/widgets/interview.js +187 -0
  90. package/dist/widgets/markdown.d.ts +87 -0
  91. package/dist/widgets/markdown.js +611 -0
  92. package/dist/widgets/panel.d.ts +19 -0
  93. package/dist/widgets/panel.js +35 -0
  94. package/dist/widgets/scroll-view.d.ts +43 -0
  95. package/dist/widgets/scroll-view.js +182 -0
  96. package/dist/widgets/styled-text.d.ts +38 -0
  97. package/dist/widgets/styled-text.js +183 -0
  98. package/dist/widgets/syntax.d.ts +37 -0
  99. package/dist/widgets/syntax.js +670 -0
  100. package/dist/widgets/text-input.d.ts +121 -0
  101. package/dist/widgets/text-input.js +618 -0
  102. package/dist/widgets/text.d.ts +34 -0
  103. package/dist/widgets/text.js +168 -0
  104. package/package.json +45 -0
@@ -0,0 +1,618 @@
1
+ /**
2
+ * Single-line text input widget — the primary replacement for readline.
3
+ *
4
+ * Handles cursor movement, text editing, history navigation, word-jump,
5
+ * clipboard paste, and visual scrolling when the value exceeds the
6
+ * visible width.
7
+ */
8
+ import { Control } from "../layout/control.js";
9
+ export class TextInput extends Control {
10
+ _value;
11
+ _cursor;
12
+ _prompt;
13
+ _placeholder;
14
+ _placeholderStyle;
15
+ _style;
16
+ _cursorStyle;
17
+ _promptStyle;
18
+ _colorize;
19
+ _deleteSize;
20
+ _hint;
21
+ _hintStyle;
22
+ /** Command history entries (most recent last). */
23
+ _history;
24
+ /** Current position in history (-1 = not browsing, 0 = oldest). */
25
+ _historyIndex = -1;
26
+ /** Saved input when user starts browsing history. */
27
+ _savedInput = "";
28
+ constructor(options = {}) {
29
+ super();
30
+ this.focusable = true;
31
+ this._value = options.value ?? "";
32
+ this._cursor = this._value.length;
33
+ this._prompt = options.prompt ?? "";
34
+ this._placeholder = options.placeholder ?? "";
35
+ this._placeholderStyle = options.placeholderStyle ?? { italic: true };
36
+ this._style = options.style ?? {};
37
+ this._cursorStyle = options.cursorStyle ?? {};
38
+ this._promptStyle = options.promptStyle ?? {};
39
+ this._history = options.history ? [...options.history] : [];
40
+ this._colorize = options.colorize ?? null;
41
+ this._deleteSize = options.deleteSize ?? null;
42
+ this._hint = options.hint ?? null;
43
+ this._hintStyle = options.hintStyle ?? { italic: true };
44
+ }
45
+ // ── Public properties ─────────────────────────────────────────
46
+ get value() {
47
+ return this._value;
48
+ }
49
+ set value(v) {
50
+ if (this._value !== v) {
51
+ this._value = v;
52
+ this._cursor = Math.min(this._cursor, v.length);
53
+ this.emit("change", v);
54
+ this.invalidate();
55
+ }
56
+ }
57
+ get cursor() {
58
+ return this._cursor;
59
+ }
60
+ set cursor(pos) {
61
+ const clamped = Math.max(0, Math.min(pos, this._value.length));
62
+ if (this._cursor !== clamped) {
63
+ this._cursor = clamped;
64
+ this.invalidate();
65
+ }
66
+ }
67
+ get prompt() {
68
+ return this._prompt;
69
+ }
70
+ set prompt(v) {
71
+ if (this._prompt !== v) {
72
+ this._prompt = v;
73
+ this.invalidate();
74
+ }
75
+ }
76
+ get placeholder() {
77
+ return this._placeholder;
78
+ }
79
+ set placeholder(v) {
80
+ this._placeholder = v;
81
+ }
82
+ get style() {
83
+ return this._style;
84
+ }
85
+ set style(v) {
86
+ this._style = v;
87
+ this.invalidate();
88
+ }
89
+ get cursorStyle() {
90
+ return this._cursorStyle;
91
+ }
92
+ set cursorStyle(v) {
93
+ this._cursorStyle = v;
94
+ this.invalidate();
95
+ }
96
+ get promptStyle() {
97
+ return this._promptStyle;
98
+ }
99
+ set promptStyle(v) {
100
+ this._promptStyle = v;
101
+ this.invalidate();
102
+ }
103
+ get placeholderStyle() {
104
+ return this._placeholderStyle;
105
+ }
106
+ set placeholderStyle(v) {
107
+ this._placeholderStyle = v;
108
+ }
109
+ get history() {
110
+ return this._history;
111
+ }
112
+ // ── Public methods ────────────────────────────────────────────
113
+ /** Clear the input value and reset cursor. */
114
+ clear() {
115
+ this._value = "";
116
+ this._cursor = 0;
117
+ this._vScrollOffset = 0;
118
+ this._historyIndex = -1;
119
+ this._savedInput = "";
120
+ this.emit("change", "");
121
+ this.invalidate();
122
+ }
123
+ /** Set the value and move cursor to the end. */
124
+ setValue(text) {
125
+ this._value = text;
126
+ this._cursor = text.length;
127
+ this._historyIndex = -1;
128
+ this._savedInput = "";
129
+ this.emit("change", text);
130
+ this.invalidate();
131
+ }
132
+ /** Insert text at the current cursor position. */
133
+ insert(text) {
134
+ this._value =
135
+ this._value.slice(0, this._cursor) +
136
+ text +
137
+ this._value.slice(this._cursor);
138
+ this._cursor += text.length;
139
+ this.emit("change", this._value);
140
+ this.invalidate();
141
+ }
142
+ // ── Input handling ────────────────────────────────────────────
143
+ handleInput(event) {
144
+ if (event.type === "paste") {
145
+ return this._handlePaste(event.event);
146
+ }
147
+ if (event.type === "key") {
148
+ return this._handleKey(event.event);
149
+ }
150
+ return false;
151
+ }
152
+ _handlePaste(paste) {
153
+ // Strip newlines from pasted text (single-line input)
154
+ const clean = paste.text.replace(/[\r\n]/g, "");
155
+ if (clean.length > 0) {
156
+ this.insert(clean);
157
+ this.emit("paste", clean);
158
+ }
159
+ return true;
160
+ }
161
+ _handleKey(key) {
162
+ // ── Shift+Enter or Alt+Enter → insert newline ─────────────
163
+ if (key.key === "enter" && (key.shift || key.alt)) {
164
+ this.insert("\n");
165
+ return true;
166
+ }
167
+ // ── Enter → submit or newline (trailing \ continues) ─────
168
+ if (key.key === "enter") {
169
+ // Trailing backslash = line continuation: replace \ with newline
170
+ if (this._cursor > 0 && this._value[this._cursor - 1] === "\\") {
171
+ this._value =
172
+ this._value.slice(0, this._cursor - 1) +
173
+ this._value.slice(this._cursor);
174
+ this._cursor--;
175
+ this.insert("\n");
176
+ return true;
177
+ }
178
+ const val = this._value;
179
+ // Add to history if non-empty and different from last entry
180
+ if (val.length > 0 &&
181
+ (this._history.length === 0 ||
182
+ this._history[this._history.length - 1] !== val)) {
183
+ this._history.push(val);
184
+ }
185
+ this.emit("submit", val);
186
+ this.clear();
187
+ return true;
188
+ }
189
+ // ── Escape → cancel ─────────────────────────────────────
190
+ if (key.key === "escape") {
191
+ this.emit("cancel");
192
+ return true;
193
+ }
194
+ // ── Tab → tab event (for autocomplete) ──────────────────
195
+ if (key.key === "tab") {
196
+ this.emit("tab");
197
+ return true;
198
+ }
199
+ // ── Backspace → delete char(s) before cursor ─────────────
200
+ if (key.key === "backspace") {
201
+ if (this._cursor > 0) {
202
+ const count = this._deleteSize
203
+ ? Math.max(1, this._deleteSize(this._value, this._cursor, "backward"))
204
+ : 1;
205
+ const deleteFrom = Math.max(0, this._cursor - count);
206
+ this._value =
207
+ this._value.slice(0, deleteFrom) + this._value.slice(this._cursor);
208
+ this._cursor = deleteFrom;
209
+ this.emit("change", this._value);
210
+ this.invalidate();
211
+ }
212
+ return true;
213
+ }
214
+ // ── Delete → delete char(s) at cursor ────────────────────
215
+ if (key.key === "delete") {
216
+ if (this._cursor < this._value.length) {
217
+ const count = this._deleteSize
218
+ ? Math.max(1, this._deleteSize(this._value, this._cursor, "forward"))
219
+ : 1;
220
+ this._value =
221
+ this._value.slice(0, this._cursor) +
222
+ this._value.slice(this._cursor + count);
223
+ this.emit("change", this._value);
224
+ this.invalidate();
225
+ }
226
+ return true;
227
+ }
228
+ // ── Left / Right ────────────────────────────────────────
229
+ if (key.key === "left" && !key.ctrl) {
230
+ if (this._cursor > 0) {
231
+ this._cursor--;
232
+ this.invalidate();
233
+ }
234
+ return true;
235
+ }
236
+ if (key.key === "right" && !key.ctrl) {
237
+ if (this._cursor < this._value.length) {
238
+ this._cursor++;
239
+ this.invalidate();
240
+ }
241
+ return true;
242
+ }
243
+ // ── Ctrl+Left → word jump left ──────────────────────────
244
+ if (key.key === "left" && key.ctrl) {
245
+ this._cursor = this._wordBoundaryLeft(this._cursor);
246
+ this.invalidate();
247
+ return true;
248
+ }
249
+ // ── Ctrl+Right → word jump right ────────────────────────
250
+ if (key.key === "right" && key.ctrl) {
251
+ this._cursor = this._wordBoundaryRight(this._cursor);
252
+ this.invalidate();
253
+ return true;
254
+ }
255
+ // ── Home → cursor to start ──────────────────────────────
256
+ if (key.key === "home") {
257
+ this._cursor = 0;
258
+ this.invalidate();
259
+ return true;
260
+ }
261
+ // ── End → cursor to end ─────────────────────────────────
262
+ if (key.key === "end") {
263
+ this._cursor = this._value.length;
264
+ this.invalidate();
265
+ return true;
266
+ }
267
+ // ── Ctrl+A → move cursor to start (select-all semantics) ─
268
+ if (key.key === "a" && key.ctrl) {
269
+ this._cursor = 0;
270
+ this.invalidate();
271
+ return true;
272
+ }
273
+ // ── Ctrl+E → move cursor to end ─────────────────────────
274
+ if (key.key === "e" && key.ctrl) {
275
+ this._cursor = this._value.length;
276
+ this.invalidate();
277
+ return true;
278
+ }
279
+ // ── Ctrl+U → clear line (kill backward) ─────────────────
280
+ if (key.key === "u" && key.ctrl) {
281
+ this._value = this._value.slice(this._cursor);
282
+ this._cursor = 0;
283
+ this.emit("change", this._value);
284
+ this.invalidate();
285
+ return true;
286
+ }
287
+ // ── Ctrl+K → kill to end of line ────────────────────────
288
+ if (key.key === "k" && key.ctrl) {
289
+ this._value = this._value.slice(0, this._cursor);
290
+ this.emit("change", this._value);
291
+ this.invalidate();
292
+ return true;
293
+ }
294
+ // ── Up → move cursor up in wrapped text, or history ─────
295
+ if (key.key === "up") {
296
+ const lines = this._wrapLines(this._lastFirstRowW, this._lastTotalWidth);
297
+ if (lines.length > 1) {
298
+ const { row, col } = this._cursorToRowCol(lines);
299
+ if (row > 0) {
300
+ // Move cursor to same column on previous row
301
+ const prevLine = lines[row - 1];
302
+ const prevLineOffset = this._lineOffset(lines, row - 1);
303
+ this._cursor = prevLineOffset + Math.min(col, prevLine.length - 1);
304
+ this.invalidate();
305
+ return true;
306
+ }
307
+ // On first row — fall through to history
308
+ }
309
+ if (this._history.length > 0) {
310
+ if (this._historyIndex === -1) {
311
+ this._savedInput = this._value;
312
+ this._historyIndex = this._history.length - 1;
313
+ }
314
+ else if (this._historyIndex > 0) {
315
+ this._historyIndex--;
316
+ }
317
+ this._value = this._history[this._historyIndex];
318
+ this._cursor = this._value.length;
319
+ this.emit("change", this._value);
320
+ this.invalidate();
321
+ }
322
+ return true;
323
+ }
324
+ // ── Down → move cursor down in wrapped text, or history ─
325
+ if (key.key === "down") {
326
+ const lines = this._wrapLines(this._lastFirstRowW, this._lastTotalWidth);
327
+ if (lines.length > 1) {
328
+ const { row, col } = this._cursorToRowCol(lines);
329
+ if (row < lines.length - 1) {
330
+ // Move cursor to same column on next row
331
+ const nextLine = lines[row + 1];
332
+ const nextLineOffset = this._lineOffset(lines, row + 1);
333
+ this._cursor = nextLineOffset + Math.min(col, nextLine.length);
334
+ this.invalidate();
335
+ return true;
336
+ }
337
+ // On last row — fall through to history
338
+ }
339
+ if (this._historyIndex >= 0) {
340
+ if (this._historyIndex < this._history.length - 1) {
341
+ this._historyIndex++;
342
+ this._value = this._history[this._historyIndex];
343
+ }
344
+ else {
345
+ this._historyIndex = -1;
346
+ this._value = this._savedInput;
347
+ this._savedInput = "";
348
+ }
349
+ this._cursor = this._value.length;
350
+ this.emit("change", this._value);
351
+ this.invalidate();
352
+ }
353
+ return true;
354
+ }
355
+ // ── Printable characters → insert ───────────────────────
356
+ if (key.char.length > 0 && !key.ctrl && !key.alt) {
357
+ this.insert(key.char);
358
+ return true;
359
+ }
360
+ return false;
361
+ }
362
+ // ── Word boundary helpers ─────────────────────────────────────
363
+ /**
364
+ * Find the position of the start of the word to the left of `pos`.
365
+ * Skips any whitespace first, then skips non-whitespace.
366
+ */
367
+ _wordBoundaryLeft(pos) {
368
+ if (pos <= 0)
369
+ return 0;
370
+ let i = pos - 1;
371
+ // Skip whitespace
372
+ while (i > 0 && this._value[i] === " ")
373
+ i--;
374
+ // Skip word characters
375
+ while (i > 0 && this._value[i - 1] !== " ")
376
+ i--;
377
+ return i;
378
+ }
379
+ /**
380
+ * Find the position of the end of the word to the right of `pos`.
381
+ * Skips any non-whitespace first, then skips whitespace.
382
+ */
383
+ _wordBoundaryRight(pos) {
384
+ const len = this._value.length;
385
+ if (pos >= len)
386
+ return len;
387
+ let i = pos;
388
+ // Skip word characters
389
+ while (i < len && this._value[i] !== " ")
390
+ i++;
391
+ // Skip whitespace
392
+ while (i < len && this._value[i] === " ")
393
+ i++;
394
+ return i;
395
+ }
396
+ // ── Word-wrap layout ────────────────────────────────────────
397
+ /**
398
+ * Build wrapped lines from the current value.
399
+ * Row 0 starts after the prompt (firstRowW chars wide).
400
+ * Subsequent rows use the full width.
401
+ * Breaks prefer spaces (word wrap) but will hard-break if a word
402
+ * is longer than the row width.
403
+ */
404
+ _wrapLines(firstRowW, fullW) {
405
+ if (this._value.length === 0)
406
+ return [""];
407
+ // Split on hard newlines first, then word-wrap each segment
408
+ const segments = this._value.split("\n");
409
+ const lines = [];
410
+ let rowW = firstRowW;
411
+ for (let s = 0; s < segments.length; s++) {
412
+ let remaining = segments[s];
413
+ // For segments after a newline, include the \n in the previous line
414
+ // so character offsets stay correct. We append \n to the end of the
415
+ // last wrapped line of the previous segment.
416
+ if (s > 0 && lines.length > 0) {
417
+ lines[lines.length - 1] += "\n";
418
+ }
419
+ if (remaining.length === 0) {
420
+ lines.push("");
421
+ rowW = fullW;
422
+ continue;
423
+ }
424
+ while (remaining.length > 0) {
425
+ if (remaining.length <= rowW) {
426
+ lines.push(remaining);
427
+ remaining = "";
428
+ }
429
+ else {
430
+ // Find a space to break on within the row width
431
+ let breakAt = remaining.lastIndexOf(" ", rowW - 1);
432
+ if (breakAt <= 0) {
433
+ // No space found — hard break
434
+ breakAt = rowW;
435
+ lines.push(remaining.slice(0, breakAt));
436
+ remaining = remaining.slice(breakAt);
437
+ }
438
+ else {
439
+ // Break after the space (space stays on this line)
440
+ lines.push(remaining.slice(0, breakAt + 1));
441
+ remaining = remaining.slice(breakAt + 1);
442
+ }
443
+ }
444
+ rowW = fullW; // subsequent rows use full width
445
+ }
446
+ }
447
+ return lines;
448
+ }
449
+ /**
450
+ * Find which wrapped line and column the cursor is on.
451
+ * Returns { row, col } in wrapped coordinates.
452
+ */
453
+ _cursorToRowCol(lines) {
454
+ let offset = 0;
455
+ for (let row = 0; row < lines.length; row++) {
456
+ const lineLen = lines[row].length;
457
+ if (this._cursor <= offset + lineLen) {
458
+ // Handle cursor-at-end on last line vs start-of-next-line
459
+ if (this._cursor === offset + lineLen && row < lines.length - 1) {
460
+ return { row: row + 1, col: 0 };
461
+ }
462
+ return { row, col: this._cursor - offset };
463
+ }
464
+ offset += lineLen;
465
+ }
466
+ // Cursor past all text — put on last line
467
+ const lastLine = lines[lines.length - 1];
468
+ return { row: lines.length - 1, col: lastLine.length };
469
+ }
470
+ /** Get the character offset where a given wrapped line starts. */
471
+ _lineOffset(lines, lineIdx) {
472
+ let off = 0;
473
+ for (let i = 0; i < lineIdx; i++)
474
+ off += lines[i].length;
475
+ return off;
476
+ }
477
+ /** Vertical scroll offset (first visible row). */
478
+ _vScrollOffset = 0;
479
+ /** Cached layout widths from last measure/render. */
480
+ _lastTotalWidth = 80;
481
+ _lastFirstRowW = 78;
482
+ // ── Layout ────────────────────────────────────────────────────
483
+ measure(constraint) {
484
+ const maxH = constraint.maxHeight;
485
+ const totalWidth = constraint.maxWidth;
486
+ const firstRowW = Math.max(1, totalWidth - this._prompt.length);
487
+ const lines = this._wrapLines(firstRowW, totalWidth);
488
+ // +1 for cursor row if cursor is at the very end and would start a new line
489
+ const cursorOnNewLine = this._value.length > 0 &&
490
+ this._cursor === this._value.length &&
491
+ lines[lines.length - 1].length >=
492
+ (lines.length === 1 ? firstRowW : totalWidth);
493
+ const totalRows = lines.length + (cursorOnNewLine ? 1 : 0);
494
+ const rows = Math.min(maxH, Math.max(1, totalRows));
495
+ return {
496
+ width: totalWidth,
497
+ height: rows,
498
+ };
499
+ }
500
+ render(ctx) {
501
+ const bounds = this.bounds;
502
+ if (!bounds)
503
+ return;
504
+ const bx = bounds.x;
505
+ const by = bounds.y;
506
+ const totalWidth = bounds.width;
507
+ const visibleRows = bounds.height;
508
+ const promptLen = this._prompt.length;
509
+ const firstRowW = Math.max(1, totalWidth - promptLen);
510
+ this._lastTotalWidth = totalWidth;
511
+ this._lastFirstRowW = firstRowW;
512
+ const isFocused = this.focused;
513
+ // ── Empty value: show placeholder or cursor ─────────────
514
+ if (this._value.length === 0) {
515
+ const promptX = bx + promptLen;
516
+ if (this._prompt.length > 0) {
517
+ ctx.drawText(bx, by, this._prompt, this._promptStyle);
518
+ }
519
+ if (isFocused) {
520
+ this._drawCursor(ctx, promptX, by, " ");
521
+ if (this._placeholder.length > 0) {
522
+ const phText = this._placeholder.slice(0, firstRowW - 1);
523
+ ctx.drawText(promptX + 1, by, phText, this._placeholderStyle);
524
+ }
525
+ }
526
+ else if (this._placeholder.length > 0) {
527
+ const phText = this._placeholder.slice(0, firstRowW);
528
+ ctx.drawText(promptX, by, phText, this._placeholderStyle);
529
+ }
530
+ this._vScrollOffset = 0;
531
+ return;
532
+ }
533
+ // ── Word-wrap and scroll ────────────────────────────────
534
+ const lines = this._wrapLines(firstRowW, totalWidth);
535
+ const { row: cursorRow, col: cursorCol } = this._cursorToRowCol(lines);
536
+ // Ensure cursor row is visible by adjusting vertical scroll
537
+ if (cursorRow < this._vScrollOffset) {
538
+ this._vScrollOffset = cursorRow;
539
+ }
540
+ if (cursorRow >= this._vScrollOffset + visibleRows) {
541
+ this._vScrollOffset = cursorRow - visibleRows + 1;
542
+ }
543
+ // Clamp
544
+ const totalLines = lines.length;
545
+ const maxVScroll = Math.max(0, totalLines - visibleRows);
546
+ this._vScrollOffset = Math.max(0, Math.min(this._vScrollOffset, maxVScroll));
547
+ // Compute per-character styles
548
+ const charStyles = this._colorize ? this._colorize(this._value) : null;
549
+ // Build a char-offset map: charOffset[row] = index into this._value
550
+ // where that wrapped line starts.
551
+ const lineOffsets = [];
552
+ let off = 0;
553
+ for (const line of lines) {
554
+ lineOffsets.push(off);
555
+ off += line.length;
556
+ }
557
+ // Render visible rows
558
+ for (let vr = 0; vr < visibleRows; vr++) {
559
+ const lineIdx = this._vScrollOffset + vr;
560
+ if (lineIdx >= lines.length)
561
+ break;
562
+ const lineText = lines[lineIdx];
563
+ const lineOffset = lineOffsets[lineIdx];
564
+ const rowX = lineIdx === 0 ? bx + promptLen : bx;
565
+ const screenY = by + vr;
566
+ // Draw prompt on the first visible row if it's line 0
567
+ if (lineIdx === 0 && this._prompt.length > 0) {
568
+ ctx.drawText(bx, screenY, this._prompt, this._promptStyle);
569
+ }
570
+ // Draw characters (skip newlines — they're just line-break markers)
571
+ let drawCol = 0;
572
+ for (let col = 0; col < lineText.length; col++) {
573
+ const ch = lineText[col];
574
+ if (ch === "\n")
575
+ continue;
576
+ const charIdx = lineOffset + col;
577
+ if (isFocused && charIdx === this._cursor) {
578
+ this._drawCursor(ctx, rowX + drawCol, screenY, ch);
579
+ }
580
+ else {
581
+ const style = charStyles?.[charIdx] ?? this._style;
582
+ ctx.drawChar(rowX + drawCol, screenY, ch, style);
583
+ }
584
+ drawCol++;
585
+ }
586
+ // Cursor at end of this line (append position)
587
+ if (isFocused && lineIdx === cursorRow && cursorCol >= drawCol) {
588
+ this._drawCursor(ctx, rowX + drawCol, screenY, " ");
589
+ // Draw hint text after cursor (only on the last line, at the end)
590
+ if (this._hint && lineIdx === lines.length - 1) {
591
+ const hintText = this._hint(this._value);
592
+ if (hintText) {
593
+ const hintX = rowX + drawCol + 1; // +1 for cursor block
594
+ const maxHintLen = totalWidth - (hintX - bx);
595
+ const clipped = hintText.slice(0, maxHintLen);
596
+ for (let hi = 0; hi < clipped.length; hi++) {
597
+ ctx.drawChar(hintX + hi, screenY, clipped[hi], this._hintStyle);
598
+ }
599
+ }
600
+ }
601
+ }
602
+ }
603
+ }
604
+ // ── Cursor rendering ──────────────────────────────────────────
605
+ /**
606
+ * Draw the cursor character with inverted foreground/background
607
+ * colours (swap fg and bg from the text style).
608
+ */
609
+ _drawCursor(ctx, x, y, char) {
610
+ const cursorStyle = {
611
+ ...this._cursorStyle,
612
+ // If no explicit cursor style colours are set, invert the text style
613
+ fg: this._cursorStyle.fg ?? this._style.bg,
614
+ bg: this._cursorStyle.bg ?? this._style.fg,
615
+ };
616
+ ctx.drawChar(x, y, char, cursorStyle);
617
+ }
618
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Static text display widget.
3
+ *
4
+ * Supports word wrapping, text alignment (left/center/right), and
5
+ * multi-line content. Automatically invalidates on text or style changes.
6
+ */
7
+ import type { DrawingContext, TextStyle } from "../drawing/context.js";
8
+ import { Control } from "../layout/control.js";
9
+ import type { Constraint, Size } from "../layout/types.js";
10
+ export interface TextOptions {
11
+ text?: string;
12
+ style?: TextStyle;
13
+ wrap?: boolean;
14
+ align?: "left" | "center" | "right";
15
+ }
16
+ export declare class Text extends Control {
17
+ private _text;
18
+ private _style;
19
+ private _wrap;
20
+ private _align;
21
+ /** Cached wrapped lines from the last measure/render pass. */
22
+ private _lines;
23
+ constructor(options?: TextOptions);
24
+ get text(): string;
25
+ set text(value: string);
26
+ get style(): TextStyle;
27
+ set style(value: TextStyle);
28
+ get wrap(): boolean;
29
+ set wrap(value: boolean);
30
+ get align(): "left" | "center" | "right";
31
+ set align(value: "left" | "center" | "right");
32
+ measure(constraint: Constraint): Size;
33
+ render(ctx: DrawingContext): void;
34
+ }