@linxiraos/pi-tui 1.0.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/CHANGELOG.md +2219 -0
- package/README.md +705 -0
- package/dist/types/autocomplete.d.ts +116 -0
- package/dist/types/bracketed-paste.d.ts +51 -0
- package/dist/types/components/box.d.ts +31 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +162 -0
- package/dist/types/components/image.d.ts +112 -0
- package/dist/types/components/input.d.ts +25 -0
- package/dist/types/components/loader.d.ts +25 -0
- package/dist/types/components/markdown.d.ts +88 -0
- package/dist/types/components/scroll-view.d.ts +62 -0
- package/dist/types/components/select-list.d.ts +69 -0
- package/dist/types/components/settings-list.d.ts +123 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +89 -0
- package/dist/types/components/text.d.ts +27 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/deccara.d.ts +49 -0
- package/dist/types/desktop-notify.d.ts +52 -0
- package/dist/types/editor-component.d.ts +38 -0
- package/dist/types/fuzzy.d.ts +48 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/keybindings.d.ts +197 -0
- package/dist/types/keys.d.ts +210 -0
- package/dist/types/kill-ring.d.ts +20 -0
- package/dist/types/kitty-graphics.d.ts +76 -0
- package/dist/types/latex-block.d.ts +8 -0
- package/dist/types/latex-to-unicode.d.ts +50 -0
- package/dist/types/loop-watchdog.d.ts +44 -0
- package/dist/types/mouse.d.ts +67 -0
- package/dist/types/stdin-buffer.d.ts +60 -0
- package/dist/types/symbols.d.ts +25 -0
- package/dist/types/terminal-capabilities.d.ts +285 -0
- package/dist/types/terminal.d.ts +175 -0
- package/dist/types/tmux.d.ts +6 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +457 -0
- package/dist/types/utils.d.ts +100 -0
- package/package.json +70 -0
- package/src/autocomplete.ts +1079 -0
- package/src/bracketed-paste.ts +123 -0
- package/src/components/box.ts +236 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +3301 -0
- package/src/components/image.ts +460 -0
- package/src/components/input.ts +482 -0
- package/src/components/loader.ts +174 -0
- package/src/components/markdown.ts +3119 -0
- package/src/components/scroll-view.ts +227 -0
- package/src/components/select-list.ts +539 -0
- package/src/components/settings-list.ts +793 -0
- package/src/components/spacer.ts +32 -0
- package/src/components/tab-bar.ts +300 -0
- package/src/components/text.ts +173 -0
- package/src/components/truncated-text.ts +69 -0
- package/src/deccara.ts +314 -0
- package/src/desktop-notify.ts +192 -0
- package/src/editor-component.ts +74 -0
- package/src/fuzzy.ts +384 -0
- package/src/index.ts +51 -0
- package/src/keybindings.ts +346 -0
- package/src/keys.ts +566 -0
- package/src/kill-ring.ts +51 -0
- package/src/kitty-graphics.ts +171 -0
- package/src/latex-block.ts +1338 -0
- package/src/latex-to-unicode.ts +2017 -0
- package/src/loop-watchdog.ts +115 -0
- package/src/mouse.ts +105 -0
- package/src/stdin-buffer.ts +781 -0
- package/src/symbols.ts +26 -0
- package/src/terminal-capabilities.ts +1211 -0
- package/src/terminal.ts +1854 -0
- package/src/tmux.ts +14 -0
- package/src/ttyid.ts +84 -0
- package/src/tui.ts +4275 -0
- package/src/utils.ts +619 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Component } from "../tui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spacer component that renders empty lines
|
|
5
|
+
*/
|
|
6
|
+
export class Spacer implements Component {
|
|
7
|
+
#lines: number;
|
|
8
|
+
#cached: string[] | undefined;
|
|
9
|
+
|
|
10
|
+
constructor(lines: number = 1) {
|
|
11
|
+
this.#lines = lines;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
setLines(lines: number): void {
|
|
15
|
+
if (lines === this.#lines) return;
|
|
16
|
+
this.#lines = lines;
|
|
17
|
+
this.#cached = undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
invalidate(): void {
|
|
21
|
+
// No cached state to invalidate currently
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
render(_width: number): readonly string[] {
|
|
25
|
+
let cached = this.#cached;
|
|
26
|
+
if (cached === undefined) {
|
|
27
|
+
cached = new Array(this.#lines).fill("");
|
|
28
|
+
this.#cached = cached;
|
|
29
|
+
}
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab Bar Component
|
|
3
|
+
*
|
|
4
|
+
* A horizontal tab bar for switching between views/panels.
|
|
5
|
+
* Renders as: "Label: Tab1 Tab2 Tab3 (tab to cycle)"
|
|
6
|
+
*
|
|
7
|
+
* Navigation:
|
|
8
|
+
* - Tab / Arrow Right: Next tab (wraps around)
|
|
9
|
+
* - Shift+Tab / Arrow Left: Previous tab (wraps around)
|
|
10
|
+
*/
|
|
11
|
+
import { matchesKey } from "../keys";
|
|
12
|
+
import type { Component } from "../tui";
|
|
13
|
+
import { truncateToWidth, visibleWidth } from "../utils";
|
|
14
|
+
|
|
15
|
+
/** Tab definition */
|
|
16
|
+
export interface Tab {
|
|
17
|
+
/** Unique identifier for the tab */
|
|
18
|
+
id: string;
|
|
19
|
+
/** Display label shown in the tab bar */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Compact form (e.g. just the icon) used when the bar must shrink to fit one line. */
|
|
22
|
+
short?: string;
|
|
23
|
+
/** Render with the muted style and skip during keyboard navigation. */
|
|
24
|
+
muted?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Theme for styling the tab bar */
|
|
28
|
+
export interface TabBarTheme {
|
|
29
|
+
/** Style for the label prefix (e.g., "Settings:") */
|
|
30
|
+
label: (text: string) => string;
|
|
31
|
+
/** Style for the currently active tab */
|
|
32
|
+
activeTab: (text: string) => string;
|
|
33
|
+
/** Style for inactive tabs */
|
|
34
|
+
inactiveTab: (text: string) => string;
|
|
35
|
+
/** Style for the hint text (e.g., "(tab to cycle)") */
|
|
36
|
+
hint: (text: string) => string;
|
|
37
|
+
/** Style for muted tabs. Falls back to `inactiveTab` when omitted. */
|
|
38
|
+
mutedTab?: (text: string) => string;
|
|
39
|
+
/** Style for the tab under the mouse pointer. Falls back to `inactiveTab` when omitted. */
|
|
40
|
+
hoverTab?: (text: string) => string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Horizontal tab bar component.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* const tabs = [
|
|
49
|
+
* { id: "config", label: "Config" },
|
|
50
|
+
* { id: "tools", label: "Tools" },
|
|
51
|
+
* ];
|
|
52
|
+
* const tabBar = new TabBar("Settings", tabs, theme);
|
|
53
|
+
* tabBar.onTabChange = (tab) => console.log(`Switched to ${tab.id}`);
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export class TabBar implements Component {
|
|
57
|
+
#tabs: Tab[];
|
|
58
|
+
#activeIndex: number = 0;
|
|
59
|
+
#theme: TabBarTheme;
|
|
60
|
+
#label: string;
|
|
61
|
+
#hoverTabId: string | null = null;
|
|
62
|
+
/** Per-render tab hit zones: 0-based line + [start, end) columns. */
|
|
63
|
+
#hitZones: { line: number; start: number; end: number; index: number }[] = [];
|
|
64
|
+
|
|
65
|
+
/** Callback fired when the active tab changes */
|
|
66
|
+
onTabChange?: (tab: Tab, index: number) => void;
|
|
67
|
+
|
|
68
|
+
/** Render the trailing "(tab to cycle)" hint. Disable when the host folds the hint into its own footer. */
|
|
69
|
+
showHint = true;
|
|
70
|
+
|
|
71
|
+
constructor(label: string, tabs: Tab[], theme: TabBarTheme, initialIndex: number = 0) {
|
|
72
|
+
this.#label = label;
|
|
73
|
+
this.#tabs = tabs;
|
|
74
|
+
this.#theme = theme;
|
|
75
|
+
this.#activeIndex = initialIndex;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Get the currently active tab */
|
|
79
|
+
getActiveTab(): Tab {
|
|
80
|
+
return this.#tabs[this.#activeIndex];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Get the index of the currently active tab */
|
|
84
|
+
getActiveIndex(): number {
|
|
85
|
+
return this.#activeIndex;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Set the active tab by index (clamped to valid range) */
|
|
89
|
+
setActiveIndex(index: number): void {
|
|
90
|
+
const newIndex = Math.max(0, Math.min(index, this.#tabs.length - 1));
|
|
91
|
+
if (newIndex !== this.#activeIndex) {
|
|
92
|
+
this.#activeIndex = newIndex;
|
|
93
|
+
this.onTabChange?.(this.#tabs[this.#activeIndex], this.#activeIndex);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Replace the tab set without firing onTabChange. The active tab is
|
|
99
|
+
* preserved by id when it survives the swap (or forced via `activeId`);
|
|
100
|
+
* otherwise the index is clamped.
|
|
101
|
+
*/
|
|
102
|
+
setTabs(tabs: Tab[], activeId?: string): void {
|
|
103
|
+
const targetId = activeId ?? this.#tabs[this.#activeIndex]?.id;
|
|
104
|
+
this.#tabs = tabs;
|
|
105
|
+
const index = tabs.findIndex(tab => tab.id === targetId);
|
|
106
|
+
this.#activeIndex = index >= 0 ? index : Math.max(0, Math.min(this.#activeIndex, tabs.length - 1));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Set the active tab by id without firing onTabChange. Returns false when the id is unknown. */
|
|
110
|
+
setActiveById(id: string): boolean {
|
|
111
|
+
const index = this.#tabs.findIndex(tab => tab.id === id);
|
|
112
|
+
if (index === -1) return false;
|
|
113
|
+
this.#activeIndex = index;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Activate the tab with `id`, firing onTabChange when it changes. Muted tabs are ignored. */
|
|
118
|
+
selectTab(id: string): boolean {
|
|
119
|
+
const index = this.#tabs.findIndex(tab => tab.id === id);
|
|
120
|
+
if (index === -1 || this.#tabs[index]?.muted) return false;
|
|
121
|
+
this.setActiveIndex(index);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Move to the next non-muted tab (wraps to first tab after last) */
|
|
126
|
+
nextTab(): void {
|
|
127
|
+
this.#stepTab(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Move to the previous non-muted tab (wraps to last tab before first) */
|
|
131
|
+
prevTab(): void {
|
|
132
|
+
this.#stepTab(-1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Step to the nearest non-muted tab in `delta` direction; no-op when none exists. */
|
|
136
|
+
#stepTab(delta: -1 | 1): void {
|
|
137
|
+
const len = this.#tabs.length;
|
|
138
|
+
if (len === 0) return;
|
|
139
|
+
for (let step = 1; step <= len; step++) {
|
|
140
|
+
const index = (((this.#activeIndex + delta * step) % len) + len) % len;
|
|
141
|
+
if (!this.#tabs[index]?.muted) {
|
|
142
|
+
this.setActiveIndex(index);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
invalidate(): void {
|
|
149
|
+
// No cached state to invalidate
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Handle keyboard input for tab navigation.
|
|
154
|
+
* @returns true if the input was handled, false otherwise
|
|
155
|
+
*/
|
|
156
|
+
handleInput(data: string): boolean {
|
|
157
|
+
if (matchesKey(data, "tab") || matchesKey(data, "right")) {
|
|
158
|
+
this.nextTab();
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
|
|
162
|
+
this.prevTab();
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Render the tab bar. When the full labels overflow the width, tabs are
|
|
170
|
+
* collapsed to their `short` form one by one — starting with the tabs
|
|
171
|
+
* farthest from the active one — until the bar fits on a single line.
|
|
172
|
+
* Wrapping to multiple lines is the last resort.
|
|
173
|
+
*/
|
|
174
|
+
render(width: number): readonly string[] {
|
|
175
|
+
const maxWidth = Math.max(1, width);
|
|
176
|
+
|
|
177
|
+
interface TabChunk {
|
|
178
|
+
text: string;
|
|
179
|
+
/** Index into #tabs when this chunk is a clickable tab button. */
|
|
180
|
+
tabIndex?: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const buildChunks = (labels: readonly string[]): TabChunk[] => {
|
|
184
|
+
const chunks: TabChunk[] = [];
|
|
185
|
+
// Label prefix (omitted when the label is empty)
|
|
186
|
+
if (this.#label) {
|
|
187
|
+
chunks.push({ text: this.#theme.label(`${this.#label}:`) });
|
|
188
|
+
chunks.push({ text: " " });
|
|
189
|
+
}
|
|
190
|
+
for (let i = 0; i < this.#tabs.length; i++) {
|
|
191
|
+
const tab = this.#tabs[i];
|
|
192
|
+
// Muted tabs never take the active highlight: they are skipped by
|
|
193
|
+
// navigation and only become "active" transiently via setTabs swaps.
|
|
194
|
+
// A hovered (non-active) tab lights up so mouse users see the target.
|
|
195
|
+
const hovered = tab.id === this.#hoverTabId && !tab.muted && i !== this.#activeIndex;
|
|
196
|
+
const style = tab.muted
|
|
197
|
+
? (this.#theme.mutedTab ?? this.#theme.inactiveTab)
|
|
198
|
+
: i === this.#activeIndex
|
|
199
|
+
? this.#theme.activeTab
|
|
200
|
+
: hovered
|
|
201
|
+
? (this.#theme.hoverTab ?? this.#theme.inactiveTab)
|
|
202
|
+
: this.#theme.inactiveTab;
|
|
203
|
+
chunks.push({ text: style(` ${labels[i]} `), tabIndex: i });
|
|
204
|
+
if (i < this.#tabs.length - 1) {
|
|
205
|
+
chunks.push({ text: " " });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Navigation hint
|
|
209
|
+
if (this.showHint) {
|
|
210
|
+
chunks.push({ text: " " });
|
|
211
|
+
chunks.push({ text: this.#theme.hint("(tab to cycle)") });
|
|
212
|
+
}
|
|
213
|
+
return chunks;
|
|
214
|
+
};
|
|
215
|
+
const totalWidth = (chunks: TabChunk[]): number =>
|
|
216
|
+
chunks.reduce((sum, chunk) => sum + visibleWidth(chunk.text), 0);
|
|
217
|
+
|
|
218
|
+
const labels = this.#tabs.map(tab => tab.label);
|
|
219
|
+
let chunks = buildChunks(labels);
|
|
220
|
+
|
|
221
|
+
if (totalWidth(chunks) > maxWidth) {
|
|
222
|
+
const collapseOrder = this.#tabs
|
|
223
|
+
.map((_, index) => index)
|
|
224
|
+
.filter(index => index !== this.#activeIndex && this.#tabs[index].short !== undefined)
|
|
225
|
+
.sort((a, b) => Math.abs(b - this.#activeIndex) - Math.abs(a - this.#activeIndex));
|
|
226
|
+
for (const index of collapseOrder) {
|
|
227
|
+
labels[index] = this.#tabs[index].short ?? this.#tabs[index].label;
|
|
228
|
+
chunks = buildChunks(labels);
|
|
229
|
+
if (totalWidth(chunks) <= maxWidth) break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.#hitZones = [];
|
|
234
|
+
const lines: string[] = [];
|
|
235
|
+
let currentLine = "";
|
|
236
|
+
let currentWidth = 0;
|
|
237
|
+
|
|
238
|
+
for (const chunk of chunks) {
|
|
239
|
+
const chunkWidth = visibleWidth(chunk.text);
|
|
240
|
+
if (chunkWidth <= 0) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (chunkWidth > maxWidth) {
|
|
245
|
+
if (currentLine) {
|
|
246
|
+
lines.push(currentLine);
|
|
247
|
+
currentLine = "";
|
|
248
|
+
currentWidth = 0;
|
|
249
|
+
}
|
|
250
|
+
if (chunk.tabIndex !== undefined) {
|
|
251
|
+
this.#hitZones.push({ line: lines.length, start: 0, end: maxWidth, index: chunk.tabIndex });
|
|
252
|
+
}
|
|
253
|
+
lines.push(truncateToWidth(chunk.text, maxWidth));
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (currentWidth > 0 && currentWidth + chunkWidth > maxWidth) {
|
|
258
|
+
lines.push(currentLine);
|
|
259
|
+
currentLine = "";
|
|
260
|
+
currentWidth = 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (chunk.tabIndex !== undefined) {
|
|
264
|
+
this.#hitZones.push({
|
|
265
|
+
line: lines.length,
|
|
266
|
+
start: currentWidth,
|
|
267
|
+
end: currentWidth + chunkWidth,
|
|
268
|
+
index: chunk.tabIndex,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
currentLine += chunk.text;
|
|
272
|
+
currentWidth += chunkWidth;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (currentLine) {
|
|
276
|
+
lines.push(currentLine);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return lines.length > 0 ? lines : [""];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Resolve a pointer position against the last rendered frame. `line` is the
|
|
284
|
+
* 0-based line index within this component's render output, `col` the
|
|
285
|
+
* 0-based column.
|
|
286
|
+
*/
|
|
287
|
+
tabAt(line: number, col: number): Tab | undefined {
|
|
288
|
+
for (const zone of this.#hitZones) {
|
|
289
|
+
if (zone.line === line && col >= zone.start && col < zone.end) {
|
|
290
|
+
return this.#tabs[zone.index];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Highlight the tab under the pointer (null clears). */
|
|
297
|
+
setHoverTab(id: string | null): void {
|
|
298
|
+
this.#hoverTabId = id;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { Component } from "../tui";
|
|
2
|
+
import {
|
|
3
|
+
applyBackgroundToLine,
|
|
4
|
+
getPaddingX,
|
|
5
|
+
getWidthConfigEpoch,
|
|
6
|
+
padding,
|
|
7
|
+
publishLineWidths,
|
|
8
|
+
replaceTabs,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
wrapTextWithAnsi,
|
|
11
|
+
} from "../utils";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Text component - displays multi-line text with word wrapping.
|
|
15
|
+
*
|
|
16
|
+
* Foreground colors may be supplied lazily via {@link setStyleFn} instead of
|
|
17
|
+
* baked into `text`: the styler runs at render time, so a caller that
|
|
18
|
+
* invalidates the component on a theme change (see the coding-agent's
|
|
19
|
+
* `onThemeChange` handler) re-resolves the color against the now-active theme
|
|
20
|
+
* rather than replaying the palette active when the component was constructed.
|
|
21
|
+
*/
|
|
22
|
+
export class Text implements Component {
|
|
23
|
+
#text: string;
|
|
24
|
+
#paddingX: number; // Left/right padding
|
|
25
|
+
#paddingY: number; // Top/bottom padding
|
|
26
|
+
#customBgFn?: (text: string) => string;
|
|
27
|
+
#styleFn?: (text: string) => string;
|
|
28
|
+
|
|
29
|
+
#ignoreTight = false;
|
|
30
|
+
|
|
31
|
+
setIgnoreTight(ignore: boolean): this {
|
|
32
|
+
this.#ignoreTight = ignore;
|
|
33
|
+
this.invalidate();
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Cache for rendered output
|
|
38
|
+
#cachedText?: string;
|
|
39
|
+
#cachedWidth?: number;
|
|
40
|
+
#cachedWidthEpoch?: number;
|
|
41
|
+
#cachedLines?: string[];
|
|
42
|
+
|
|
43
|
+
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
|
|
44
|
+
this.#text = text;
|
|
45
|
+
this.#paddingX = paddingX;
|
|
46
|
+
this.#paddingY = paddingY;
|
|
47
|
+
this.#customBgFn = customBgFn;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getText(): string {
|
|
51
|
+
return this.#text;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setText(text: string): boolean {
|
|
55
|
+
if (text === this.#text) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
this.#text = text;
|
|
59
|
+
this.#cachedText = undefined;
|
|
60
|
+
this.#cachedWidth = undefined;
|
|
61
|
+
this.#cachedWidthEpoch = undefined;
|
|
62
|
+
this.#cachedLines = undefined;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setCustomBgFn(customBgFn?: (text: string) => string): void {
|
|
67
|
+
this.#customBgFn = customBgFn;
|
|
68
|
+
this.#cachedText = undefined;
|
|
69
|
+
this.#cachedWidth = undefined;
|
|
70
|
+
this.#cachedWidthEpoch = undefined;
|
|
71
|
+
this.#cachedLines = undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Supply a foreground styler applied to the text at render time (e.g. a
|
|
76
|
+
* theme color resolver). Unlike baking the color into `text`, the styler
|
|
77
|
+
* re-runs on every render, so invalidating the component after a theme
|
|
78
|
+
* change re-resolves the color against the active theme.
|
|
79
|
+
*/
|
|
80
|
+
setStyleFn(styleFn?: (text: string) => string): this {
|
|
81
|
+
this.#styleFn = styleFn;
|
|
82
|
+
this.#cachedText = undefined;
|
|
83
|
+
this.#cachedWidth = undefined;
|
|
84
|
+
this.#cachedWidthEpoch = undefined;
|
|
85
|
+
this.#cachedLines = undefined;
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
invalidate(): void {
|
|
90
|
+
this.#cachedText = undefined;
|
|
91
|
+
this.#cachedWidth = undefined;
|
|
92
|
+
this.#cachedWidthEpoch = undefined;
|
|
93
|
+
this.#cachedLines = undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
render(width: number): readonly string[] {
|
|
97
|
+
// Check cache
|
|
98
|
+
if (
|
|
99
|
+
this.#cachedLines &&
|
|
100
|
+
this.#cachedText === this.#text &&
|
|
101
|
+
this.#cachedWidth === width &&
|
|
102
|
+
this.#cachedWidthEpoch === getWidthConfigEpoch()
|
|
103
|
+
) {
|
|
104
|
+
return this.#cachedLines;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Don't render anything if there's no actual text
|
|
108
|
+
if (!this.#text || this.#text.trim() === "") {
|
|
109
|
+
const result: string[] = [];
|
|
110
|
+
this.#cachedText = this.#text;
|
|
111
|
+
this.#cachedWidth = width;
|
|
112
|
+
this.#cachedWidthEpoch = getWidthConfigEpoch();
|
|
113
|
+
this.#cachedLines = result;
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Replace tabs with 3 spaces
|
|
118
|
+
const normalizedText = replaceTabs(this.#styleFn ? this.#styleFn(this.#text) : this.#text);
|
|
119
|
+
|
|
120
|
+
// Calculate content width (subtract left/right margins)
|
|
121
|
+
const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
|
|
122
|
+
const contentWidth = Math.max(1, width - paddingX * 2);
|
|
123
|
+
// Wrap text (this preserves ANSI codes but does NOT pad)
|
|
124
|
+
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
|
|
125
|
+
|
|
126
|
+
// Add margins and background to each line
|
|
127
|
+
const leftMargin = padding(paddingX);
|
|
128
|
+
const rightMargin = padding(paddingX);
|
|
129
|
+
const contentLines: string[] = [];
|
|
130
|
+
// Exact visible widths of `result` rows, published only when rows are
|
|
131
|
+
// `content + spaces` (customBgFn output width is not knowable here).
|
|
132
|
+
const resultWidths: number[] | undefined = this.#customBgFn ? undefined : [];
|
|
133
|
+
|
|
134
|
+
for (const line of wrappedLines) {
|
|
135
|
+
// Add margins
|
|
136
|
+
const lineWithMargins = leftMargin + line + rightMargin;
|
|
137
|
+
|
|
138
|
+
// Apply background if specified (this also pads to full width)
|
|
139
|
+
if (this.#customBgFn) {
|
|
140
|
+
contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.#customBgFn));
|
|
141
|
+
} else {
|
|
142
|
+
// No background - just pad to width with spaces
|
|
143
|
+
const visibleLen = visibleWidth(lineWithMargins);
|
|
144
|
+
const paddingNeeded = Math.max(0, width - visibleLen);
|
|
145
|
+
contentLines.push(lineWithMargins + padding(paddingNeeded));
|
|
146
|
+
resultWidths?.push(visibleLen + paddingNeeded);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Add top/bottom padding (empty lines)
|
|
151
|
+
const emptyLine = padding(width);
|
|
152
|
+
const emptyLines: string[] = [];
|
|
153
|
+
for (let i = 0; i < this.#paddingY; i++) {
|
|
154
|
+
const line = this.#customBgFn ? applyBackgroundToLine(emptyLine, width, this.#customBgFn) : emptyLine;
|
|
155
|
+
emptyLines.push(line);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = [...emptyLines, ...contentLines, ...emptyLines];
|
|
159
|
+
if (resultWidths !== undefined) {
|
|
160
|
+
// Pad rows are exactly `width` cells wide.
|
|
161
|
+
const emptyWidths = new Array<number>(emptyLines.length).fill(width);
|
|
162
|
+
publishLineWidths(result, [...emptyWidths, ...resultWidths, ...emptyWidths]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Update cache
|
|
166
|
+
this.#cachedText = this.#text;
|
|
167
|
+
this.#cachedWidth = width;
|
|
168
|
+
this.#cachedWidthEpoch = getWidthConfigEpoch();
|
|
169
|
+
this.#cachedLines = result;
|
|
170
|
+
|
|
171
|
+
return result.length > 0 ? result : [""];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Component } from "../tui";
|
|
2
|
+
import { padding, truncateToWidth } from "../utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Text component that truncates to fit viewport width
|
|
6
|
+
*/
|
|
7
|
+
export class TruncatedText implements Component {
|
|
8
|
+
#text: string;
|
|
9
|
+
#paddingX: number;
|
|
10
|
+
#paddingY: number;
|
|
11
|
+
#cachedWidth = -1;
|
|
12
|
+
#cachedLines: string[] | undefined;
|
|
13
|
+
|
|
14
|
+
constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
|
|
15
|
+
this.#text = text;
|
|
16
|
+
this.#paddingX = paddingX;
|
|
17
|
+
this.#paddingY = paddingY;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
invalidate(): void {
|
|
21
|
+
this.#cachedWidth = -1;
|
|
22
|
+
this.#cachedLines = undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
render(width: number): readonly string[] {
|
|
26
|
+
if (this.#cachedLines && this.#cachedWidth === width) {
|
|
27
|
+
return this.#cachedLines;
|
|
28
|
+
}
|
|
29
|
+
const result: string[] = [];
|
|
30
|
+
|
|
31
|
+
// Empty line padded to width
|
|
32
|
+
const emptyLine = padding(width);
|
|
33
|
+
|
|
34
|
+
// Add vertical padding above
|
|
35
|
+
for (let i = 0; i < this.#paddingY; i++) {
|
|
36
|
+
result.push(emptyLine);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Calculate available width after horizontal padding
|
|
40
|
+
const availableWidth = Math.max(1, width - this.#paddingX * 2);
|
|
41
|
+
|
|
42
|
+
// Take only the first line (stop at newline)
|
|
43
|
+
let singleLineText = this.#text;
|
|
44
|
+
const newlineIndex = this.#text.indexOf("\n");
|
|
45
|
+
if (newlineIndex !== -1) {
|
|
46
|
+
singleLineText = this.#text.substring(0, newlineIndex);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Truncate text if needed (accounting for ANSI codes)
|
|
50
|
+
const displayText = truncateToWidth(singleLineText, availableWidth);
|
|
51
|
+
|
|
52
|
+
// Add horizontal padding
|
|
53
|
+
const leftPadding = padding(this.#paddingX);
|
|
54
|
+
const rightPadding = padding(this.#paddingX);
|
|
55
|
+
const lineWithPadding = leftPadding + displayText + rightPadding;
|
|
56
|
+
|
|
57
|
+
// Don't pad to full width - avoids trailing spaces when copying
|
|
58
|
+
result.push(lineWithPadding);
|
|
59
|
+
|
|
60
|
+
// Add vertical padding below
|
|
61
|
+
for (let i = 0; i < this.#paddingY; i++) {
|
|
62
|
+
result.push(emptyLine);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.#cachedWidth = width;
|
|
66
|
+
this.#cachedLines = result;
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
}
|