@nghyane/arcane-tui 0.1.8 → 0.1.9
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/package.json +1 -1
- package/src/buffer/render.ts +22 -6
- package/src/components/markdown.ts +87 -10
- package/src/index.ts +1 -1
- package/src/terminal.ts +50 -0
- package/src/tui.ts +19 -5
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@nghyane/arcane-tui",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.9",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/nghyane/arcane",
|
|
7
7
|
"author": "Can Bölük",
|
package/src/buffer/render.ts
CHANGED
|
@@ -5,7 +5,7 @@ function styleEquals(a: Style, b: Style): boolean {
|
|
|
5
5
|
return a.fg === b.fg && a.bg === b.bg && a.mods === b.mods && a.link === b.link;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
function emitStyleDiff(prev: Style, next: Style, parts: string[]): void {
|
|
8
|
+
function emitStyleDiff(prev: Style, next: Style, parts: string[], defaultBg = 0): void {
|
|
9
9
|
// Check if reset is simpler
|
|
10
10
|
const needReset =
|
|
11
11
|
(prev.mods & ~next.mods) !== 0 ||
|
|
@@ -23,6 +23,9 @@ function emitStyleDiff(prev: Style, next: Style, parts: string[]): void {
|
|
|
23
23
|
if (hasColor(next.bg)) {
|
|
24
24
|
const [r, g, b] = unpackRgb(next.bg);
|
|
25
25
|
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
26
|
+
} else if (defaultBg) {
|
|
27
|
+
const [r, g, b] = unpackRgb(defaultBg);
|
|
28
|
+
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
26
29
|
}
|
|
27
30
|
} else {
|
|
28
31
|
if (prev.fg !== next.fg) {
|
|
@@ -38,7 +41,12 @@ function emitStyleDiff(prev: Style, next: Style, parts: string[]): void {
|
|
|
38
41
|
const [r, g, b] = unpackRgb(next.bg);
|
|
39
42
|
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
40
43
|
} else {
|
|
41
|
-
|
|
44
|
+
if (defaultBg) {
|
|
45
|
+
const [r, g, b] = unpackRgb(defaultBg);
|
|
46
|
+
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
47
|
+
} else {
|
|
48
|
+
parts.push("\x1b[49m");
|
|
49
|
+
}
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
if (prev.mods !== next.mods) {
|
|
@@ -78,7 +86,7 @@ function emitMods(prev: number, next: number, parts: string[]): void {
|
|
|
78
86
|
if (removed & Mod.Strikethrough) parts.push("\x1b[29m");
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
export function renderDiff(changes: CellChange[], _width: number): string {
|
|
89
|
+
export function renderDiff(changes: CellChange[], _width: number, defaultBg = 0): string {
|
|
82
90
|
if (changes.length === 0) return "";
|
|
83
91
|
|
|
84
92
|
const sorted = [...changes].sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
|
@@ -104,7 +112,7 @@ export function renderDiff(changes: CellChange[], _width: number): string {
|
|
|
104
112
|
|
|
105
113
|
// Style changes
|
|
106
114
|
if (!styleEquals(curStyle, cell.style)) {
|
|
107
|
-
emitStyleDiff(curStyle, cell.style, parts);
|
|
115
|
+
emitStyleDiff(curStyle, cell.style, parts, defaultBg);
|
|
108
116
|
curStyle = { ...cell.style };
|
|
109
117
|
}
|
|
110
118
|
|
|
@@ -113,10 +121,14 @@ export function renderDiff(changes: CellChange[], _width: number): string {
|
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
parts.push("\x1b[0m");
|
|
124
|
+
if (defaultBg) {
|
|
125
|
+
const [r, g, b] = unpackRgb(defaultBg);
|
|
126
|
+
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
127
|
+
}
|
|
116
128
|
return parts.join("");
|
|
117
129
|
}
|
|
118
130
|
|
|
119
|
-
export function renderBuffer(buf: Buffer): string[] {
|
|
131
|
+
export function renderBuffer(buf: Buffer, defaultBg = 0): string[] {
|
|
120
132
|
const lines: string[] = [];
|
|
121
133
|
|
|
122
134
|
for (let row = 0; row < buf.height; row++) {
|
|
@@ -130,7 +142,7 @@ export function renderBuffer(buf: Buffer): string[] {
|
|
|
130
142
|
if (cell.width === 0) continue;
|
|
131
143
|
|
|
132
144
|
if (!styleEquals(curStyle, cell.style)) {
|
|
133
|
-
emitStyleDiff(curStyle, cell.style, parts);
|
|
145
|
+
emitStyleDiff(curStyle, cell.style, parts, defaultBg);
|
|
134
146
|
curStyle = { ...cell.style };
|
|
135
147
|
}
|
|
136
148
|
|
|
@@ -140,6 +152,10 @@ export function renderBuffer(buf: Buffer): string[] {
|
|
|
140
152
|
// Reset at end of line
|
|
141
153
|
if (!styleEquals(curStyle, DEFAULT_STYLE)) {
|
|
142
154
|
parts.push("\x1b[0m");
|
|
155
|
+
if (defaultBg) {
|
|
156
|
+
const [r, g, b] = unpackRgb(defaultBg);
|
|
157
|
+
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
158
|
+
}
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
lines.push(parts.join(""));
|
|
@@ -22,6 +22,8 @@ export interface DefaultTextStyle {
|
|
|
22
22
|
strikethrough?: boolean;
|
|
23
23
|
/** Underline text */
|
|
24
24
|
underline?: boolean;
|
|
25
|
+
leftBorder?: string;
|
|
26
|
+
/** Left border string (already styled, prepended to each line) */
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -106,8 +108,9 @@ export class Markdown implements Component {
|
|
|
106
108
|
return this.#cachedLines;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
+
const leftBorder = this.#defaultTextStyle?.leftBorder ?? "";
|
|
112
|
+
const borderWidth = visibleWidth(leftBorder);
|
|
113
|
+
const contentWidth = Math.max(1, width - this.#paddingX * 2 - borderWidth);
|
|
111
114
|
|
|
112
115
|
// Don't render anything if there's no actual text
|
|
113
116
|
if (!this.#text || this.#text.trim() === "") {
|
|
@@ -153,26 +156,23 @@ export class Markdown implements Component {
|
|
|
153
156
|
const contentLines: string[] = [];
|
|
154
157
|
|
|
155
158
|
for (const line of wrappedLines) {
|
|
156
|
-
// Image lines must be output raw - no margins or background
|
|
157
159
|
if (TERMINAL.isImageLine(line)) {
|
|
158
160
|
contentLines.push(line);
|
|
159
161
|
continue;
|
|
160
162
|
}
|
|
161
163
|
|
|
162
|
-
const lineWithMargins = leftMargin + line + rightMargin;
|
|
164
|
+
const lineWithMargins = leftBorder + leftMargin + line + rightMargin;
|
|
163
165
|
|
|
164
166
|
if (bgFn) {
|
|
165
167
|
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
|
|
166
168
|
} else {
|
|
167
|
-
// No background - just pad to width
|
|
168
169
|
const visibleLen = visibleWidth(lineWithMargins);
|
|
169
170
|
const paddingNeeded = Math.max(0, width - visibleLen);
|
|
170
171
|
contentLines.push(lineWithMargins + padding(paddingNeeded));
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
|
|
174
|
-
|
|
175
|
-
const emptyLine = padding(width);
|
|
175
|
+
const emptyLine = leftBorder + padding(width - borderWidth);
|
|
176
176
|
const emptyLines: string[] = [];
|
|
177
177
|
for (let i = 0; i < this.#paddingY; i++) {
|
|
178
178
|
const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;
|
|
@@ -365,13 +365,15 @@ export class Markdown implements Component {
|
|
|
365
365
|
applyText: quoteStyle,
|
|
366
366
|
stylePrefix: this.#getStylePrefix(quoteStyle),
|
|
367
367
|
};
|
|
368
|
-
|
|
369
|
-
|
|
368
|
+
|
|
369
|
+
// Blockquote tokens contain block-level children (paragraph, code, list, etc.)
|
|
370
|
+
// Render each child appropriately, then prefix every line with the quote border.
|
|
371
|
+
const quoteContentLines = this.#renderBlockquoteTokens(token.tokens || [], quoteStyleContext, width - 2);
|
|
370
372
|
|
|
371
373
|
// Calculate available width for quote content (subtract border + space = 2 chars)
|
|
372
374
|
const quoteContentWidth = Math.max(1, width - 2);
|
|
373
375
|
|
|
374
|
-
for (const quoteLine of
|
|
376
|
+
for (const quoteLine of quoteContentLines) {
|
|
375
377
|
// Wrap the styled line, then add border to each wrapped line
|
|
376
378
|
const wrappedLines = wrapTextWithAnsi(quoteLine, quoteContentWidth);
|
|
377
379
|
for (const wrappedLine of wrappedLines) {
|
|
@@ -601,6 +603,81 @@ export class Markdown implements Component {
|
|
|
601
603
|
return lines;
|
|
602
604
|
}
|
|
603
605
|
|
|
606
|
+
/**
|
|
607
|
+
* Render tokens inside a blockquote, handling block-level elements
|
|
608
|
+
* (code, list, heading) that #renderInlineTokens cannot process.
|
|
609
|
+
*/
|
|
610
|
+
#renderBlockquoteTokens(tokens: Token[], styleContext: InlineStyleContext, availableWidth: number): string[] {
|
|
611
|
+
const lines: string[] = [];
|
|
612
|
+
|
|
613
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
614
|
+
const token = tokens[i];
|
|
615
|
+
const nextToken = tokens[i + 1];
|
|
616
|
+
|
|
617
|
+
switch (token.type) {
|
|
618
|
+
case "paragraph": {
|
|
619
|
+
const text = this.#renderInlineTokens(token.tokens || [], styleContext);
|
|
620
|
+
lines.push(text);
|
|
621
|
+
if (nextToken && nextToken.type !== "space") {
|
|
622
|
+
lines.push("");
|
|
623
|
+
}
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
case "code": {
|
|
628
|
+
const codeIndent = padding(this.#codeBlockIndent);
|
|
629
|
+
lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
|
|
630
|
+
if (this.#theme.highlightCode) {
|
|
631
|
+
const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
|
|
632
|
+
for (const hlLine of highlightedLines) {
|
|
633
|
+
lines.push(`${codeIndent}${hlLine}`);
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
const codeLines = token.text.split("\n");
|
|
637
|
+
for (const codeLine of codeLines) {
|
|
638
|
+
lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
lines.push(this.#theme.codeBlockBorder("```"));
|
|
642
|
+
if (nextToken && nextToken.type !== "space") {
|
|
643
|
+
lines.push("");
|
|
644
|
+
}
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
case "heading": {
|
|
649
|
+
const headingLines = this.#renderToken(token, availableWidth, nextToken?.type);
|
|
650
|
+
lines.push(...headingLines);
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
case "list": {
|
|
655
|
+
const listLines = this.#renderList(token as any, 0);
|
|
656
|
+
lines.push(...listLines);
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
case "hr": {
|
|
661
|
+
lines.push(this.#theme.hr(this.#theme.symbols.hrChar.repeat(Math.min(availableWidth, 80))));
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
case "space":
|
|
666
|
+
lines.push("");
|
|
667
|
+
break;
|
|
668
|
+
|
|
669
|
+
default: {
|
|
670
|
+
const text = this.#renderInlineTokens([token], styleContext);
|
|
671
|
+
if (text) {
|
|
672
|
+
lines.push(text);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return lines;
|
|
679
|
+
}
|
|
680
|
+
|
|
604
681
|
/**
|
|
605
682
|
* Get the visible width of the longest word in a string.
|
|
606
683
|
*/
|
package/src/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ export {
|
|
|
59
59
|
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer";
|
|
60
60
|
export type { BoxSymbols, SymbolTheme } from "./symbols";
|
|
61
61
|
// Terminal interface and implementations
|
|
62
|
-
export { emergencyTerminalRestore, ProcessTerminal, type Terminal } from "./terminal";
|
|
62
|
+
export { emergencyTerminalRestore, ProcessTerminal, queryTerminalBackground, type Terminal } from "./terminal";
|
|
63
63
|
// Terminal image support
|
|
64
64
|
export * from "./terminal-capabilities";
|
|
65
65
|
// TTY ID
|
package/src/terminal.ts
CHANGED
|
@@ -56,6 +56,56 @@ export function emergencyTerminalRestore(): void {
|
|
|
56
56
|
// Terminal may already be dead during crash cleanup - ignore errors
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Query terminal background color via OSC 11.
|
|
62
|
+
* Returns hex color string (e.g. "#1c1e26") or null if terminal does not respond.
|
|
63
|
+
* Must be called before TUI start() — temporarily enters raw mode.
|
|
64
|
+
*/
|
|
65
|
+
export async function queryTerminalBackground(timeoutMs = 150): Promise<string | null> {
|
|
66
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
|
|
67
|
+
|
|
68
|
+
const wasRaw = process.stdin.isRaw;
|
|
69
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
70
|
+
process.stdin.setEncoding("utf8");
|
|
71
|
+
process.stdin.resume();
|
|
72
|
+
|
|
73
|
+
const { promise, resolve } = Promise.withResolvers<string | null>();
|
|
74
|
+
let settled = false;
|
|
75
|
+
let buf = "";
|
|
76
|
+
|
|
77
|
+
const onData = (data: string) => {
|
|
78
|
+
buf += data;
|
|
79
|
+
// OSC 11 response: \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\ or ...\x07
|
|
80
|
+
const match = buf.match(/\x1b\]11;rgb:([0-9a-fA-F]{2,4})\/([0-9a-fA-F]{2,4})\/([0-9a-fA-F]{2,4})/);
|
|
81
|
+
if (match && !settled) {
|
|
82
|
+
settled = true;
|
|
83
|
+
const r = parseInt(match[1].slice(0, 2), 16);
|
|
84
|
+
const g = parseInt(match[2].slice(0, 2), 16);
|
|
85
|
+
const b = parseInt(match[3].slice(0, 2), 16);
|
|
86
|
+
resolve(
|
|
87
|
+
`#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
process.stdin.on("data", onData);
|
|
93
|
+
process.stdout.write("\x1b]11;?\x1b\\");
|
|
94
|
+
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
if (!settled) {
|
|
97
|
+
settled = true;
|
|
98
|
+
resolve(null);
|
|
99
|
+
}
|
|
100
|
+
}, timeoutMs);
|
|
101
|
+
|
|
102
|
+
const result = await promise;
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
process.stdin.removeListener("data", onData);
|
|
105
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(wasRaw);
|
|
106
|
+
if (!wasRaw) process.stdin.pause();
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
59
109
|
export interface Terminal {
|
|
60
110
|
// Start the terminal with input and resize handlers
|
|
61
111
|
start(onInput: (data: string) => void, onResize: () => void): void;
|
package/src/tui.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { linesToBuffer } from "./buffer/ansi-parser.js";
|
|
2
2
|
import type { Buffer as CellBuffer, CellChange } from "./buffer/buffer.js";
|
|
3
|
-
import { Mod } from "./buffer/cell.js";
|
|
3
|
+
import { Mod, unpackRgb } from "./buffer/cell.js";
|
|
4
4
|
import { renderBuffer, renderDiff } from "./buffer/render.js";
|
|
5
5
|
import { isKeyRelease, matchesKey } from "./keys";
|
|
6
6
|
import { type MouseEvent, SCROLL_DOWN, SCROLL_UP, type Terminal } from "./terminal";
|
|
@@ -222,6 +222,7 @@ export class TUI extends Container {
|
|
|
222
222
|
#scrollFlushScheduled = false; // Whether a flush is scheduled on next tick
|
|
223
223
|
#fullRenderCache: string[] = []; // Cached full render output for native scroll
|
|
224
224
|
#previousBuffer: CellBuffer | null = null; // Previous viewport buffer for cell-level diff
|
|
225
|
+
#appBg = 0; // Packed RGB for app-wide background (0 = terminal default)
|
|
225
226
|
|
|
226
227
|
// Selection state for mouse text selection
|
|
227
228
|
#selectionActive = false;
|
|
@@ -275,6 +276,11 @@ export class TUI extends Container {
|
|
|
275
276
|
this.#clearOnShrink = enabled;
|
|
276
277
|
}
|
|
277
278
|
|
|
279
|
+
setAppBg(packedRgb: number): void {
|
|
280
|
+
this.#appBg = packedRgb;
|
|
281
|
+
this.#previousBuffer = null; // Force full redraw
|
|
282
|
+
}
|
|
283
|
+
|
|
278
284
|
setFocus(component: Component | null): void {
|
|
279
285
|
// Clear focused flag on old component
|
|
280
286
|
if (isFocusable(this.#focusedComponent)) {
|
|
@@ -475,10 +481,14 @@ export class TUI extends Container {
|
|
|
475
481
|
if (this.#previousBuffer) {
|
|
476
482
|
const changes = currentBuffer.diff(this.#previousBuffer);
|
|
477
483
|
if (changes.length > 0) {
|
|
478
|
-
out += renderDiff(changes, width);
|
|
484
|
+
out += renderDiff(changes, width, this.#appBg);
|
|
479
485
|
}
|
|
480
486
|
} else {
|
|
481
487
|
// No previous buffer — full viewport redraw
|
|
488
|
+
if (this.#appBg) {
|
|
489
|
+
const [r, g, b] = unpackRgb(this.#appBg);
|
|
490
|
+
out += `\x1b[48;2;${r};${g};${b}m`;
|
|
491
|
+
}
|
|
482
492
|
out += "\x1b[H";
|
|
483
493
|
for (let i = 0; i < viewportLines.length; i++) {
|
|
484
494
|
if (i > 0) out += "\r\n";
|
|
@@ -715,7 +725,7 @@ export class TUI extends Container {
|
|
|
715
725
|
// Output only the changed cells
|
|
716
726
|
let out = "\x1b[?2026h"; // Synchronized output
|
|
717
727
|
out += "\x1b7"; // Save cursor position
|
|
718
|
-
out += renderDiff(changes, width);
|
|
728
|
+
out += renderDiff(changes, width, this.#appBg);
|
|
719
729
|
out += "\x1b8"; // Restore cursor position
|
|
720
730
|
out += "\x1b[?2026l";
|
|
721
731
|
this.terminal.write(out);
|
|
@@ -1046,8 +1056,12 @@ export class TUI extends Container {
|
|
|
1046
1056
|
// No previous buffer — full render
|
|
1047
1057
|
this.#fullRedrawCount += 1;
|
|
1048
1058
|
let out = "\x1b[?2026h"; // Begin synchronized output
|
|
1059
|
+
if (this.#appBg) {
|
|
1060
|
+
const [r, g, b] = unpackRgb(this.#appBg);
|
|
1061
|
+
out += `\x1b[48;2;${r};${g};${b}m`;
|
|
1062
|
+
}
|
|
1049
1063
|
out += this.#clearScrollbackOnNextFullRender ? "\x1b[3J\x1b[2J\x1b[H" : "\x1b[2J\x1b[H";
|
|
1050
|
-
const renderedLines = renderBuffer(currentBuffer);
|
|
1064
|
+
const renderedLines = renderBuffer(currentBuffer, this.#appBg);
|
|
1051
1065
|
for (let i = 0; i < renderedLines.length; i++) {
|
|
1052
1066
|
if (i > 0) out += "\r\n";
|
|
1053
1067
|
out += renderedLines[i];
|
|
@@ -1083,7 +1097,7 @@ export class TUI extends Container {
|
|
|
1083
1097
|
} else {
|
|
1084
1098
|
// Emit minimal ANSI for changed cells
|
|
1085
1099
|
let out = "\x1b[?2026h"; // Begin synchronized output
|
|
1086
|
-
out += renderDiff(changes, width);
|
|
1100
|
+
out += renderDiff(changes, width, this.#appBg);
|
|
1087
1101
|
const adjCursorPos = cursorPos
|
|
1088
1102
|
? { row: cursorPos.row - Math.max(0, newLines.length - height), col: cursorPos.col }
|
|
1089
1103
|
: null;
|