@f5-sales-demo/pi-tui 19.51.2
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 +702 -0
- package/README.md +704 -0
- package/package.json +71 -0
- package/src/autocomplete.ts +780 -0
- package/src/bracketed-paste.ts +47 -0
- package/src/chord-dispatcher.ts +90 -0
- package/src/chord-parser.ts +66 -0
- package/src/components/box.ts +155 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +2610 -0
- package/src/components/image.ts +90 -0
- package/src/components/input.ts +439 -0
- package/src/components/loader.ts +67 -0
- package/src/components/markdown.ts +940 -0
- package/src/components/select-list.ts +249 -0
- package/src/components/settings-list.ts +195 -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/editor-component.ts +71 -0
- package/src/events.ts +32 -0
- package/src/fuzzy.ts +143 -0
- package/src/horizontal-split.ts +186 -0
- package/src/index.ts +64 -0
- package/src/keybindings.ts +340 -0
- package/src/keys.ts +408 -0
- package/src/kill-ring.ts +46 -0
- package/src/stdin-buffer.ts +481 -0
- package/src/symbols.ts +24 -0
- package/src/terminal-capabilities.ts +533 -0
- package/src/terminal.ts +687 -0
- package/src/ttyid.ts +66 -0
- package/src/tui.ts +1341 -0
- package/src/utils.ts +345 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { AutocompleteProvider } from "./autocomplete";
|
|
2
|
+
import type { Component } from "./tui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Interface for custom editor components.
|
|
6
|
+
*
|
|
7
|
+
* This allows extensions to provide their own editor implementation
|
|
8
|
+
* (e.g., vim mode, emacs mode, custom keybindings) while maintaining
|
|
9
|
+
* compatibility with the core application.
|
|
10
|
+
*/
|
|
11
|
+
export interface EditorComponent extends Component {
|
|
12
|
+
// =========================================================================
|
|
13
|
+
// Core text access (required)
|
|
14
|
+
// =========================================================================
|
|
15
|
+
|
|
16
|
+
/** Get the current text content */
|
|
17
|
+
getText(): string;
|
|
18
|
+
|
|
19
|
+
/** Set the text content */
|
|
20
|
+
setText(text: string): void;
|
|
21
|
+
|
|
22
|
+
/** Handle raw terminal input (key presses, paste sequences, etc.) */
|
|
23
|
+
handleInput(data: string): void;
|
|
24
|
+
|
|
25
|
+
// =========================================================================
|
|
26
|
+
// Callbacks (required)
|
|
27
|
+
// =========================================================================
|
|
28
|
+
|
|
29
|
+
/** Called when user submits (e.g., Enter key) */
|
|
30
|
+
onSubmit?: (text: string) => void;
|
|
31
|
+
|
|
32
|
+
/** Called when text changes */
|
|
33
|
+
onChange?: (text: string) => void;
|
|
34
|
+
|
|
35
|
+
// =========================================================================
|
|
36
|
+
// History support (optional)
|
|
37
|
+
// =========================================================================
|
|
38
|
+
|
|
39
|
+
/** Add text to history for up/down navigation */
|
|
40
|
+
addToHistory?(text: string): void;
|
|
41
|
+
|
|
42
|
+
// =========================================================================
|
|
43
|
+
// Advanced text manipulation (optional)
|
|
44
|
+
// =========================================================================
|
|
45
|
+
|
|
46
|
+
/** Insert text at current cursor position */
|
|
47
|
+
insertTextAtCursor?(text: string): void;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get text with any markers expanded (e.g., paste markers).
|
|
51
|
+
* Falls back to getText() if not implemented.
|
|
52
|
+
*/
|
|
53
|
+
getExpandedText?(): string;
|
|
54
|
+
|
|
55
|
+
// =========================================================================
|
|
56
|
+
// Autocomplete support (optional)
|
|
57
|
+
// =========================================================================
|
|
58
|
+
|
|
59
|
+
/** Set the autocomplete provider */
|
|
60
|
+
setAutocompleteProvider?(provider: AutocompleteProvider): void;
|
|
61
|
+
|
|
62
|
+
// =========================================================================
|
|
63
|
+
// Appearance (optional)
|
|
64
|
+
// =========================================================================
|
|
65
|
+
|
|
66
|
+
/** Border color function */
|
|
67
|
+
borderColor?: (str: string) => string;
|
|
68
|
+
|
|
69
|
+
/** Set horizontal padding */
|
|
70
|
+
setPaddingX?(padding: number): void;
|
|
71
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed publish-subscribe emitter.
|
|
3
|
+
*
|
|
4
|
+
* - Keys of `T` are event names; values are payload shapes.
|
|
5
|
+
* - `on()` returns an unsubscribe closure — the ONLY unsubscribe path.
|
|
6
|
+
* No public `off()` method; this avoids divergence between two
|
|
7
|
+
* unsubscribe mechanisms and keeps bookkeeping on the emitter.
|
|
8
|
+
* - `emit()` with zero subscribers is a documented no-op (it does not
|
|
9
|
+
* throw and invokes no callback).
|
|
10
|
+
*/
|
|
11
|
+
export class TypedEventEmitter<T extends Record<string, unknown>> {
|
|
12
|
+
readonly #handlers = new Map<keyof T, Set<(payload: unknown) => void>>();
|
|
13
|
+
|
|
14
|
+
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): () => void {
|
|
15
|
+
let set = this.#handlers.get(event);
|
|
16
|
+
if (!set) {
|
|
17
|
+
set = new Set();
|
|
18
|
+
this.#handlers.set(event, set);
|
|
19
|
+
}
|
|
20
|
+
const wrapped = (p: unknown) => handler(p as T[K]);
|
|
21
|
+
set.add(wrapped);
|
|
22
|
+
return () => {
|
|
23
|
+
set?.delete(wrapped);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
emit<K extends keyof T>(event: K, payload: T[K]): void {
|
|
28
|
+
const set = this.#handlers.get(event);
|
|
29
|
+
if (!set) return;
|
|
30
|
+
for (const handler of set) handler(payload);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/fuzzy.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzzy matching utilities.
|
|
3
|
+
* Matches if all query characters appear in order (not necessarily consecutive).
|
|
4
|
+
* Lower score = better match.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface FuzzyMatch {
|
|
8
|
+
matches: boolean;
|
|
9
|
+
score: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ALPHANUMERIC_SWAP_PENALTY = 5;
|
|
13
|
+
|
|
14
|
+
function scoreMatch(queryLower: string, textLower: string): FuzzyMatch {
|
|
15
|
+
if (queryLower.length === 0) {
|
|
16
|
+
return { matches: true, score: 0 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (queryLower.length > textLower.length) {
|
|
20
|
+
return { matches: false, score: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let queryIndex = 0;
|
|
24
|
+
let score = 0;
|
|
25
|
+
let lastMatchIndex = -1;
|
|
26
|
+
let consecutiveMatches = 0;
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
|
29
|
+
if (textLower[i] === queryLower[queryIndex]) {
|
|
30
|
+
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
|
|
31
|
+
|
|
32
|
+
// Reward consecutive matches
|
|
33
|
+
if (lastMatchIndex === i - 1) {
|
|
34
|
+
consecutiveMatches++;
|
|
35
|
+
score -= consecutiveMatches * 5;
|
|
36
|
+
} else {
|
|
37
|
+
consecutiveMatches = 0;
|
|
38
|
+
// Penalize gaps
|
|
39
|
+
if (lastMatchIndex >= 0) {
|
|
40
|
+
score += (i - lastMatchIndex - 1) * 2;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Reward word boundary matches
|
|
45
|
+
if (isWordBoundary) {
|
|
46
|
+
score -= 10;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Slight penalty for later matches
|
|
50
|
+
score += i * 0.1;
|
|
51
|
+
|
|
52
|
+
lastMatchIndex = i;
|
|
53
|
+
queryIndex++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (queryIndex < queryLower.length) {
|
|
58
|
+
return { matches: false, score: 0 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { matches: true, score };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildAlphanumericSwapQueries(queryLower: string): string[] {
|
|
65
|
+
const variants = new Set<string>();
|
|
66
|
+
for (let i = 0; i < queryLower.length - 1; i++) {
|
|
67
|
+
const current = queryLower[i];
|
|
68
|
+
const next = queryLower[i + 1];
|
|
69
|
+
const isAlphaNumSwap =
|
|
70
|
+
(current && /[a-z]/.test(current) && next && /\d/.test(next)) ||
|
|
71
|
+
(current && /\d/.test(current) && next && /[a-z]/.test(next));
|
|
72
|
+
if (!isAlphaNumSwap) continue;
|
|
73
|
+
const swapped = queryLower.slice(0, i) + next + current + queryLower.slice(i + 2);
|
|
74
|
+
variants.add(swapped);
|
|
75
|
+
}
|
|
76
|
+
return [...variants];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
|
80
|
+
const queryLower = query.toLowerCase();
|
|
81
|
+
const textLower = text.toLowerCase();
|
|
82
|
+
|
|
83
|
+
const direct = scoreMatch(queryLower, textLower);
|
|
84
|
+
if (direct.matches) {
|
|
85
|
+
return direct;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let bestSwap: FuzzyMatch | null = null;
|
|
89
|
+
for (const variant of buildAlphanumericSwapQueries(queryLower)) {
|
|
90
|
+
const match = scoreMatch(variant, textLower);
|
|
91
|
+
if (!match.matches) continue;
|
|
92
|
+
const score = match.score + ALPHANUMERIC_SWAP_PENALTY;
|
|
93
|
+
if (!bestSwap || score < bestSwap.score) {
|
|
94
|
+
bestSwap = { matches: true, score };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return bestSwap ?? direct;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Filter and sort items by fuzzy match quality (best matches first).
|
|
103
|
+
* Supports space-separated tokens: all tokens must match.
|
|
104
|
+
*/
|
|
105
|
+
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
|
|
106
|
+
if (!query.trim()) {
|
|
107
|
+
return items;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const tokens = query
|
|
111
|
+
.trim()
|
|
112
|
+
.split(/\s+/)
|
|
113
|
+
.filter(t => t.length > 0);
|
|
114
|
+
|
|
115
|
+
if (tokens.length === 0) {
|
|
116
|
+
return items;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const results: { item: T; totalScore: number }[] = [];
|
|
120
|
+
|
|
121
|
+
for (const item of items) {
|
|
122
|
+
const text = getText(item);
|
|
123
|
+
let totalScore = 0;
|
|
124
|
+
let allMatch = true;
|
|
125
|
+
|
|
126
|
+
for (const token of tokens) {
|
|
127
|
+
const match = fuzzyMatch(token, text);
|
|
128
|
+
if (match.matches) {
|
|
129
|
+
totalScore += match.score;
|
|
130
|
+
} else {
|
|
131
|
+
allMatch = false;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (allMatch) {
|
|
137
|
+
results.push({ item, totalScore });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
results.sort((a, b) => a.totalScore - b.totalScore);
|
|
142
|
+
return results.map(r => r.item);
|
|
143
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { Component } from "./tui";
|
|
2
|
+
import { padding, sliceByColumn, visibleWidth } from "./utils";
|
|
3
|
+
|
|
4
|
+
const RESET_SGR = "\x1b[0m";
|
|
5
|
+
|
|
6
|
+
/** Width configuration for a single HorizontalSplit child. */
|
|
7
|
+
export type SplitChildWidth = { kind: "fixed"; value: number } | { kind: "flex"; value: number; minWidth?: number };
|
|
8
|
+
|
|
9
|
+
/** One child placed in a HorizontalSplit, with width + priority metadata. */
|
|
10
|
+
export interface SplitChild {
|
|
11
|
+
component: Component;
|
|
12
|
+
width: SplitChildWidth;
|
|
13
|
+
/** Lower priority collapses first when the terminal is too narrow to satisfy all flex minimums. */
|
|
14
|
+
priority: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Side-by-side layout primitive.
|
|
19
|
+
*
|
|
20
|
+
* Implements `Component` directly (not a Container subclass) because each
|
|
21
|
+
* child carries width/priority metadata that doesn't fit the plain
|
|
22
|
+
* `Component[]` model of `Container`. API surface matches spec §6.1:
|
|
23
|
+
* `render(width)` returns the composed rows.
|
|
24
|
+
*
|
|
25
|
+
* Width allocation (spec §6.1):
|
|
26
|
+
* 1. Subtract separator cost (N-1 columns for N children).
|
|
27
|
+
* 2. Allocate fixed-width children first.
|
|
28
|
+
* 3. Distribute remaining columns across flex children proportionally
|
|
29
|
+
* by `flex.value`.
|
|
30
|
+
* 4. If any flex child falls below its `minWidth`: drop the lowest-priority
|
|
31
|
+
* child (binary collapse — not fractional shrink) and retry.
|
|
32
|
+
* 5. If all minimums still unsatisfiable: expand the highest-priority flex
|
|
33
|
+
* child to consume everything; no separators rendered.
|
|
34
|
+
*
|
|
35
|
+
* Line composition (spec §6.1):
|
|
36
|
+
* - row count = max(child.render(w).length)
|
|
37
|
+
* - each row: per-child slice-or-pad to its allocated width, joined by
|
|
38
|
+
* the separator character, terminated with `\x1b[0m`.
|
|
39
|
+
*/
|
|
40
|
+
export class HorizontalSplit implements Component {
|
|
41
|
+
readonly #children: SplitChild[];
|
|
42
|
+
readonly #separator: string;
|
|
43
|
+
|
|
44
|
+
constructor(children: SplitChild[], separator: string = " ") {
|
|
45
|
+
this.#children = children;
|
|
46
|
+
this.#separator = separator;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
invalidate(): void {
|
|
50
|
+
for (const c of this.#children) c.component.invalidate?.();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
render(totalWidth: number): string[] {
|
|
54
|
+
const widths = this.#allocate(Math.max(1, totalWidth));
|
|
55
|
+
return this.#compose(widths);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Allocate per-child widths. Fixed children get their configured value;
|
|
60
|
+
* flex children split the remainder proportionally. Collapsed children
|
|
61
|
+
* receive 0. Separator columns (widths.separatorCount) are accounted for.
|
|
62
|
+
*
|
|
63
|
+
* Task 5 adds binary collapse: when a flex child's minWidth is unmet, the
|
|
64
|
+
* lowest-priority active child is dropped to zero and allocation is retried.
|
|
65
|
+
* Task 6 adds the final fallback when nothing can be dropped.
|
|
66
|
+
*/
|
|
67
|
+
#allocate(totalWidth: number): number[] {
|
|
68
|
+
return this.#allocateWithActive(
|
|
69
|
+
totalWidth,
|
|
70
|
+
this.#children.map((_, i) => i),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#allocateWithActive(totalWidth: number, activeIndices: number[]): number[] {
|
|
75
|
+
const n = this.#children.length;
|
|
76
|
+
const widths = new Array<number>(n).fill(0);
|
|
77
|
+
if (activeIndices.length === 0) return widths;
|
|
78
|
+
|
|
79
|
+
const separatorCost = Math.max(0, activeIndices.length - 1);
|
|
80
|
+
let remaining = totalWidth - separatorCost;
|
|
81
|
+
|
|
82
|
+
// Fixed pass for active children only
|
|
83
|
+
for (const i of activeIndices) {
|
|
84
|
+
const child = this.#children[i]!;
|
|
85
|
+
if (child.width.kind === "fixed") {
|
|
86
|
+
widths[i] = Math.min(child.width.value, Math.max(0, remaining));
|
|
87
|
+
remaining -= widths[i]!;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Flex pass
|
|
92
|
+
const flexIndices = activeIndices.filter(i => this.#children[i]!.width.kind === "flex");
|
|
93
|
+
let totalFlex = 0;
|
|
94
|
+
for (const i of flexIndices) {
|
|
95
|
+
totalFlex += (this.#children[i]!.width as { value: number }).value;
|
|
96
|
+
}
|
|
97
|
+
if (flexIndices.length > 0 && totalFlex > 0) {
|
|
98
|
+
let distributed = 0;
|
|
99
|
+
for (let k = 0; k < flexIndices.length - 1; k++) {
|
|
100
|
+
const i = flexIndices[k]!;
|
|
101
|
+
const flex = (this.#children[i]!.width as { value: number }).value;
|
|
102
|
+
widths[i] = Math.floor((remaining * flex) / totalFlex);
|
|
103
|
+
distributed += widths[i]!;
|
|
104
|
+
}
|
|
105
|
+
const last = flexIndices[flexIndices.length - 1]!;
|
|
106
|
+
widths[last] = Math.max(0, remaining - distributed);
|
|
107
|
+
} else if (flexIndices.length > 0) {
|
|
108
|
+
// All flex values sum to 0 — distribute evenly or give to first.
|
|
109
|
+
// Spec doesn't hit this normally; give all remaining to first flex child.
|
|
110
|
+
widths[flexIndices[0]!] = Math.max(0, remaining);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// minWidth validation — if violations, drop lowest-priority violator and retry.
|
|
114
|
+
const violators: number[] = [];
|
|
115
|
+
for (const i of flexIndices) {
|
|
116
|
+
const spec = this.#children[i]!.width as { kind: "flex"; value: number; minWidth?: number };
|
|
117
|
+
if (spec.minWidth !== undefined && widths[i]! < spec.minWidth) {
|
|
118
|
+
violators.push(i);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (violators.length === 0) return widths;
|
|
122
|
+
|
|
123
|
+
// Find lowest-priority active child.
|
|
124
|
+
const activeByPriority = [...activeIndices].sort(
|
|
125
|
+
(a, b) => this.#children[a]!.priority - this.#children[b]!.priority,
|
|
126
|
+
);
|
|
127
|
+
const drop = activeByPriority[0]!;
|
|
128
|
+
const nextActive = activeIndices.filter(i => i !== drop);
|
|
129
|
+
if (nextActive.length === 0) {
|
|
130
|
+
// Terminal fallback: expand the highest-priority child of the ORIGINAL
|
|
131
|
+
// child set to consume everything. No separators (only one active child).
|
|
132
|
+
const byPriority = [...this.#children.keys()].sort(
|
|
133
|
+
(a, b) => this.#children[b]!.priority - this.#children[a]!.priority,
|
|
134
|
+
);
|
|
135
|
+
const winner = byPriority[0]!;
|
|
136
|
+
const fallback = new Array<number>(n).fill(0);
|
|
137
|
+
fallback[winner] = Math.max(1, totalWidth);
|
|
138
|
+
return fallback;
|
|
139
|
+
}
|
|
140
|
+
return this.#allocateWithActive(totalWidth, nextActive);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#compose(widths: number[]): string[] {
|
|
144
|
+
const visibleIdx = widths.map((w, i) => (w > 0 ? i : -1)).filter(i => i >= 0);
|
|
145
|
+
if (visibleIdx.length === 0) return [];
|
|
146
|
+
|
|
147
|
+
const perChildLines = visibleIdx.map(i => this.#children[i]!.component.render(widths[i]!));
|
|
148
|
+
const rowCount = perChildLines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
149
|
+
|
|
150
|
+
const rows: string[] = [];
|
|
151
|
+
for (let r = 0; r < rowCount; r++) {
|
|
152
|
+
const parts: string[] = [];
|
|
153
|
+
for (let v = 0; v < visibleIdx.length; v++) {
|
|
154
|
+
const childWidth = widths[visibleIdx[v]!]!;
|
|
155
|
+
const raw = perChildLines[v]![r] ?? "";
|
|
156
|
+
parts.push(padOrSliceToWidth(raw, childWidth));
|
|
157
|
+
}
|
|
158
|
+
// Insert RESET_SGR between cells so unclosed SGR in one cell does
|
|
159
|
+
// not bleed through the separator into the next cell. Row end still
|
|
160
|
+
// gets RESET_SGR.
|
|
161
|
+
rows.push(parts.join(RESET_SGR + this.#separator) + RESET_SGR);
|
|
162
|
+
}
|
|
163
|
+
return rows;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Pad `line` to exactly `width` visible columns, slicing if longer.
|
|
169
|
+
* Uses the existing ANSI-aware helpers from `utils.ts` so SGR state is
|
|
170
|
+
* preserved (and any open SGR is closed) at the slice boundary.
|
|
171
|
+
*/
|
|
172
|
+
function padOrSliceToWidth(line: string, width: number): string {
|
|
173
|
+
if (width <= 0) return "";
|
|
174
|
+
const visible = visibleWidth(line);
|
|
175
|
+
if (visible === width) return line;
|
|
176
|
+
if (visible > width) {
|
|
177
|
+
// strict=true: exclude any wide char that would extend past the right edge.
|
|
178
|
+
// The resulting slice has visibleWidth <= width; pad the remainder with
|
|
179
|
+
// unstyled spaces so the next child still starts at its own col 0.
|
|
180
|
+
const sliced = sliceByColumn(line, 0, width, true);
|
|
181
|
+
const slicedWidth = visibleWidth(sliced);
|
|
182
|
+
if (slicedWidth < width) return sliced + padding(width - slicedWidth);
|
|
183
|
+
return sliced;
|
|
184
|
+
}
|
|
185
|
+
return line + padding(width - visible);
|
|
186
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Core TUI interfaces and classes
|
|
2
|
+
|
|
3
|
+
// Autocomplete support
|
|
4
|
+
export * from "./autocomplete";
|
|
5
|
+
// Chord dispatcher
|
|
6
|
+
export {
|
|
7
|
+
ChordDispatcher,
|
|
8
|
+
type ChordDispatcherCallbacks,
|
|
9
|
+
type ChordResult,
|
|
10
|
+
} from "./chord-dispatcher";
|
|
11
|
+
// Chord parser
|
|
12
|
+
export {
|
|
13
|
+
type BindingParseError,
|
|
14
|
+
type BindingsInput,
|
|
15
|
+
type ChordBinding,
|
|
16
|
+
type ParseBindingsResult,
|
|
17
|
+
type ParsedBinding,
|
|
18
|
+
type ParseResult,
|
|
19
|
+
parseBinding,
|
|
20
|
+
parseBindings,
|
|
21
|
+
} from "./chord-parser";
|
|
22
|
+
// Components
|
|
23
|
+
export * from "./components/box";
|
|
24
|
+
export * from "./components/cancellable-loader";
|
|
25
|
+
export * from "./components/editor";
|
|
26
|
+
export * from "./components/image";
|
|
27
|
+
export * from "./components/input";
|
|
28
|
+
export * from "./components/loader";
|
|
29
|
+
export * from "./components/markdown";
|
|
30
|
+
export * from "./components/select-list";
|
|
31
|
+
export * from "./components/settings-list";
|
|
32
|
+
export * from "./components/spacer";
|
|
33
|
+
export * from "./components/tab-bar";
|
|
34
|
+
export * from "./components/text";
|
|
35
|
+
export * from "./components/truncated-text";
|
|
36
|
+
// Editor component interface (for custom editors)
|
|
37
|
+
export type * from "./editor-component";
|
|
38
|
+
// Events
|
|
39
|
+
export { TypedEventEmitter } from "./events";
|
|
40
|
+
// Fuzzy matching
|
|
41
|
+
export * from "./fuzzy";
|
|
42
|
+
// Horizontal split layout primitive
|
|
43
|
+
export {
|
|
44
|
+
HorizontalSplit,
|
|
45
|
+
type SplitChild,
|
|
46
|
+
type SplitChildWidth,
|
|
47
|
+
} from "./horizontal-split";
|
|
48
|
+
// Keybindings
|
|
49
|
+
export * from "./keybindings";
|
|
50
|
+
// Kitty keyboard protocol helpers
|
|
51
|
+
export * from "./keys";
|
|
52
|
+
// Mermaid diagram support
|
|
53
|
+
// Input buffering for batch splitting
|
|
54
|
+
export * from "./stdin-buffer";
|
|
55
|
+
export type * from "./symbols";
|
|
56
|
+
// Terminal interface and implementations
|
|
57
|
+
export * from "./terminal";
|
|
58
|
+
// Terminal image support
|
|
59
|
+
export * from "./terminal-capabilities";
|
|
60
|
+
// TTY ID
|
|
61
|
+
export * from "./ttyid";
|
|
62
|
+
export * from "./tui";
|
|
63
|
+
// Utilities
|
|
64
|
+
export * from "./utils";
|