take4-console 0.15.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 (131) hide show
  1. package/CHANGELOG.md +287 -0
  2. package/LICENSE +21 -0
  3. package/README.md +986 -0
  4. package/dist/Screen/InterfaceBuilder.d.mts +43 -0
  5. package/dist/Screen/InterfaceBuilder.d.mts.map +1 -0
  6. package/dist/Screen/InterfaceBuilder.mjs +355 -0
  7. package/dist/Screen/InterfaceBuilder.mjs.map +1 -0
  8. package/dist/Screen/Pos.d.mts +52 -0
  9. package/dist/Screen/Pos.d.mts.map +1 -0
  10. package/dist/Screen/Pos.mjs +105 -0
  11. package/dist/Screen/Pos.mjs.map +1 -0
  12. package/dist/Screen/Region.d.mts +31 -0
  13. package/dist/Screen/Region.d.mts.map +1 -0
  14. package/dist/Screen/Region.mjs +73 -0
  15. package/dist/Screen/Region.mjs.map +1 -0
  16. package/dist/Screen/RegistryHolder.d.mts +6 -0
  17. package/dist/Screen/RegistryHolder.d.mts.map +1 -0
  18. package/dist/Screen/RegistryHolder.mjs +14 -0
  19. package/dist/Screen/RegistryHolder.mjs.map +1 -0
  20. package/dist/Screen/Screen.d.mts +25 -0
  21. package/dist/Screen/Screen.d.mts.map +1 -0
  22. package/dist/Screen/Screen.mjs +100 -0
  23. package/dist/Screen/Screen.mjs.map +1 -0
  24. package/dist/Screen/Size.d.mts +23 -0
  25. package/dist/Screen/Size.d.mts.map +1 -0
  26. package/dist/Screen/Size.mjs +48 -0
  27. package/dist/Screen/Size.mjs.map +1 -0
  28. package/dist/Screen/StyleRegistry.d.mts +32 -0
  29. package/dist/Screen/StyleRegistry.d.mts.map +1 -0
  30. package/dist/Screen/StyleRegistry.mjs +80 -0
  31. package/dist/Screen/StyleRegistry.mjs.map +1 -0
  32. package/dist/Screen/Window.d.mts +121 -0
  33. package/dist/Screen/Window.d.mts.map +1 -0
  34. package/dist/Screen/Window.mjs +407 -0
  35. package/dist/Screen/Window.mjs.map +1 -0
  36. package/dist/Screen/WindowManager.d.mts +86 -0
  37. package/dist/Screen/WindowManager.d.mts.map +1 -0
  38. package/dist/Screen/WindowManager.mjs +399 -0
  39. package/dist/Screen/WindowManager.mjs.map +1 -0
  40. package/dist/Screen/controls/BarChart.d.mts +29 -0
  41. package/dist/Screen/controls/BarChart.d.mts.map +1 -0
  42. package/dist/Screen/controls/BarChart.mjs +90 -0
  43. package/dist/Screen/controls/BarChart.mjs.map +1 -0
  44. package/dist/Screen/controls/Button.d.mts +16 -0
  45. package/dist/Screen/controls/Button.d.mts.map +1 -0
  46. package/dist/Screen/controls/Button.mjs +34 -0
  47. package/dist/Screen/controls/Button.mjs.map +1 -0
  48. package/dist/Screen/controls/Checkbox.d.mts +23 -0
  49. package/dist/Screen/controls/Checkbox.d.mts.map +1 -0
  50. package/dist/Screen/controls/Checkbox.mjs +55 -0
  51. package/dist/Screen/controls/Checkbox.mjs.map +1 -0
  52. package/dist/Screen/controls/LineChart.d.mts +29 -0
  53. package/dist/Screen/controls/LineChart.d.mts.map +1 -0
  54. package/dist/Screen/controls/LineChart.mjs +172 -0
  55. package/dist/Screen/controls/LineChart.mjs.map +1 -0
  56. package/dist/Screen/controls/ListBox.d.mts +34 -0
  57. package/dist/Screen/controls/ListBox.d.mts.map +1 -0
  58. package/dist/Screen/controls/ListBox.mjs +138 -0
  59. package/dist/Screen/controls/ListBox.mjs.map +1 -0
  60. package/dist/Screen/controls/ProgressBar.d.mts +26 -0
  61. package/dist/Screen/controls/ProgressBar.d.mts.map +1 -0
  62. package/dist/Screen/controls/ProgressBar.mjs +70 -0
  63. package/dist/Screen/controls/ProgressBar.mjs.map +1 -0
  64. package/dist/Screen/controls/ProgressBarV.d.mts +22 -0
  65. package/dist/Screen/controls/ProgressBarV.d.mts.map +1 -0
  66. package/dist/Screen/controls/ProgressBarV.mjs +61 -0
  67. package/dist/Screen/controls/ProgressBarV.mjs.map +1 -0
  68. package/dist/Screen/controls/Radio.d.mts +23 -0
  69. package/dist/Screen/controls/Radio.d.mts.map +1 -0
  70. package/dist/Screen/controls/Radio.mjs +55 -0
  71. package/dist/Screen/controls/Radio.mjs.map +1 -0
  72. package/dist/Screen/controls/Sparkline.d.mts +29 -0
  73. package/dist/Screen/controls/Sparkline.d.mts.map +1 -0
  74. package/dist/Screen/controls/Sparkline.mjs +82 -0
  75. package/dist/Screen/controls/Sparkline.mjs.map +1 -0
  76. package/dist/Screen/controls/Spinner.d.mts +37 -0
  77. package/dist/Screen/controls/Spinner.d.mts.map +1 -0
  78. package/dist/Screen/controls/Spinner.mjs +87 -0
  79. package/dist/Screen/controls/Spinner.mjs.map +1 -0
  80. package/dist/Screen/controls/StatusLED.d.mts +22 -0
  81. package/dist/Screen/controls/StatusLED.d.mts.map +1 -0
  82. package/dist/Screen/controls/StatusLED.mjs +51 -0
  83. package/dist/Screen/controls/StatusLED.mjs.map +1 -0
  84. package/dist/Screen/controls/Tabs.d.mts +42 -0
  85. package/dist/Screen/controls/Tabs.d.mts.map +1 -0
  86. package/dist/Screen/controls/Tabs.mjs +126 -0
  87. package/dist/Screen/controls/Tabs.mjs.map +1 -0
  88. package/dist/Screen/controls/TextArea.d.mts +41 -0
  89. package/dist/Screen/controls/TextArea.d.mts.map +1 -0
  90. package/dist/Screen/controls/TextArea.mjs +197 -0
  91. package/dist/Screen/controls/TextArea.mjs.map +1 -0
  92. package/dist/Screen/controls/TextBox.d.mts +35 -0
  93. package/dist/Screen/controls/TextBox.d.mts.map +1 -0
  94. package/dist/Screen/controls/TextBox.mjs +135 -0
  95. package/dist/Screen/controls/TextBox.mjs.map +1 -0
  96. package/dist/Screen/types.d.mts +399 -0
  97. package/dist/Screen/types.d.mts.map +1 -0
  98. package/dist/Screen/types.mjs +22 -0
  99. package/dist/Screen/types.mjs.map +1 -0
  100. package/dist/index.d.mts +26 -0
  101. package/dist/index.d.mts.map +1 -0
  102. package/dist/index.mjs +41 -0
  103. package/dist/index.mjs.map +1 -0
  104. package/package.json +72 -0
  105. package/src/Screen/InterfaceBuilder.mts +403 -0
  106. package/src/Screen/Pos.mts +119 -0
  107. package/src/Screen/Region.mts +88 -0
  108. package/src/Screen/RegistryHolder.mts +16 -0
  109. package/src/Screen/Screen.mts +103 -0
  110. package/src/Screen/Size.mts +55 -0
  111. package/src/Screen/StyleRegistry.mts +95 -0
  112. package/src/Screen/Window.mts +439 -0
  113. package/src/Screen/WindowManager.mts +472 -0
  114. package/src/Screen/controls/BarChart.mts +109 -0
  115. package/src/Screen/controls/Button.mts +40 -0
  116. package/src/Screen/controls/Checkbox.mts +66 -0
  117. package/src/Screen/controls/LineChart.mts +202 -0
  118. package/src/Screen/controls/ListBox.mts +154 -0
  119. package/src/Screen/controls/ProgressBar.mts +88 -0
  120. package/src/Screen/controls/ProgressBarV.mts +77 -0
  121. package/src/Screen/controls/Radio.mts +66 -0
  122. package/src/Screen/controls/Sparkline.mts +101 -0
  123. package/src/Screen/controls/Spinner.mts +102 -0
  124. package/src/Screen/controls/StatusLED.mts +65 -0
  125. package/src/Screen/controls/Tabs.mts +140 -0
  126. package/src/Screen/controls/TextArea.mts +194 -0
  127. package/src/Screen/controls/TextBox.mts +139 -0
  128. package/src/Screen/types.mts +416 -0
  129. package/src/demo.mts +171 -0
  130. package/src/index.mts +105 -0
  131. package/src/layout.yaml +236 -0
@@ -0,0 +1,102 @@
1
+ import type { SpinnerProperties, WindowProperties, StyleId } from '../types.mjs';
2
+ import { BUILTIN_TEXT } from '../types.mjs';
3
+ import { Window } from '../Window.mjs';
4
+ import { Size } from '../Size.mjs';
5
+ import { getRegistry } from '../RegistryHolder.mjs';
6
+
7
+ /** Animation style name → frame sequence. */
8
+ const SPINNER_FRAMES: Record<'braille' | 'dots' | 'line' | 'circle' | 'arrow', string[]> = {
9
+ braille: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
10
+ dots: ['. ', '.. ', '...', ' ..', ' .', ' '],
11
+ line: ['|', '/', '-', '\\'],
12
+ circle: ['◐', '◓', '◑', '◒'],
13
+ arrow: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'],
14
+ };
15
+
16
+ /** A read-only animated loader indicator. Holds an animation frame index that
17
+ * advances manually via step() — typically driven by setInterval() in the
18
+ * demo, but any external clock source will do. The glyph is rendered to the
19
+ * left of an optional text label. */
20
+ export class Spinner extends Window {
21
+ private styleName: keyof typeof SPINNER_FRAMES;
22
+ private frames: string[];
23
+ private frame: number;
24
+ private running: boolean;
25
+ private glyphStyleId: StyleId;
26
+ private labelStyleId: StyleId;
27
+
28
+ /** Creates a Spinner from window properties and optional control-specific properties.
29
+ * When wp.size is omitted, width is auto-computed as max(frameWidth) + (label ? 1 + label.length : 0).
30
+ * Uses the global StyleRegistry set by the Screen constructor. */
31
+ public constructor(wp: WindowProperties, cp?: SpinnerProperties) {
32
+ const reg = getRegistry();
33
+ const styleName = cp?.style ?? 'braille';
34
+ const frames = SPINNER_FRAMES[styleName];
35
+ const label = wp.label ?? '';
36
+ const glyphW = Math.max(...frames.map(f => [...f].length));
37
+ const width = glyphW + (label.length > 0 ? 1 + label.length : 0);
38
+ const size = wp.size ?? new Size(Math.max(1, width), 1);
39
+
40
+ super({ ...wp, size });
41
+
42
+ this.styleName = styleName;
43
+ this.frames = frames;
44
+ this.frame = ((cp?.frame ?? 0) % frames.length + frames.length) % frames.length;
45
+ this.running = cp?.running ?? true;
46
+
47
+ this.glyphStyleId = reg.register({ foreground: cp?.color ?? 75, bold: true });
48
+ this.labelStyleId = reg.getNamed(BUILTIN_TEXT)!;
49
+ }
50
+
51
+ /** Advances the animation by one frame. No-op when running is false. */
52
+ public step(): void {
53
+ if (!this.running || this.frames.length === 0) return;
54
+ this.frame = (this.frame + 1) % this.frames.length;
55
+ }
56
+
57
+ /** Sets the current frame index directly. Wraps into the valid range. */
58
+ public setFrame(frame: number): void {
59
+ if (this.frames.length === 0) return;
60
+ this.frame = ((frame % this.frames.length) + this.frames.length) % this.frames.length;
61
+ }
62
+
63
+ /** Returns the current frame index. */
64
+ public getFrame(): number {
65
+ return this.frame;
66
+ }
67
+
68
+ /** Starts or resumes the animation. step() will advance frames again. */
69
+ public start(): void {
70
+ this.running = true;
71
+ }
72
+
73
+ /** Pauses the animation. step() becomes a no-op; the current frame stays visible. */
74
+ public stop(): void {
75
+ this.running = false;
76
+ }
77
+
78
+ /** Returns whether the spinner is currently animating. */
79
+ public isRunning(): boolean {
80
+ return this.running;
81
+ }
82
+
83
+ /** Returns the animation style name. */
84
+ public getStyleName(): keyof typeof SPINNER_FRAMES {
85
+ return this.styleName;
86
+ }
87
+
88
+ public override render(): void {
89
+ this.clear();
90
+
91
+ const glyph = this.frames[this.frame] ?? '';
92
+ this.writeText(glyph, { x: 0, y: 0, style: this.glyphStyleId });
93
+
94
+ if (this.label.length > 0) {
95
+ // Leave one space between glyph and label.
96
+ const glyphW = Math.max(...this.frames.map(f => [...f].length));
97
+ this.writeText(' ' + this.label, { x: glyphW, y: 0, style: this.labelStyleId });
98
+ }
99
+
100
+ super.render();
101
+ }
102
+ }
@@ -0,0 +1,65 @@
1
+ import type { StatusLEDProperties, WindowProperties, StyleId } from '../types.mjs';
2
+ import { Window } from '../Window.mjs';
3
+ import { Size } from '../Size.mjs';
4
+ import { getRegistry } from '../RegistryHolder.mjs';
5
+
6
+ /** Width of the indicator dot and trailing space ('● '). */
7
+ const DOT_WIDTH = 2;
8
+
9
+ /** A read-only status indicator rendered as a coloured dot with an optional text label.
10
+ * The control auto-sizes its width to fit the label when size is not provided. */
11
+ export class StatusLED extends Window {
12
+ private state: 'ok' | 'warn' | 'error' | 'off';
13
+
14
+ private offStyleId: StyleId;
15
+ private okStyleId: StyleId;
16
+ private warnStyleId: StyleId;
17
+ private errorStyleId: StyleId;
18
+ private textStyleId: StyleId;
19
+
20
+ /** Creates a StatusLED from window properties and optional control-specific properties.
21
+ * When wp.size is omitted, width is auto-computed as 2 + label.length.
22
+ * Uses the global StyleRegistry set by the Screen constructor. */
23
+ public constructor(wp: WindowProperties, cp?: StatusLEDProperties) {
24
+ const reg = getRegistry();
25
+ const label = wp.label ?? '';
26
+ const size = wp.size ?? new Size(DOT_WIDTH + label.length, 1);
27
+
28
+ super({ ...wp, size });
29
+
30
+ this.state = cp?.state ?? 'off';
31
+
32
+ this.offStyleId = reg.register({ foreground: 240 });
33
+ this.okStyleId = reg.register({ foreground: 76 });
34
+ this.warnStyleId = reg.register({ foreground: 226 });
35
+ this.errorStyleId = reg.register({ foreground: 196 });
36
+ this.textStyleId = reg.register({ foreground: 252 });
37
+ }
38
+
39
+ /** Sets the current LED state. Call render() afterwards to update the display. */
40
+ public setState(state: 'ok' | 'warn' | 'error' | 'off'): void {
41
+ this.state = state;
42
+ }
43
+
44
+ /** Returns the current LED state. */
45
+ public getState(): 'ok' | 'warn' | 'error' | 'off' {
46
+ return this.state;
47
+ }
48
+
49
+ public override render(): void {
50
+ this.clear();
51
+
52
+ const dotStyle = this.state === 'ok' ? this.okStyleId
53
+ : this.state === 'warn' ? this.warnStyleId
54
+ : this.state === 'error' ? this.errorStyleId
55
+ : this.offStyleId;
56
+
57
+ this.writeText('●', { x: 0, y: 0, style: dotStyle });
58
+
59
+ if (this.label.length > 0) {
60
+ this.writeText(' ' + this.label, { x: 1, y: 0, style: this.textStyleId });
61
+ }
62
+
63
+ super.render();
64
+ }
65
+ }
@@ -0,0 +1,140 @@
1
+ import type { TabsProperties, WindowProperties, StyleId } from '../types.mjs';
2
+ import { Window } from '../Window.mjs';
3
+ import { getRegistry } from '../RegistryHolder.mjs';
4
+
5
+ /** A tabbed container. Renders a header row with tab titles and shows only the
6
+ * children associated with the active tab index. Other children added via the
7
+ * regular addChild() are treated as "pinned" and stay visible regardless of
8
+ * which tab is active.
9
+ *
10
+ * Keyboard: ← / → cycle tabs while focused. Tab / Shift-Tab remain reserved
11
+ * for the WindowManager focus cycle, so they are never intercepted here. */
12
+ export class Tabs extends Window {
13
+ private titles: string[];
14
+ private activeIndex: number;
15
+ private onChange?: (index: number, title: string) => void;
16
+ /** Maps each tagged child to its tab index. Untagged children are always visible. */
17
+ private childTab: Map<Window, number>;
18
+ private activeTextStyleId: StyleId;
19
+ private separatorStyleId: StyleId;
20
+
21
+ /** Creates a Tabs control from window properties and optional control-specific properties.
22
+ * Uses the global StyleRegistry set by the Screen constructor. */
23
+ public constructor(wp: WindowProperties, cp?: TabsProperties) {
24
+ super({
25
+ ...wp,
26
+ defaultBorder: { top: true, right: true, bottom: true, left: true, style: 'single' },
27
+ });
28
+
29
+ this.titles = cp?.titles ?? [];
30
+ this.activeIndex = Math.max(0, Math.min(this.titles.length - 1, cp?.activeIndex ?? 0));
31
+ this.onChange = cp?.onChange;
32
+ this.childTab = new Map();
33
+
34
+ // Active tab: inverse highlight; brighter when focused (uses Window.focusedStyleId).
35
+ const reg = getRegistry();
36
+ this.activeTextStyleId = reg.register({ background: 238, foreground: 255, bold: true });
37
+ this.separatorStyleId = reg.register({ foreground: 240 });
38
+ }
39
+
40
+ /** Adds a child window and associates it with the given tab index. Children
41
+ * added this way are only rendered while their tab is active. */
42
+ public addChildToTab(tabIndex: number, child: Window): void {
43
+ this.addChild(child);
44
+ this.childTab.set(child, tabIndex);
45
+ }
46
+
47
+ /** Replaces the tab titles. Clamps activeIndex into the new range. */
48
+ public setTitles(titles: string[]): void {
49
+ this.titles = titles;
50
+ this.activeIndex = Math.max(0, Math.min(titles.length - 1, this.activeIndex));
51
+ }
52
+
53
+ /** Returns the current tab titles. */
54
+ public getTitles(): string[] {
55
+ return this.titles;
56
+ }
57
+
58
+ /** Sets the active tab index. Clamped to the valid range. Fires onChange if it differs. */
59
+ public setActiveIndex(index: number): void {
60
+ if (this.titles.length === 0) {
61
+ this.activeIndex = 0;
62
+ return;
63
+ }
64
+ const clamped = Math.max(0, Math.min(this.titles.length - 1, index));
65
+ if (clamped !== this.activeIndex) {
66
+ this.activeIndex = clamped;
67
+ this.onChange?.(clamped, this.titles[clamped]);
68
+ }
69
+ }
70
+
71
+ /** Returns the currently active tab index. */
72
+ public getActiveIndex(): number {
73
+ return this.activeIndex;
74
+ }
75
+
76
+ /** Processes a key press; Left/Right arrows cycle through tabs (no wrap-around). */
77
+ public handleKey(key: string): void {
78
+ if (this.disabled || this.titles.length === 0) return;
79
+ if (key === '\x1b[D') { // Left arrow
80
+ this.setActiveIndex(this.activeIndex - 1);
81
+ } else if (key === '\x1b[C') { // Right arrow
82
+ this.setActiveIndex(this.activeIndex + 1);
83
+ }
84
+ }
85
+
86
+ /** Rebuilds the tabs: draws header row, composites only the active tab's children.
87
+ * Note: content is NOT cleared on each render, so user-written decorations (e.g. sparkline
88
+ * labels placed via writeText) survive across frames. The header row is fully overwritten. */
89
+ public override render(): void {
90
+
91
+ this.drawHeader();
92
+
93
+ // Temporarily restrict children to those visible in the active tab.
94
+ const original = this.children;
95
+ this.children = original.filter(c => {
96
+ const tab = this.childTab.get(c);
97
+ return tab === undefined || tab === this.activeIndex;
98
+ });
99
+ try {
100
+ super.render();
101
+ } finally {
102
+ this.children = original;
103
+ }
104
+ }
105
+
106
+ /** Writes the header row (tab titles + separators) into content at y=0. The full
107
+ * width of the row is overwritten so that title changes never leave stale glyphs. */
108
+ private drawHeader(): void {
109
+ const { width } = this.getInnerSize();
110
+ if (width < 1) return;
111
+
112
+ // Blank the full header row first so stale cells from previous renders disappear.
113
+ this.writeText(' '.repeat(width), { x: 0, y: 0 });
114
+
115
+ if (this.titles.length === 0) return;
116
+
117
+ let x = 1; // leading space so the first title does not touch the border
118
+ for (let i = 0; i < this.titles.length; i++) {
119
+ if (x >= width) break;
120
+
121
+ const title = this.titles[i];
122
+ const padded = ` ${title} `;
123
+ const isActive = i === this.activeIndex;
124
+ const style: StyleId = this.disabled
125
+ ? this.disabledStyleId
126
+ : isActive
127
+ ? (this.focused ? this.focusedStyleId : this.activeTextStyleId)
128
+ : this.normalStyleId;
129
+
130
+ const visible = padded.slice(0, width - x);
131
+ this.writeText(visible, { x, y: 0, style });
132
+ x += visible.length;
133
+
134
+ if (i < this.titles.length - 1 && x < width) {
135
+ this.writeText('│', { x, y: 0, style: this.separatorStyleId });
136
+ x++;
137
+ }
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,194 @@
1
+ import type { TextAreaProperties, WindowProperties, StyleId } from '../types.mjs';
2
+ import { BUILTIN_TEXT_PLACEHOLDER, BUILTIN_CURSOR } from '../types.mjs';
3
+ import { Window } from '../Window.mjs';
4
+ import { getRegistry } from '../RegistryHolder.mjs';
5
+
6
+ /** A multi-line text-input widget with 2-D cursor, scrolling, and placeholder support.
7
+ * Call handleKey() to feed raw terminal key strings from your input loop. */
8
+ export class TextArea extends Window {
9
+ private lines: string[];
10
+ private cursor: { x: number; y: number };
11
+ private scrollX: number;
12
+ private scrollY: number;
13
+ private placeholder: string;
14
+ private placeholderStyleId: StyleId;
15
+ private cursorStyleId: StyleId;
16
+
17
+ /** Creates a TextArea from window properties and optional control-specific properties.
18
+ * Uses the global StyleRegistry set by the Screen constructor. */
19
+ public constructor(wp: WindowProperties, cp?: TextAreaProperties) {
20
+ super({
21
+ ...wp,
22
+ defaultBorder: { top: true, right: true, bottom: true, left: true, style: 'single' },
23
+ });
24
+
25
+ this.lines = (cp?.value ?? '').split('\n');
26
+ this.placeholder = cp?.placeholder ?? '';
27
+ this.scrollX = 0;
28
+ this.scrollY = 0;
29
+
30
+ const rawCursor = cp?.cursor ?? { x: 0, y: 0 };
31
+ this.cursor = {
32
+ y: Math.max(0, Math.min(rawCursor.y, this.lines.length - 1)),
33
+ x: 0,
34
+ };
35
+ this.cursor.x = Math.max(0, Math.min(rawCursor.x, this.lines[this.cursor.y].length));
36
+
37
+ const reg = getRegistry();
38
+ this.placeholderStyleId = reg.getNamed(BUILTIN_TEXT_PLACEHOLDER)!;
39
+ this.cursorStyleId = reg.getNamed(BUILTIN_CURSOR)!;
40
+
41
+ this.clampScroll();
42
+ }
43
+
44
+ /** Replaces the current value; cursor and scroll are clamped to fit. */
45
+ public setValue(text: string): void {
46
+ this.lines = text.split('\n');
47
+ this.cursor.y = Math.min(this.cursor.y, this.lines.length - 1);
48
+ this.cursor.x = Math.min(this.cursor.x, this.lines[this.cursor.y].length);
49
+ this.clampScroll();
50
+ }
51
+
52
+ /** Returns the current text value (lines joined with '\n'). */
53
+ public getValue(): string {
54
+ return this.lines.join('\n');
55
+ }
56
+
57
+ /** Sets the cursor to the given position (clamped to valid range). */
58
+ public setCursor(pos: { x: number; y: number }): void {
59
+ this.cursor.y = Math.max(0, Math.min(pos.y, this.lines.length - 1));
60
+ this.cursor.x = Math.max(0, Math.min(pos.x, this.lines[this.cursor.y].length));
61
+ this.clampScroll();
62
+ }
63
+
64
+ /** Returns a copy of the current cursor position. */
65
+ public getCursor(): { x: number; y: number } {
66
+ return { ...this.cursor };
67
+ }
68
+
69
+ /** Processes a key string from the terminal input loop and updates value/cursor.
70
+ * Supported special keys: backspace (\x7f), delete (\x1b[3~), arrow keys (\x1b[A/B/C/D),
71
+ * home (\x1b[H), end (\x1b[F), enter (\r or \n).
72
+ * Human-readable aliases: 'backspace', 'delete', 'left', 'right', 'up', 'down', 'home', 'end', 'enter'.
73
+ * Any single printable character is inserted at the cursor. */
74
+ public handleKey(key: string): void {
75
+ if (this.disabled) return;
76
+ const line = this.lines[this.cursor.y];
77
+ switch (key) {
78
+ case '\x7f': case '\b': case 'backspace':
79
+ if (this.cursor.x > 0) {
80
+ this.lines[this.cursor.y] = line.slice(0, this.cursor.x - 1) + line.slice(this.cursor.x);
81
+ this.cursor.x--;
82
+ } else if (this.cursor.y > 0) {
83
+ const prev = this.lines[this.cursor.y - 1];
84
+ this.cursor.x = prev.length;
85
+ this.lines[this.cursor.y - 1] = prev + line;
86
+ this.lines.splice(this.cursor.y, 1);
87
+ this.cursor.y--;
88
+ }
89
+ break;
90
+ case '\x1b[3~': case 'delete':
91
+ if (this.cursor.x < line.length) {
92
+ this.lines[this.cursor.y] = line.slice(0, this.cursor.x) + line.slice(this.cursor.x + 1);
93
+ } else if (this.cursor.y < this.lines.length - 1) {
94
+ this.lines[this.cursor.y] = line + this.lines[this.cursor.y + 1];
95
+ this.lines.splice(this.cursor.y + 1, 1);
96
+ }
97
+ break;
98
+ case '\r': case '\n': case 'enter':
99
+ this.lines.splice(this.cursor.y + 1, 0, line.slice(this.cursor.x));
100
+ this.lines[this.cursor.y] = line.slice(0, this.cursor.x);
101
+ this.cursor.y++;
102
+ this.cursor.x = 0;
103
+ break;
104
+ case '\x1b[D': case 'left':
105
+ if (this.cursor.x > 0) {
106
+ this.cursor.x--;
107
+ } else if (this.cursor.y > 0) {
108
+ this.cursor.y--;
109
+ this.cursor.x = this.lines[this.cursor.y].length;
110
+ }
111
+ break;
112
+ case '\x1b[C': case 'right':
113
+ if (this.cursor.x < line.length) {
114
+ this.cursor.x++;
115
+ } else if (this.cursor.y < this.lines.length - 1) {
116
+ this.cursor.y++;
117
+ this.cursor.x = 0;
118
+ }
119
+ break;
120
+ case '\x1b[A': case 'up':
121
+ if (this.cursor.y > 0) {
122
+ this.cursor.y--;
123
+ this.cursor.x = Math.min(this.cursor.x, this.lines[this.cursor.y].length);
124
+ }
125
+ break;
126
+ case '\x1b[B': case 'down':
127
+ if (this.cursor.y < this.lines.length - 1) {
128
+ this.cursor.y++;
129
+ this.cursor.x = Math.min(this.cursor.x, this.lines[this.cursor.y].length);
130
+ }
131
+ break;
132
+ case '\x1b[H': case 'home':
133
+ this.cursor.x = 0;
134
+ break;
135
+ case '\x1b[F': case 'end':
136
+ this.cursor.x = this.lines[this.cursor.y].length;
137
+ break;
138
+ default:
139
+ if (key.length === 1 && key >= ' ') {
140
+ this.lines[this.cursor.y] = line.slice(0, this.cursor.x) + key + line.slice(this.cursor.x);
141
+ this.cursor.x++;
142
+ }
143
+ }
144
+ this.clampScroll();
145
+ }
146
+
147
+ /** Rebuilds the TextArea: renders visible lines, draws cursor. */
148
+ public override render(): void {
149
+ this.clear();
150
+
151
+ const { width, height } = this.getInnerSize();
152
+ const isEmpty = this.lines.length === 1 && this.lines[0] === '';
153
+ const phStyle = this.disabled ? undefined : this.placeholderStyleId;
154
+
155
+ if (isEmpty && !this.focused && this.placeholder !== '') {
156
+ this.writeText(this.placeholder.slice(0, width), { style: phStyle });
157
+ } else {
158
+ for (let row = 0; row < height; row++) {
159
+ const lineIdx = row + this.scrollY;
160
+ if (lineIdx >= this.lines.length) break;
161
+ const visible = this.lines[lineIdx].slice(this.scrollX, this.scrollX + width);
162
+ this.writeText(visible, { x: 0, y: row, style: this.disabled ? undefined : this.normalStyleId });
163
+ }
164
+ }
165
+
166
+ // Draw cursor when focused.
167
+ if (this.focused && !this.disabled) {
168
+ const screenX = this.cursor.x - this.scrollX;
169
+ const screenY = this.cursor.y - this.scrollY;
170
+ if (screenX >= 0 && screenX < width && screenY >= 0 && screenY < height) {
171
+ const cursorChar = this.lines[this.cursor.y][this.cursor.x] ?? ' ';
172
+ const cursorStyle = this.registry.merge(this.normalStyleId, this.cursorStyleId);
173
+ this.writeText(cursorChar, { x: screenX, y: screenY, style: cursorStyle });
174
+ }
175
+ }
176
+
177
+ super.render();
178
+ }
179
+
180
+ /** Adjusts scrollX/scrollY so the cursor remains within the visible area. */
181
+ private clampScroll(): void {
182
+ const { width, height } = this.getInnerSize();
183
+ if (this.cursor.y < this.scrollY) {
184
+ this.scrollY = this.cursor.y;
185
+ } else if (this.cursor.y >= this.scrollY + height) {
186
+ this.scrollY = this.cursor.y - height + 1;
187
+ }
188
+ if (this.cursor.x < this.scrollX) {
189
+ this.scrollX = this.cursor.x;
190
+ } else if (this.cursor.x >= this.scrollX + width) {
191
+ this.scrollX = this.cursor.x - width + 1;
192
+ }
193
+ }
194
+ }
@@ -0,0 +1,139 @@
1
+ import type { TextBoxProperties, WindowProperties, StyleId } from '../types.mjs';
2
+ import { BUILTIN_TEXT_PLACEHOLDER, BUILTIN_CURSOR } from '../types.mjs';
3
+ import { Window } from '../Window.mjs';
4
+ import { getRegistry } from '../RegistryHolder.mjs';
5
+
6
+ /** A single-line text-input widget with scrolling, cursor display, and placeholder support.
7
+ * Wraps content area inside a single-line border (total height 3 by default).
8
+ * Call handleKey() to feed raw terminal key strings from your input loop. */
9
+ export class TextBox extends Window {
10
+ private value: string;
11
+ private cursor: number;
12
+ private scrollOffset: number;
13
+ private placeholder: string;
14
+ private placeholderStyleId: StyleId;
15
+ private cursorStyleId: StyleId;
16
+
17
+ /** Creates a TextBox from window properties and optional control-specific properties.
18
+ * Uses the global StyleRegistry set by the Screen constructor. */
19
+ public constructor(wp: WindowProperties, cp?: TextBoxProperties) {
20
+ super({
21
+ ...wp,
22
+ defaultBorder: { top: true, right: true, bottom: true, left: true, style: 'single' },
23
+ });
24
+
25
+ this.value = cp?.value ?? '';
26
+ this.placeholder = cp?.placeholder ?? '';
27
+ this.scrollOffset = 0;
28
+ this.cursor = cp?.cursor !== undefined
29
+ ? Math.max(0, Math.min(cp.cursor, this.value.length))
30
+ : this.value.length;
31
+
32
+ const reg = getRegistry();
33
+ this.placeholderStyleId = reg.getNamed(BUILTIN_TEXT_PLACEHOLDER)!;
34
+ this.cursorStyleId = reg.getNamed(BUILTIN_CURSOR)!;
35
+
36
+ this.clampScroll();
37
+ }
38
+
39
+ /** Replaces the current value; clamps cursor and scroll to fit. */
40
+ public setValue(value: string): void {
41
+ this.value = value;
42
+ this.cursor = Math.min(this.cursor, value.length);
43
+ this.clampScroll();
44
+ }
45
+
46
+ /** Returns the current text value. */
47
+ public getValue(): string {
48
+ return this.value;
49
+ }
50
+
51
+ /** Sets the cursor to the given character index (clamped to valid range). */
52
+ public setCursor(pos: number): void {
53
+ this.cursor = Math.max(0, Math.min(pos, this.value.length));
54
+ this.clampScroll();
55
+ }
56
+
57
+ /** Returns the current cursor character index. */
58
+ public getCursor(): number {
59
+ return this.cursor;
60
+ }
61
+
62
+ /** Processes a key string from the terminal input loop and updates value/cursor.
63
+ * Supported special keys: backspace (\x7f), delete (\x1b[3~), left (\x1b[D),
64
+ * right (\x1b[C), home (\x1b[H), end (\x1b[F).
65
+ * Human-readable aliases: 'backspace', 'delete', 'left', 'right', 'home', 'end'.
66
+ * Any single printable character is inserted at the cursor position. */
67
+ public handleKey(key: string): void {
68
+ if (this.disabled) return;
69
+ switch (key) {
70
+ case '\x7f': case '\b': case 'backspace':
71
+ if (this.cursor > 0) {
72
+ this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
73
+ this.cursor--;
74
+ }
75
+ break;
76
+ case '\x1b[3~': case 'delete':
77
+ if (this.cursor < this.value.length) {
78
+ this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
79
+ }
80
+ break;
81
+ case '\x1b[D': case 'left':
82
+ if (this.cursor > 0) this.cursor--;
83
+ break;
84
+ case '\x1b[C': case 'right':
85
+ if (this.cursor < this.value.length) this.cursor++;
86
+ break;
87
+ case '\x1b[H': case 'home':
88
+ this.cursor = 0;
89
+ break;
90
+ case '\x1b[F': case 'end':
91
+ this.cursor = this.value.length;
92
+ break;
93
+ default:
94
+ if (key.length === 1 && key >= ' ') {
95
+ this.value = this.value.slice(0, this.cursor) + key + this.value.slice(this.cursor);
96
+ this.cursor++;
97
+ }
98
+ }
99
+ this.clampScroll();
100
+ }
101
+
102
+ /** Rebuilds the TextBox: renders text or placeholder, draws cursor. */
103
+ public override render(): void {
104
+ this.clear();
105
+
106
+ const { width } = this.getInnerSize();
107
+ const isEmpty = this.value === '';
108
+ const phStyle = this.disabled ? undefined : this.placeholderStyleId;
109
+
110
+ if (isEmpty && !this.focused && this.placeholder !== '') {
111
+ this.writeText(this.placeholder.slice(0, width), { style: phStyle });
112
+ } else if (!isEmpty) {
113
+ const visible = this.value.slice(this.scrollOffset, this.scrollOffset + width);
114
+ this.writeText(visible, { style: this.disabled ? undefined : this.normalStyleId });
115
+ }
116
+
117
+ // Draw cursor when focused.
118
+ if (this.focused && !this.disabled) {
119
+ const cursorX = this.cursor - this.scrollOffset;
120
+ if (cursorX >= 0 && cursorX < width) {
121
+ const cursorChar = this.value[this.cursor] ?? ' ';
122
+ const cursorStyle = this.registry.merge(this.normalStyleId, this.cursorStyleId);
123
+ this.writeText(cursorChar, { x: cursorX, y: 0, style: cursorStyle });
124
+ }
125
+ }
126
+
127
+ super.render();
128
+ }
129
+
130
+ /** Adjusts scrollOffset so the cursor stays within the visible window. */
131
+ private clampScroll(): void {
132
+ const { width } = this.getInnerSize();
133
+ if (this.cursor < this.scrollOffset) {
134
+ this.scrollOffset = this.cursor;
135
+ } else if (this.cursor >= this.scrollOffset + width) {
136
+ this.scrollOffset = this.cursor - width + 1;
137
+ }
138
+ }
139
+ }