@prometheus-ai/tui 0.5.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 +7 -0
- package/README.md +704 -0
- package/dist/types/autocomplete.d.ts +76 -0
- package/dist/types/bracketed-paste.d.ts +26 -0
- package/dist/types/components/box.d.ts +17 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +105 -0
- package/dist/types/components/image.d.ts +84 -0
- package/dist/types/components/input.d.ts +18 -0
- package/dist/types/components/loader.d.ts +13 -0
- package/dist/types/components/markdown.d.ts +61 -0
- package/dist/types/components/scroll-view.d.ts +40 -0
- package/dist/types/components/select-list.d.ts +48 -0
- package/dist/types/components/settings-list.d.ts +41 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +56 -0
- package/dist/types/components/text.d.ts +13 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/deccara.d.ts +49 -0
- package/dist/types/editor-component.d.ts +36 -0
- package/dist/types/fuzzy.d.ts +15 -0
- package/dist/types/index.d.ts +28 -0
- package/dist/types/keybindings.d.ts +189 -0
- package/dist/types/keys.d.ts +208 -0
- package/dist/types/kill-ring.d.ts +27 -0
- package/dist/types/kitty-graphics.d.ts +94 -0
- package/dist/types/stdin-buffer.d.ts +43 -0
- package/dist/types/symbols.d.ts +25 -0
- package/dist/types/terminal-capabilities.d.ts +196 -0
- package/dist/types/terminal.d.ts +103 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +275 -0
- package/dist/types/utils.d.ts +89 -0
- package/package.json +73 -0
- package/src/autocomplete.ts +871 -0
- package/src/bracketed-paste.ts +47 -0
- package/src/components/box.ts +156 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +2695 -0
- package/src/components/image.ts +318 -0
- package/src/components/input.ts +459 -0
- package/src/components/loader.ts +86 -0
- package/src/components/markdown.ts +1189 -0
- package/src/components/scroll-view.ts +166 -0
- package/src/components/select-list.ts +331 -0
- package/src/components/settings-list.ts +212 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +175 -0
- package/src/components/text.ts +110 -0
- package/src/components/truncated-text.ts +61 -0
- package/src/deccara.ts +314 -0
- package/src/editor-component.ts +71 -0
- package/src/fuzzy.ts +143 -0
- package/src/index.ts +44 -0
- package/src/keybindings.ts +279 -0
- package/src/keys.ts +537 -0
- package/src/kill-ring.ts +46 -0
- package/src/kitty-graphics.ts +270 -0
- package/src/stdin-buffer.ts +423 -0
- package/src/symbols.ts +26 -0
- package/src/terminal-capabilities.ts +1009 -0
- package/src/terminal.ts +1114 -0
- package/src/ttyid.ts +70 -0
- package/src/tui.ts +2988 -0
- package/src/utils.ts +452 -0
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
|
|
9
|
+
constructor(lines: number = 1) {
|
|
10
|
+
this.#lines = lines;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
setLines(lines: number): void {
|
|
14
|
+
this.#lines = lines;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
invalidate(): void {
|
|
18
|
+
// No cached state to invalidate currently
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
render(_width: number): string[] {
|
|
22
|
+
const result: string[] = [];
|
|
23
|
+
for (let i = 0; i < this.#lines; i++) {
|
|
24
|
+
result.push("");
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Theme for styling the tab bar */
|
|
24
|
+
export interface TabBarTheme {
|
|
25
|
+
/** Style for the label prefix (e.g., "Settings:") */
|
|
26
|
+
label: (text: string) => string;
|
|
27
|
+
/** Style for the currently active tab */
|
|
28
|
+
activeTab: (text: string) => string;
|
|
29
|
+
/** Style for inactive tabs */
|
|
30
|
+
inactiveTab: (text: string) => string;
|
|
31
|
+
/** Style for the hint text (e.g., "(tab to cycle)") */
|
|
32
|
+
hint: (text: string) => string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Horizontal tab bar component.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const tabs = [
|
|
41
|
+
* { id: "config", label: "Config" },
|
|
42
|
+
* { id: "tools", label: "Tools" },
|
|
43
|
+
* ];
|
|
44
|
+
* const tabBar = new TabBar("Settings", tabs, theme);
|
|
45
|
+
* tabBar.onTabChange = (tab) => console.log(`Switched to ${tab.id}`);
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export class TabBar implements Component {
|
|
49
|
+
#tabs: Tab[];
|
|
50
|
+
#activeIndex: number = 0;
|
|
51
|
+
#theme: TabBarTheme;
|
|
52
|
+
#label: string;
|
|
53
|
+
|
|
54
|
+
/** Callback fired when the active tab changes */
|
|
55
|
+
onTabChange?: (tab: Tab, index: number) => void;
|
|
56
|
+
|
|
57
|
+
constructor(label: string, tabs: Tab[], theme: TabBarTheme, initialIndex: number = 0) {
|
|
58
|
+
this.#label = label;
|
|
59
|
+
this.#tabs = tabs;
|
|
60
|
+
this.#theme = theme;
|
|
61
|
+
this.#activeIndex = initialIndex;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Get the currently active tab */
|
|
65
|
+
getActiveTab(): Tab {
|
|
66
|
+
return this.#tabs[this.#activeIndex];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Get the index of the currently active tab */
|
|
70
|
+
getActiveIndex(): number {
|
|
71
|
+
return this.#activeIndex;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Set the active tab by index (clamped to valid range) */
|
|
75
|
+
setActiveIndex(index: number): void {
|
|
76
|
+
const newIndex = Math.max(0, Math.min(index, this.#tabs.length - 1));
|
|
77
|
+
if (newIndex !== this.#activeIndex) {
|
|
78
|
+
this.#activeIndex = newIndex;
|
|
79
|
+
this.onTabChange?.(this.#tabs[this.#activeIndex], this.#activeIndex);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Move to the next tab (wraps to first tab after last) */
|
|
84
|
+
nextTab(): void {
|
|
85
|
+
this.setActiveIndex((this.#activeIndex + 1) % this.#tabs.length);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Move to the previous tab (wraps to last tab before first) */
|
|
89
|
+
prevTab(): void {
|
|
90
|
+
this.setActiveIndex((this.#activeIndex - 1 + this.#tabs.length) % this.#tabs.length);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
invalidate(): void {
|
|
94
|
+
// No cached state to invalidate
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Handle keyboard input for tab navigation.
|
|
99
|
+
* @returns true if the input was handled, false otherwise
|
|
100
|
+
*/
|
|
101
|
+
handleInput(data: string): boolean {
|
|
102
|
+
if (matchesKey(data, "tab") || matchesKey(data, "right")) {
|
|
103
|
+
this.nextTab();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
|
|
107
|
+
this.prevTab();
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Render the tab bar, wrapping to multiple lines if needed */
|
|
114
|
+
render(width: number): string[] {
|
|
115
|
+
const maxWidth = Math.max(1, width);
|
|
116
|
+
const chunks: string[] = [];
|
|
117
|
+
|
|
118
|
+
// Label prefix
|
|
119
|
+
chunks.push(this.#theme.label(`${this.#label}:`));
|
|
120
|
+
chunks.push(" ");
|
|
121
|
+
|
|
122
|
+
// Tab buttons
|
|
123
|
+
for (let i = 0; i < this.#tabs.length; i++) {
|
|
124
|
+
const tab = this.#tabs[i];
|
|
125
|
+
if (i === this.#activeIndex) {
|
|
126
|
+
chunks.push(this.#theme.activeTab(` ${tab.label} `));
|
|
127
|
+
} else {
|
|
128
|
+
chunks.push(this.#theme.inactiveTab(` ${tab.label} `));
|
|
129
|
+
}
|
|
130
|
+
if (i < this.#tabs.length - 1) {
|
|
131
|
+
chunks.push(" ");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Navigation hint
|
|
136
|
+
chunks.push(" ");
|
|
137
|
+
chunks.push(this.#theme.hint("(tab to cycle)"));
|
|
138
|
+
|
|
139
|
+
const lines: string[] = [];
|
|
140
|
+
let currentLine = "";
|
|
141
|
+
let currentWidth = 0;
|
|
142
|
+
|
|
143
|
+
for (const chunk of chunks) {
|
|
144
|
+
const chunkWidth = visibleWidth(chunk);
|
|
145
|
+
if (chunkWidth <= 0) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (chunkWidth > maxWidth) {
|
|
150
|
+
if (currentLine) {
|
|
151
|
+
lines.push(currentLine);
|
|
152
|
+
currentLine = "";
|
|
153
|
+
currentWidth = 0;
|
|
154
|
+
}
|
|
155
|
+
lines.push(truncateToWidth(chunk, maxWidth));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (currentWidth > 0 && currentWidth + chunkWidth > maxWidth) {
|
|
160
|
+
lines.push(currentLine);
|
|
161
|
+
currentLine = "";
|
|
162
|
+
currentWidth = 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
currentLine += chunk;
|
|
166
|
+
currentWidth += chunkWidth;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (currentLine) {
|
|
170
|
+
lines.push(currentLine);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return lines.length > 0 ? lines : [""];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Component } from "../tui";
|
|
2
|
+
import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Text component - displays multi-line text with word wrapping
|
|
6
|
+
*/
|
|
7
|
+
export class Text implements Component {
|
|
8
|
+
#text: string;
|
|
9
|
+
#paddingX: number; // Left/right padding
|
|
10
|
+
#paddingY: number; // Top/bottom padding
|
|
11
|
+
#customBgFn?: (text: string) => string;
|
|
12
|
+
|
|
13
|
+
// Cache for rendered output
|
|
14
|
+
#cachedText?: string;
|
|
15
|
+
#cachedWidth?: number;
|
|
16
|
+
#cachedLines?: string[];
|
|
17
|
+
|
|
18
|
+
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
|
|
19
|
+
this.#text = text;
|
|
20
|
+
this.#paddingX = paddingX;
|
|
21
|
+
this.#paddingY = paddingY;
|
|
22
|
+
this.#customBgFn = customBgFn;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
getText(): string {
|
|
26
|
+
return this.#text;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setText(text: string): void {
|
|
30
|
+
this.#text = text;
|
|
31
|
+
this.#cachedText = undefined;
|
|
32
|
+
this.#cachedWidth = undefined;
|
|
33
|
+
this.#cachedLines = undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setCustomBgFn(customBgFn?: (text: string) => string): void {
|
|
37
|
+
this.#customBgFn = customBgFn;
|
|
38
|
+
this.#cachedText = undefined;
|
|
39
|
+
this.#cachedWidth = undefined;
|
|
40
|
+
this.#cachedLines = undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
invalidate(): void {
|
|
44
|
+
this.#cachedText = undefined;
|
|
45
|
+
this.#cachedWidth = undefined;
|
|
46
|
+
this.#cachedLines = undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
render(width: number): string[] {
|
|
50
|
+
// Check cache
|
|
51
|
+
if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
|
|
52
|
+
return this.#cachedLines;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Don't render anything if there's no actual text
|
|
56
|
+
if (!this.#text || this.#text.trim() === "") {
|
|
57
|
+
const result: string[] = [];
|
|
58
|
+
this.#cachedText = this.#text;
|
|
59
|
+
this.#cachedWidth = width;
|
|
60
|
+
this.#cachedLines = result;
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Replace tabs with 3 spaces
|
|
65
|
+
const normalizedText = replaceTabs(this.#text);
|
|
66
|
+
|
|
67
|
+
// Calculate content width (subtract left/right margins)
|
|
68
|
+
const contentWidth = Math.max(1, width - this.#paddingX * 2);
|
|
69
|
+
|
|
70
|
+
// Wrap text (this preserves ANSI codes but does NOT pad)
|
|
71
|
+
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
|
|
72
|
+
|
|
73
|
+
// Add margins and background to each line
|
|
74
|
+
const leftMargin = padding(this.#paddingX);
|
|
75
|
+
const rightMargin = padding(this.#paddingX);
|
|
76
|
+
const contentLines: string[] = [];
|
|
77
|
+
|
|
78
|
+
for (const line of wrappedLines) {
|
|
79
|
+
// Add margins
|
|
80
|
+
const lineWithMargins = leftMargin + line + rightMargin;
|
|
81
|
+
|
|
82
|
+
// Apply background if specified (this also pads to full width)
|
|
83
|
+
if (this.#customBgFn) {
|
|
84
|
+
contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.#customBgFn));
|
|
85
|
+
} else {
|
|
86
|
+
// No background - just pad to width with spaces
|
|
87
|
+
const visibleLen = visibleWidth(lineWithMargins);
|
|
88
|
+
const paddingNeeded = Math.max(0, width - visibleLen);
|
|
89
|
+
contentLines.push(lineWithMargins + padding(paddingNeeded));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Add top/bottom padding (empty lines)
|
|
94
|
+
const emptyLine = padding(width);
|
|
95
|
+
const emptyLines: string[] = [];
|
|
96
|
+
for (let i = 0; i < this.#paddingY; i++) {
|
|
97
|
+
const line = this.#customBgFn ? applyBackgroundToLine(emptyLine, width, this.#customBgFn) : emptyLine;
|
|
98
|
+
emptyLines.push(line);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = [...emptyLines, ...contentLines, ...emptyLines];
|
|
102
|
+
|
|
103
|
+
// Update cache
|
|
104
|
+
this.#cachedText = this.#text;
|
|
105
|
+
this.#cachedWidth = width;
|
|
106
|
+
this.#cachedLines = result;
|
|
107
|
+
|
|
108
|
+
return result.length > 0 ? result : [""];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
|
|
12
|
+
constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
|
|
13
|
+
this.#text = text;
|
|
14
|
+
this.#paddingX = paddingX;
|
|
15
|
+
this.#paddingY = paddingY;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
invalidate(): void {
|
|
19
|
+
// No cached state to invalidate currently
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
render(width: number): string[] {
|
|
23
|
+
const result: string[] = [];
|
|
24
|
+
|
|
25
|
+
// Empty line padded to width
|
|
26
|
+
const emptyLine = padding(width);
|
|
27
|
+
|
|
28
|
+
// Add vertical padding above
|
|
29
|
+
for (let i = 0; i < this.#paddingY; i++) {
|
|
30
|
+
result.push(emptyLine);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Calculate available width after horizontal padding
|
|
34
|
+
const availableWidth = Math.max(1, width - this.#paddingX * 2);
|
|
35
|
+
|
|
36
|
+
// Take only the first line (stop at newline)
|
|
37
|
+
let singleLineText = this.#text;
|
|
38
|
+
const newlineIndex = this.#text.indexOf("\n");
|
|
39
|
+
if (newlineIndex !== -1) {
|
|
40
|
+
singleLineText = this.#text.substring(0, newlineIndex);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Truncate text if needed (accounting for ANSI codes)
|
|
44
|
+
const displayText = truncateToWidth(singleLineText, availableWidth);
|
|
45
|
+
|
|
46
|
+
// Add horizontal padding
|
|
47
|
+
const leftPadding = padding(this.#paddingX);
|
|
48
|
+
const rightPadding = padding(this.#paddingX);
|
|
49
|
+
const lineWithPadding = leftPadding + displayText + rightPadding;
|
|
50
|
+
|
|
51
|
+
// Don't pad to full width - avoids trailing spaces when copying
|
|
52
|
+
result.push(lineWithPadding);
|
|
53
|
+
|
|
54
|
+
// Add vertical padding below
|
|
55
|
+
for (let i = 0; i < this.#paddingY; i++) {
|
|
56
|
+
result.push(emptyLine);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/deccara.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DECCARA rectangular-SGR background-fill optimizer.
|
|
3
|
+
*
|
|
4
|
+
* Kitty extends VT510 DECCARA ("Change Attributes in Rectangular Area") to all
|
|
5
|
+
* SGR attributes, including background color, so a solid background panel can be
|
|
6
|
+
* painted as a single rectangle escape instead of a full-width run of
|
|
7
|
+
* background-styled spaces on every row (see kitty `docs/deccara.rst`):
|
|
8
|
+
*
|
|
9
|
+
* <ESC>[2*x DECSACE: select rectangle change extent
|
|
10
|
+
* <ESC>[Pt;Pl;Pb;Pr;<sgr>$r DECCARA: apply <sgr> to rows Pt..Pb, cols Pl..Pr
|
|
11
|
+
* <ESC>[*x DECSACE: restore default extent
|
|
12
|
+
*
|
|
13
|
+
* Coordinates are 1-based and inclusive. This module is a pure, renderer-level
|
|
14
|
+
* planner: it consumes the *final* ANSI strings the renderer would otherwise
|
|
15
|
+
* write, strips the trailing background-padded spaces it can prove are safe to
|
|
16
|
+
* drop, and returns the rectangles to emit in their place. It never mutates
|
|
17
|
+
* component output and never decides which rows are scrollback-bound — those
|
|
18
|
+
* concerns belong to the caller in `tui.ts`.
|
|
19
|
+
*/
|
|
20
|
+
import { visibleWidth } from "./utils";
|
|
21
|
+
|
|
22
|
+
/** Reset every attribute (SGR 0). Mirrors `tui.ts`'s per-line terminator. */
|
|
23
|
+
const SEGMENT_RESET = "\x1b[0m";
|
|
24
|
+
|
|
25
|
+
/** DECSACE — select the rectangle change extent so DECCARA fills a rectangle. */
|
|
26
|
+
export const DECSACE_RECT = "\x1b[2*x";
|
|
27
|
+
/** DECSACE — restore the default (stream) change extent. */
|
|
28
|
+
export const DECSACE_DEFAULT = "\x1b[*x";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Byte cost of the per-frame DECSACE wrapper ({@link DECSACE_RECT} +
|
|
32
|
+
* {@link DECSACE_DEFAULT}) that brackets every rectangle batch. Charged once per
|
|
33
|
+
* frame: a plan is emitted only when the trailing-space bytes it removes exceed
|
|
34
|
+
* the rectangles' own bytes by more than this, so the optimizer never inflates.
|
|
35
|
+
*/
|
|
36
|
+
const DECSACE_WRAPPER_BYTES = DECSACE_RECT.length + DECSACE_DEFAULT.length;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Encode a single DECCARA rectangle. `top`/`bottom` are 1-based inclusive screen
|
|
40
|
+
* rows, `left`/`right` 1-based inclusive columns, `sgr` the raw SGR parameter
|
|
41
|
+
* list to apply (e.g. `48;2;10;20;30`, `48;5;4`, `41`).
|
|
42
|
+
*/
|
|
43
|
+
export function encodeDeccara(top: number, left: number, bottom: number, right: number, sgr: string): string {
|
|
44
|
+
return `\x1b[${top};${left};${bottom};${right};${sgr}$r`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Sentinel for a background form this optimizer refuses to reason about. */
|
|
48
|
+
const BAIL = Symbol("deccara-bail");
|
|
49
|
+
type BgState = string | null;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fold one SGR parameter list into the active background-color parameter string.
|
|
53
|
+
* Returns the new background (`null` = default/no background) or {@link BAIL}
|
|
54
|
+
* when the sequence contains a background form this optimizer will not reason
|
|
55
|
+
* about (colon-form extended color, malformed params). Foreground and style
|
|
56
|
+
* parameters are skipped; only background state is tracked.
|
|
57
|
+
*/
|
|
58
|
+
function nextBackground(bg: BgState, params: string): BgState | typeof BAIL {
|
|
59
|
+
// CSI m with no parameters is SGR 0 (reset everything).
|
|
60
|
+
if (params.length === 0) return null;
|
|
61
|
+
const tokens = params.split(";");
|
|
62
|
+
let result: BgState = bg;
|
|
63
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
64
|
+
const token = tokens[i];
|
|
65
|
+
// An empty parameter defaults to 0 (reset), matching terminal behavior.
|
|
66
|
+
const n = token.length === 0 ? 0 : Number(token);
|
|
67
|
+
if (!Number.isInteger(n)) return BAIL;
|
|
68
|
+
if (n === 0 || n === 49) {
|
|
69
|
+
result = null;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if ((n >= 40 && n <= 47) || (n >= 100 && n <= 107)) {
|
|
73
|
+
result = token;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (n === 48) {
|
|
77
|
+
const mode = tokens[i + 1];
|
|
78
|
+
if (mode === "5") {
|
|
79
|
+
const idx = tokens[i + 2];
|
|
80
|
+
if (idx === undefined) return BAIL;
|
|
81
|
+
result = `48;5;${idx}`;
|
|
82
|
+
i += 2;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (mode === "2") {
|
|
86
|
+
const r = tokens[i + 2];
|
|
87
|
+
const g = tokens[i + 3];
|
|
88
|
+
const b = tokens[i + 4];
|
|
89
|
+
if (r === undefined || g === undefined || b === undefined) return BAIL;
|
|
90
|
+
result = `48;2;${r};${g};${b}`;
|
|
91
|
+
i += 4;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Colon-form (`48:2:...`) collapses to a single non-integer token and is
|
|
95
|
+
// rejected above; anything else following 48 is unexpected — bail.
|
|
96
|
+
return BAIL;
|
|
97
|
+
}
|
|
98
|
+
if (n === 38) {
|
|
99
|
+
// Foreground extended color: skip its sub-parameters, leave bg alone.
|
|
100
|
+
const mode = tokens[i + 1];
|
|
101
|
+
if (mode === "5") {
|
|
102
|
+
i += 2;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (mode === "2") {
|
|
106
|
+
i += 4;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
return BAIL;
|
|
110
|
+
}
|
|
111
|
+
// Every other parameter (foreground 30-39/90-97, styles) leaves bg alone.
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Where to cut a fillable line and the background to paint over the remainder. */
|
|
117
|
+
export interface BgFillAnalysis {
|
|
118
|
+
/** Byte index where droppable trailing background padding begins (0 = whole line). */
|
|
119
|
+
cut: number;
|
|
120
|
+
/** 0-based column where the trailing padding begins (DECCARA left = leftCol + 1). */
|
|
121
|
+
leftCol: number;
|
|
122
|
+
/** SGR parameter list of the background covering the trailing region. */
|
|
123
|
+
bg: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Decide whether `line` (a final, width-fit, reset-terminated ANSI string) is a
|
|
128
|
+
* full-width background fill whose trailing padding can be replaced by a DECCARA
|
|
129
|
+
* rectangle. Returns `null` unless it can *prove* the dropped bytes are literal
|
|
130
|
+
* trailing spaces under a single, constant, non-default background span (or the
|
|
131
|
+
* entire row is background-styled spaces).
|
|
132
|
+
*
|
|
133
|
+
* Conservative by construction: any OSC sequence (hyperlinks/images), any
|
|
134
|
+
* non-SGR CSI, a partial row, an inconsistent or default trailing background, or
|
|
135
|
+
* a malformed escape all yield `null` so the caller keeps the exact original.
|
|
136
|
+
*/
|
|
137
|
+
export function analyzeBgFillLine(line: string, width: number): BgFillAnalysis | null {
|
|
138
|
+
if (width <= 0 || line.length === 0) return null;
|
|
139
|
+
let i = 0;
|
|
140
|
+
let col = 0;
|
|
141
|
+
let bg: BgState = null;
|
|
142
|
+
// Byte index / column immediately after the last non-space printable glyph.
|
|
143
|
+
let nonSpaceEndByte = 0;
|
|
144
|
+
let nonSpaceEndCol = 0;
|
|
145
|
+
// Background covering the current trailing run of spaces, and whether that
|
|
146
|
+
// trailing run has started. `null` is a real "default background" value, so
|
|
147
|
+
// it cannot double as the uninitialized sentinel.
|
|
148
|
+
let trailBg: BgState = null;
|
|
149
|
+
let trailStarted = false;
|
|
150
|
+
let trailConsistent = true;
|
|
151
|
+
|
|
152
|
+
while (i < line.length) {
|
|
153
|
+
if (line.charCodeAt(i) === 0x1b) {
|
|
154
|
+
// Only CSI SGR (`\x1b[ ... m`) is tolerated. OSC, APC, and any other
|
|
155
|
+
// CSI mean styled hyperlinks/images/cursor markers — refuse to touch.
|
|
156
|
+
if (line.charCodeAt(i + 1) !== 0x5b) return null;
|
|
157
|
+
let j = i + 2;
|
|
158
|
+
while (j < line.length) {
|
|
159
|
+
const c = line.charCodeAt(j);
|
|
160
|
+
if (c >= 0x40 && c <= 0x7e) break;
|
|
161
|
+
j++;
|
|
162
|
+
}
|
|
163
|
+
if (j >= line.length) return null; // unterminated CSI
|
|
164
|
+
if (line.charCodeAt(j) !== 0x6d) return null; // non-SGR CSI (final byte != 'm')
|
|
165
|
+
const next = nextBackground(bg, line.slice(i + 2, j));
|
|
166
|
+
if (next === BAIL) return null;
|
|
167
|
+
bg = next;
|
|
168
|
+
i = j + 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Printable run up to the next escape.
|
|
173
|
+
let j = i;
|
|
174
|
+
while (j < line.length && line.charCodeAt(j) !== 0x1b) j++;
|
|
175
|
+
const text = line.slice(i, j);
|
|
176
|
+
let nonSpaceLen = text.length;
|
|
177
|
+
while (nonSpaceLen > 0 && text.charCodeAt(nonSpaceLen - 1) === 0x20) nonSpaceLen--;
|
|
178
|
+
|
|
179
|
+
if (nonSpaceLen > 0) {
|
|
180
|
+
// Run carries a non-space glyph: the trailing region restarts after it.
|
|
181
|
+
const nonSpaceWidth = visibleWidth(text.slice(0, nonSpaceLen));
|
|
182
|
+
nonSpaceEndByte = i + nonSpaceLen;
|
|
183
|
+
nonSpaceEndCol = col + nonSpaceWidth;
|
|
184
|
+
// Spaces after the last non-space glyph in this same printable run sit
|
|
185
|
+
// under the current bg. If there are none, the trailing region has not
|
|
186
|
+
// started yet; a later SGR can still begin a uniform fill safely.
|
|
187
|
+
if (nonSpaceLen < text.length) {
|
|
188
|
+
trailBg = bg;
|
|
189
|
+
trailStarted = true;
|
|
190
|
+
} else {
|
|
191
|
+
trailBg = null;
|
|
192
|
+
trailStarted = false;
|
|
193
|
+
}
|
|
194
|
+
trailConsistent = true;
|
|
195
|
+
} else if (text.length > 0) {
|
|
196
|
+
// Whole run is spaces: it extends the trailing region. Track bg drift.
|
|
197
|
+
if (!trailStarted) {
|
|
198
|
+
trailBg = bg;
|
|
199
|
+
trailStarted = true;
|
|
200
|
+
} else if (bg !== trailBg) {
|
|
201
|
+
trailConsistent = false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
col += visibleWidth(text);
|
|
205
|
+
i = j;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (col !== width) return null; // not a full-width fill
|
|
209
|
+
if (nonSpaceEndCol >= width) return null; // no trailing padding to drop
|
|
210
|
+
if (!trailStarted || trailBg === null || !trailConsistent) return null; // default/mixed bg — nothing safe to paint
|
|
211
|
+
return { cut: nonSpaceEndByte, leftCol: nonSpaceEndCol, bg: trailBg };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface FillCandidate {
|
|
215
|
+
left: number;
|
|
216
|
+
right: number;
|
|
217
|
+
bg: string;
|
|
218
|
+
short: string;
|
|
219
|
+
origLen: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Per-frame plan: the (possibly shortened) row strings and the DECCARA batch. */
|
|
223
|
+
export interface DeccaraPlan {
|
|
224
|
+
/** Row strings to write, parallel to the input. Optimized rows are shortened. */
|
|
225
|
+
texts: string[];
|
|
226
|
+
/** DECSACE-wrapped rectangle batch to emit after the rows, or `""` if none. */
|
|
227
|
+
sequence: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Plan DECCARA rectangles for a contiguous block of visible rows.
|
|
232
|
+
*
|
|
233
|
+
* `lines[k]` is the final ANSI string for screen row `firstScreenRow + k`
|
|
234
|
+
* (0-based). For each fillable row the trailing background padding is removed
|
|
235
|
+
* (the row's cells are cleared/erased by the caller, then repainted by the
|
|
236
|
+
* rectangle), and vertically adjacent rows with an identical left/right/bg span
|
|
237
|
+
* coalesce into one rectangle. Rectangles are emitted only when they save more
|
|
238
|
+
* bytes than they cost, so the result never exceeds the original byte count.
|
|
239
|
+
*/
|
|
240
|
+
export function planDeccaraFills(lines: string[], width: number, firstScreenRow = 0): DeccaraPlan {
|
|
241
|
+
const n = lines.length;
|
|
242
|
+
const texts: string[] = new Array(n);
|
|
243
|
+
const candidates: (FillCandidate | null)[] = new Array(n);
|
|
244
|
+
|
|
245
|
+
for (let k = 0; k < n; k++) {
|
|
246
|
+
const line = lines[k];
|
|
247
|
+
texts[k] = line;
|
|
248
|
+
const analysis = analyzeBgFillLine(line, width);
|
|
249
|
+
if (!analysis) {
|
|
250
|
+
candidates[k] = null;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
// Cut at the last non-space glyph and re-close attributes. An all-space row
|
|
254
|
+
// (cut 0) needs no styled text at all — the caller's erase plus the
|
|
255
|
+
// rectangle paint it. A content row keeps its prefix and a fresh reset so
|
|
256
|
+
// the inline background never bleeds past the row.
|
|
257
|
+
const short = analysis.cut === 0 ? "" : line.slice(0, analysis.cut) + SEGMENT_RESET;
|
|
258
|
+
candidates[k] = { left: analysis.leftCol + 1, right: width, bg: analysis.bg, short, origLen: line.length };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Collect coalesced groups whose rectangle at least pays for its own bytes.
|
|
262
|
+
// The DECSACE wrapper is a single per-frame cost, so it is charged once below
|
|
263
|
+
// rather than amortized into each group (which would over-reject lone rows).
|
|
264
|
+
interface Group {
|
|
265
|
+
start: number;
|
|
266
|
+
end: number;
|
|
267
|
+
rect: string;
|
|
268
|
+
}
|
|
269
|
+
const groups: Group[] = [];
|
|
270
|
+
let removedTotal = 0;
|
|
271
|
+
let rectBytesTotal = 0;
|
|
272
|
+
let k = 0;
|
|
273
|
+
while (k < n) {
|
|
274
|
+
const head = candidates[k];
|
|
275
|
+
if (!head) {
|
|
276
|
+
k++;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// Extend the group over adjacent rows sharing the same fill span.
|
|
280
|
+
let end = k;
|
|
281
|
+
while (end + 1 < n) {
|
|
282
|
+
const next = candidates[end + 1];
|
|
283
|
+
if (!next || next.left !== head.left || next.right !== head.right || next.bg !== head.bg) break;
|
|
284
|
+
end++;
|
|
285
|
+
}
|
|
286
|
+
const rect = encodeDeccara(firstScreenRow + k + 1, head.left, firstScreenRow + end + 1, head.right, head.bg);
|
|
287
|
+
let removed = 0;
|
|
288
|
+
for (let r = k; r <= end; r++) {
|
|
289
|
+
const c = candidates[r];
|
|
290
|
+
if (c) removed += c.origLen - c.short.length;
|
|
291
|
+
}
|
|
292
|
+
if (removed > rect.length) {
|
|
293
|
+
groups.push({ start: k, end, rect });
|
|
294
|
+
removedTotal += removed;
|
|
295
|
+
rectBytesTotal += rect.length;
|
|
296
|
+
}
|
|
297
|
+
k = end + 1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Emit nothing unless the batch beats the original by more than the wrapper.
|
|
301
|
+
if (groups.length === 0 || removedTotal - rectBytesTotal <= DECSACE_WRAPPER_BYTES) {
|
|
302
|
+
return { texts, sequence: "" };
|
|
303
|
+
}
|
|
304
|
+
let sequence = DECSACE_RECT;
|
|
305
|
+
for (const group of groups) {
|
|
306
|
+
for (let r = group.start; r <= group.end; r++) {
|
|
307
|
+
const c = candidates[r];
|
|
308
|
+
if (c) texts[r] = c.short;
|
|
309
|
+
}
|
|
310
|
+
sequence += group.rect;
|
|
311
|
+
}
|
|
312
|
+
sequence += DECSACE_DEFAULT;
|
|
313
|
+
return { texts, sequence };
|
|
314
|
+
}
|