@nghyane/arcane-tui 0.1.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 +3 -0
- package/README.md +704 -0
- package/package.json +72 -0
- package/src/autocomplete.ts +772 -0
- package/src/buffer/ansi-parser.ts +349 -0
- package/src/buffer/buffer.ts +120 -0
- package/src/buffer/cell.ts +103 -0
- package/src/buffer/index.ts +16 -0
- package/src/buffer/render.ts +149 -0
- package/src/components/box.ts +144 -0
- package/src/components/cancellable-loader.ts +39 -0
- package/src/components/editor.ts +2289 -0
- package/src/components/image.ts +86 -0
- package/src/components/input.ts +531 -0
- package/src/components/loader.ts +59 -0
- package/src/components/markdown.ts +858 -0
- package/src/components/select-list.ts +198 -0
- package/src/components/settings-list.ts +194 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +142 -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/fuzzy.ts +143 -0
- package/src/index.ts +69 -0
- package/src/keybindings.ts +197 -0
- package/src/keys.ts +270 -0
- package/src/kill-ring.ts +46 -0
- package/src/mermaid.ts +140 -0
- package/src/stdin-buffer.ts +385 -0
- package/src/symbols.ts +24 -0
- package/src/terminal-capabilities.ts +393 -0
- package/src/terminal.ts +467 -0
- package/src/ttyid.ts +66 -0
- package/src/tui.ts +1134 -0
- package/src/utils.ts +149 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Buffer, CellChange } from "./buffer.js";
|
|
2
|
+
import { DEFAULT_STYLE, hasColor, Mod, type Style, unpackRgb } from "./cell.js";
|
|
3
|
+
|
|
4
|
+
function styleEquals(a: Style, b: Style): boolean {
|
|
5
|
+
return a.fg === b.fg && a.bg === b.bg && a.mods === b.mods && a.link === b.link;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function emitStyleDiff(prev: Style, next: Style, parts: string[]): void {
|
|
9
|
+
// Check if reset is simpler
|
|
10
|
+
const needReset =
|
|
11
|
+
(prev.mods & ~next.mods) !== 0 ||
|
|
12
|
+
(hasColor(prev.fg) && !hasColor(next.fg)) ||
|
|
13
|
+
(hasColor(prev.bg) && !hasColor(next.bg));
|
|
14
|
+
|
|
15
|
+
if (needReset) {
|
|
16
|
+
parts.push("\x1b[0m");
|
|
17
|
+
// After reset, emit everything in next that isn't default
|
|
18
|
+
if (next.mods) emitMods(0, next.mods, parts);
|
|
19
|
+
if (hasColor(next.fg)) {
|
|
20
|
+
const [r, g, b] = unpackRgb(next.fg);
|
|
21
|
+
parts.push(`\x1b[38;2;${r};${g};${b}m`);
|
|
22
|
+
}
|
|
23
|
+
if (hasColor(next.bg)) {
|
|
24
|
+
const [r, g, b] = unpackRgb(next.bg);
|
|
25
|
+
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
if (prev.fg !== next.fg) {
|
|
29
|
+
if (hasColor(next.fg)) {
|
|
30
|
+
const [r, g, b] = unpackRgb(next.fg);
|
|
31
|
+
parts.push(`\x1b[38;2;${r};${g};${b}m`);
|
|
32
|
+
} else {
|
|
33
|
+
parts.push("\x1b[39m");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (prev.bg !== next.bg) {
|
|
37
|
+
if (hasColor(next.bg)) {
|
|
38
|
+
const [r, g, b] = unpackRgb(next.bg);
|
|
39
|
+
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
40
|
+
} else {
|
|
41
|
+
parts.push("\x1b[49m");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (prev.mods !== next.mods) {
|
|
45
|
+
emitMods(prev.mods, next.mods, parts);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (prev.link !== next.link) {
|
|
50
|
+
if (next.link) {
|
|
51
|
+
parts.push(`\x1b]8;;${next.link}\x07`);
|
|
52
|
+
} else {
|
|
53
|
+
parts.push("\x1b]8;;\x07");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function emitMods(prev: number, next: number, parts: string[]): void {
|
|
59
|
+
const added = next & ~prev;
|
|
60
|
+
const removed = prev & ~next;
|
|
61
|
+
|
|
62
|
+
if (added & Mod.Bold) parts.push("\x1b[1m");
|
|
63
|
+
if (added & Mod.Dim) parts.push("\x1b[2m");
|
|
64
|
+
if (added & Mod.Italic) parts.push("\x1b[3m");
|
|
65
|
+
if (added & Mod.Underline) parts.push("\x1b[4m");
|
|
66
|
+
if (added & Mod.Blink) parts.push("\x1b[5m");
|
|
67
|
+
if (added & Mod.Reverse) parts.push("\x1b[7m");
|
|
68
|
+
if (added & Mod.Hidden) parts.push("\x1b[8m");
|
|
69
|
+
if (added & Mod.Strikethrough) parts.push("\x1b[9m");
|
|
70
|
+
|
|
71
|
+
if (removed & Mod.Bold) parts.push("\x1b[22m");
|
|
72
|
+
if (removed & Mod.Dim) parts.push("\x1b[22m");
|
|
73
|
+
if (removed & Mod.Italic) parts.push("\x1b[23m");
|
|
74
|
+
if (removed & Mod.Underline) parts.push("\x1b[24m");
|
|
75
|
+
if (removed & Mod.Blink) parts.push("\x1b[25m");
|
|
76
|
+
if (removed & Mod.Reverse) parts.push("\x1b[27m");
|
|
77
|
+
if (removed & Mod.Hidden) parts.push("\x1b[28m");
|
|
78
|
+
if (removed & Mod.Strikethrough) parts.push("\x1b[29m");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function renderDiff(changes: CellChange[], _width: number): string {
|
|
82
|
+
if (changes.length === 0) return "";
|
|
83
|
+
|
|
84
|
+
const sorted = [...changes].sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
|
85
|
+
|
|
86
|
+
const parts: string[] = [];
|
|
87
|
+
let curRow = -1;
|
|
88
|
+
let curCol = -1;
|
|
89
|
+
let curStyle: Style = { ...DEFAULT_STYLE };
|
|
90
|
+
|
|
91
|
+
for (const change of sorted) {
|
|
92
|
+
const { col, row, cell } = change;
|
|
93
|
+
|
|
94
|
+
// Skip placeholder cells
|
|
95
|
+
if (cell.width === 0) continue;
|
|
96
|
+
|
|
97
|
+
// Cursor positioning
|
|
98
|
+
if (row !== curRow || col !== curCol) {
|
|
99
|
+
// 1-based positioning
|
|
100
|
+
parts.push(`\x1b[${row + 1};${col + 1}H`);
|
|
101
|
+
curRow = row;
|
|
102
|
+
curCol = col;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Style changes
|
|
106
|
+
if (!styleEquals(curStyle, cell.style)) {
|
|
107
|
+
emitStyleDiff(curStyle, cell.style, parts);
|
|
108
|
+
curStyle = { ...cell.style };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
parts.push(cell.char);
|
|
112
|
+
curCol += cell.width;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
parts.push("\x1b[0m");
|
|
116
|
+
return parts.join("");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function renderBuffer(buf: Buffer): string[] {
|
|
120
|
+
const lines: string[] = [];
|
|
121
|
+
|
|
122
|
+
for (let row = 0; row < buf.height; row++) {
|
|
123
|
+
const parts: string[] = [];
|
|
124
|
+
let curStyle: Style = { ...DEFAULT_STYLE };
|
|
125
|
+
|
|
126
|
+
for (let col = 0; col < buf.width; col++) {
|
|
127
|
+
const cell = buf.get(col, row);
|
|
128
|
+
|
|
129
|
+
// Skip placeholders
|
|
130
|
+
if (cell.width === 0) continue;
|
|
131
|
+
|
|
132
|
+
if (!styleEquals(curStyle, cell.style)) {
|
|
133
|
+
emitStyleDiff(curStyle, cell.style, parts);
|
|
134
|
+
curStyle = { ...cell.style };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
parts.push(cell.char);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Reset at end of line
|
|
141
|
+
if (!styleEquals(curStyle, DEFAULT_STYLE)) {
|
|
142
|
+
parts.push("\x1b[0m");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
lines.push(parts.join(""));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Component } from "../tui";
|
|
2
|
+
import { applyBackgroundToLine, padding, visibleWidth } from "../utils";
|
|
3
|
+
|
|
4
|
+
type Cache = {
|
|
5
|
+
key: bigint;
|
|
6
|
+
result: string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Box component - a container that applies padding and background to all children
|
|
11
|
+
*/
|
|
12
|
+
export class Box implements Component {
|
|
13
|
+
children: Component[] = [];
|
|
14
|
+
#paddingX: number;
|
|
15
|
+
#paddingY: number;
|
|
16
|
+
#bgFn?: (text: string) => string;
|
|
17
|
+
|
|
18
|
+
// Cache for rendered output
|
|
19
|
+
#cached?: Cache;
|
|
20
|
+
|
|
21
|
+
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
|
|
22
|
+
this.#paddingX = paddingX;
|
|
23
|
+
this.#paddingY = paddingY;
|
|
24
|
+
this.#bgFn = bgFn;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
addChild(component: Component): void {
|
|
28
|
+
this.children.push(component);
|
|
29
|
+
this.#invalidateCache();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
removeChild(component: Component): void {
|
|
33
|
+
const index = this.children.indexOf(component);
|
|
34
|
+
if (index !== -1) {
|
|
35
|
+
this.children.splice(index, 1);
|
|
36
|
+
this.#invalidateCache();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clear(): void {
|
|
41
|
+
this.children = [];
|
|
42
|
+
this.#invalidateCache();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setBgFn(bgFn?: (text: string) => string): void {
|
|
46
|
+
this.#bgFn = bgFn;
|
|
47
|
+
// Don't invalidate here - we'll detect bgFn changes by sampling output
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#invalidateCache(): void {
|
|
51
|
+
this.#cached = undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static #tmp = new Uint32Array(2);
|
|
55
|
+
#computeCacheKey(width: number, childLines: string[], bgSample: string | undefined): bigint {
|
|
56
|
+
Box.#tmp[0] = width;
|
|
57
|
+
Box.#tmp[1] = childLines.length;
|
|
58
|
+
let h = Bun.hash.xxHash64(Box.#tmp);
|
|
59
|
+
for (const line of childLines) {
|
|
60
|
+
Box.#tmp[0] = line.length;
|
|
61
|
+
h = Bun.hash.xxHash64(Box.#tmp, h);
|
|
62
|
+
h = Bun.hash.xxHash64(line, h);
|
|
63
|
+
}
|
|
64
|
+
h = Bun.hash.xxHash64(bgSample ?? "", h);
|
|
65
|
+
return h;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#matchCache(cacheKey: bigint): boolean {
|
|
69
|
+
return this.#cached?.key === cacheKey;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
invalidate(): void {
|
|
73
|
+
this.#invalidateCache();
|
|
74
|
+
for (const child of this.children) {
|
|
75
|
+
child.invalidate?.();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
render(width: number): string[] {
|
|
80
|
+
if (this.children.length === 0) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const contentWidth = Math.max(1, width - this.#paddingX * 2);
|
|
85
|
+
const leftPad = padding(this.#paddingX);
|
|
86
|
+
|
|
87
|
+
// Render all children
|
|
88
|
+
const childLines: string[] = [];
|
|
89
|
+
for (const child of this.children) {
|
|
90
|
+
const lines = child.render(contentWidth);
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
childLines.push(leftPad + line);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (childLines.length === 0) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check if bgFn output changed by sampling
|
|
101
|
+
const bgSample = this.#bgFn ? this.#bgFn("test") : undefined;
|
|
102
|
+
|
|
103
|
+
const cacheKey = this.#computeCacheKey(width, childLines, bgSample);
|
|
104
|
+
|
|
105
|
+
// Check cache validity
|
|
106
|
+
if (this.#matchCache(cacheKey)) {
|
|
107
|
+
return this.#cached!.result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Apply background and padding
|
|
111
|
+
const result: string[] = [];
|
|
112
|
+
|
|
113
|
+
// Top padding
|
|
114
|
+
for (let i = 0; i < this.#paddingY; i++) {
|
|
115
|
+
result.push(this.#applyBg("", width));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Content
|
|
119
|
+
for (const line of childLines) {
|
|
120
|
+
result.push(this.#applyBg(line, width));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Bottom padding
|
|
124
|
+
for (let i = 0; i < this.#paddingY; i++) {
|
|
125
|
+
result.push(this.#applyBg("", width));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Update cache
|
|
129
|
+
this.#cached = { key: cacheKey, result };
|
|
130
|
+
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#applyBg(line: string, width: number): string {
|
|
135
|
+
const visLen = visibleWidth(line);
|
|
136
|
+
const padNeeded = Math.max(0, width - visLen);
|
|
137
|
+
const padded = line + padding(padNeeded);
|
|
138
|
+
|
|
139
|
+
if (this.#bgFn) {
|
|
140
|
+
return applyBackgroundToLine(padded, width, this.#bgFn);
|
|
141
|
+
}
|
|
142
|
+
return padded;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { matchesKey } from "../keys";
|
|
2
|
+
import { Loader } from "./loader";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Loader that can be cancelled with Escape.
|
|
6
|
+
* Extends Loader with an AbortSignal for cancelling async operations.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
|
|
10
|
+
* loader.onAbort = () => done(null);
|
|
11
|
+
* doWork(loader.signal).then(done);
|
|
12
|
+
*/
|
|
13
|
+
export class CancellableLoader extends Loader {
|
|
14
|
+
#abortController = new AbortController();
|
|
15
|
+
|
|
16
|
+
/** Called when user presses Escape */
|
|
17
|
+
onAbort?: () => void;
|
|
18
|
+
|
|
19
|
+
/** AbortSignal that is aborted when user presses Escape */
|
|
20
|
+
get signal(): AbortSignal {
|
|
21
|
+
return this.#abortController.signal;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether the loader was aborted */
|
|
25
|
+
get aborted(): boolean {
|
|
26
|
+
return this.#abortController.signal.aborted;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
handleInput(data: string): void {
|
|
30
|
+
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
|
31
|
+
this.#abortController.abort();
|
|
32
|
+
this.onAbort?.();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
dispose(): void {
|
|
37
|
+
this.stop();
|
|
38
|
+
}
|
|
39
|
+
}
|