@oh-my-pi/pi-tui 16.0.11 → 16.1.1
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 +6 -0
- package/dist/types/components/box.d.ts +14 -1
- package/package.json +3 -3
- package/src/components/box.ts +54 -8
- package/src/components/markdown.ts +61 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.0] - 2026-06-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `Box` now accepts an optional `border` (box-drawing glyphs + colorizer) and exposes `setBorder()`, drawing a colored outline around its padded/background content. The border is automatically dropped at widths too narrow to frame so a bordered box never overflows its given width.
|
|
10
|
+
|
|
5
11
|
## [16.0.11] - 2026-06-19
|
|
6
12
|
|
|
7
13
|
### Breaking Changes
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import type { Component } from "../tui";
|
|
2
|
+
/** Box-drawing glyphs plus an optional colorizer for an outline drawn around a {@link Box}. */
|
|
3
|
+
export interface BoxBorder {
|
|
4
|
+
chars: {
|
|
5
|
+
topLeft: string;
|
|
6
|
+
topRight: string;
|
|
7
|
+
bottomLeft: string;
|
|
8
|
+
bottomRight: string;
|
|
9
|
+
horizontal: string;
|
|
10
|
+
vertical: string;
|
|
11
|
+
};
|
|
12
|
+
color?: (text: string) => string;
|
|
13
|
+
}
|
|
2
14
|
/**
|
|
3
15
|
* Box component - a container that applies padding and background to all children
|
|
4
16
|
*/
|
|
@@ -6,13 +18,14 @@ export declare class Box implements Component {
|
|
|
6
18
|
#private;
|
|
7
19
|
children: Component[];
|
|
8
20
|
setIgnoreTight(ignore: boolean): this;
|
|
9
|
-
constructor(paddingX?: number, paddingY?: number, bgFn?: (text: string) => string);
|
|
21
|
+
constructor(paddingX?: number, paddingY?: number, bgFn?: (text: string) => string, border?: BoxBorder);
|
|
10
22
|
addChild(component: Component): void;
|
|
11
23
|
removeChild(component: Component): void;
|
|
12
24
|
clear(): void;
|
|
13
25
|
setPaddingX(paddingX: number): void;
|
|
14
26
|
setPaddingY(paddingY: number): void;
|
|
15
27
|
setBgFn(bgFn?: (text: string) => string): void;
|
|
28
|
+
setBorder(border?: BoxBorder): void;
|
|
16
29
|
invalidate(): void;
|
|
17
30
|
render(width: number): readonly string[];
|
|
18
31
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "16.
|
|
4
|
+
"version": "16.1.1",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.1.1",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.1.1",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
package/src/components/box.ts
CHANGED
|
@@ -4,10 +4,24 @@ import { applyBackgroundToLine, getPaddingX, padding, visibleWidth } from "../ut
|
|
|
4
4
|
type Cache = {
|
|
5
5
|
width: number;
|
|
6
6
|
bgSample: string | undefined;
|
|
7
|
+
borderSample: string | undefined;
|
|
7
8
|
childLines: (readonly string[])[];
|
|
8
9
|
result: string[];
|
|
9
10
|
};
|
|
10
11
|
|
|
12
|
+
/** Box-drawing glyphs plus an optional colorizer for an outline drawn around a {@link Box}. */
|
|
13
|
+
export interface BoxBorder {
|
|
14
|
+
chars: {
|
|
15
|
+
topLeft: string;
|
|
16
|
+
topRight: string;
|
|
17
|
+
bottomLeft: string;
|
|
18
|
+
bottomRight: string;
|
|
19
|
+
horizontal: string;
|
|
20
|
+
vertical: string;
|
|
21
|
+
};
|
|
22
|
+
color?: (text: string) => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
/**
|
|
12
26
|
* Box component - a container that applies padding and background to all children
|
|
13
27
|
*/
|
|
@@ -16,6 +30,7 @@ export class Box implements Component {
|
|
|
16
30
|
#paddingX: number;
|
|
17
31
|
#paddingY: number;
|
|
18
32
|
#bgFn?: (text: string) => string;
|
|
33
|
+
#border?: BoxBorder;
|
|
19
34
|
|
|
20
35
|
#ignoreTight = false;
|
|
21
36
|
|
|
@@ -28,10 +43,11 @@ export class Box implements Component {
|
|
|
28
43
|
// Cache for rendered output
|
|
29
44
|
#cached?: Cache;
|
|
30
45
|
|
|
31
|
-
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
|
|
46
|
+
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string, border?: BoxBorder) {
|
|
32
47
|
this.#paddingX = paddingX;
|
|
33
48
|
this.#paddingY = paddingY;
|
|
34
49
|
this.#bgFn = bgFn;
|
|
50
|
+
this.#border = border;
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
addChild(component: Component): void {
|
|
@@ -72,6 +88,11 @@ export class Box implements Component {
|
|
|
72
88
|
// Don't invalidate here - we'll detect bgFn changes by sampling output
|
|
73
89
|
}
|
|
74
90
|
|
|
91
|
+
setBorder(border?: BoxBorder): void {
|
|
92
|
+
this.#border = border;
|
|
93
|
+
this.#invalidateCache();
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
#invalidateCache(): void {
|
|
76
97
|
this.#cached = undefined;
|
|
77
98
|
}
|
|
@@ -87,10 +108,18 @@ export class Box implements Component {
|
|
|
87
108
|
const children = this.children;
|
|
88
109
|
const count = children.length;
|
|
89
110
|
const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
111
|
+
// A border eats one column on each side; skip it unless the interior can still
|
|
112
|
+
// hold the horizontal padding plus at least one content column, so a bordered
|
|
113
|
+
// Box never overflows the width it was given.
|
|
114
|
+
const border = this.#border && width - 2 >= paddingX * 2 + 1 ? this.#border : undefined;
|
|
115
|
+
const innerWidth = border ? width - 2 : width;
|
|
116
|
+
const contentWidth = Math.max(1, innerWidth - paddingX * 2);
|
|
117
|
+
// bgFn / border output can change without the function reference changing
|
|
118
|
+
// (theme mutation); sample both so a silent palette swap still misses the cache.
|
|
93
119
|
const bgSample = this.#bgFn ? this.#bgFn("test") : undefined;
|
|
120
|
+
const borderSample = border
|
|
121
|
+
? `${border.color ? border.color("|") : "|"}${border.chars.topLeft}${border.chars.vertical}`
|
|
122
|
+
: undefined;
|
|
94
123
|
|
|
95
124
|
// Render every child every frame (renders may carry side effects); the
|
|
96
125
|
// memo only skips re-deriving the padded/background rows. Per the
|
|
@@ -101,6 +130,7 @@ export class Box implements Component {
|
|
|
101
130
|
cached !== undefined &&
|
|
102
131
|
cached.width === width &&
|
|
103
132
|
cached.bgSample === bgSample &&
|
|
133
|
+
cached.borderSample === borderSample &&
|
|
104
134
|
cached.childLines.length === count;
|
|
105
135
|
const childLines: (readonly string[])[] = new Array(count);
|
|
106
136
|
let contentRows = 0;
|
|
@@ -115,23 +145,39 @@ export class Box implements Component {
|
|
|
115
145
|
const result: string[] = [];
|
|
116
146
|
if (contentRows > 0) {
|
|
117
147
|
const leftPad = padding(paddingX);
|
|
148
|
+
const interior: string[] = [];
|
|
118
149
|
// Top padding
|
|
119
150
|
for (let i = 0; i < this.#paddingY; i++) {
|
|
120
|
-
|
|
151
|
+
interior.push(this.#applyBg("", innerWidth));
|
|
121
152
|
}
|
|
122
153
|
// Content
|
|
123
154
|
for (const lines of childLines) {
|
|
124
155
|
for (const line of lines) {
|
|
125
|
-
|
|
156
|
+
interior.push(this.#applyBg(leftPad + line, innerWidth));
|
|
126
157
|
}
|
|
127
158
|
}
|
|
128
159
|
// Bottom padding
|
|
129
160
|
for (let i = 0; i < this.#paddingY; i++) {
|
|
130
|
-
|
|
161
|
+
interior.push(this.#applyBg("", innerWidth));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (border) {
|
|
165
|
+
const paint = border.color ?? (s => s);
|
|
166
|
+
const rule = border.chars.horizontal.repeat(Math.max(0, innerWidth));
|
|
167
|
+
const side = paint(border.chars.vertical);
|
|
168
|
+
result.push(paint(border.chars.topLeft + rule + border.chars.topRight));
|
|
169
|
+
for (const row of interior) {
|
|
170
|
+
result.push(side + row + side);
|
|
171
|
+
}
|
|
172
|
+
result.push(paint(border.chars.bottomLeft + rule + border.chars.bottomRight));
|
|
173
|
+
} else {
|
|
174
|
+
for (const row of interior) {
|
|
175
|
+
result.push(row);
|
|
176
|
+
}
|
|
131
177
|
}
|
|
132
178
|
}
|
|
133
179
|
|
|
134
|
-
this.#cached = { width, bgSample, childLines, result };
|
|
180
|
+
this.#cached = { width, bgSample, borderSample, childLines, result };
|
|
135
181
|
return result;
|
|
136
182
|
}
|
|
137
183
|
|
|
@@ -222,6 +222,56 @@ markdownParser.setOptions({
|
|
|
222
222
|
// never math. Inline extensions run before marked's escape tokenizer, so
|
|
223
223
|
// `\(…\)` becomes math while a genuinely escaped `\$` is left to `escape` and
|
|
224
224
|
// renders as a literal dollar.
|
|
225
|
+
const CUSTOM_HR_START_REGEX = /(?:^|\n) {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/;
|
|
226
|
+
const CUSTOM_HR_TOKENIZER_REGEX = /^ {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/;
|
|
227
|
+
|
|
228
|
+
function getHrChar(char: string, hrChar: string): string {
|
|
229
|
+
const isAscii = hrChar === "-";
|
|
230
|
+
switch (char) {
|
|
231
|
+
case "=":
|
|
232
|
+
return "=";
|
|
233
|
+
case "═":
|
|
234
|
+
return isAscii ? "=" : "═";
|
|
235
|
+
case "━":
|
|
236
|
+
return isAscii ? "-" : "━";
|
|
237
|
+
case "─":
|
|
238
|
+
return isAscii ? "-" : "─";
|
|
239
|
+
case "–":
|
|
240
|
+
return isAscii ? "-" : "–";
|
|
241
|
+
case "—":
|
|
242
|
+
return isAscii ? "-" : "—";
|
|
243
|
+
default:
|
|
244
|
+
return hrChar;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const customHrExtension: TokenizerAndRendererExtension = {
|
|
249
|
+
name: "customHr",
|
|
250
|
+
level: "block",
|
|
251
|
+
start(src) {
|
|
252
|
+
const match = CUSTOM_HR_START_REGEX.exec(src);
|
|
253
|
+
if (!match) return undefined;
|
|
254
|
+
let idx = match.index;
|
|
255
|
+
if (src[idx] === "\n") {
|
|
256
|
+
idx += 1;
|
|
257
|
+
}
|
|
258
|
+
return idx;
|
|
259
|
+
},
|
|
260
|
+
tokenizer(src) {
|
|
261
|
+
const match = CUSTOM_HR_TOKENIZER_REGEX.exec(src);
|
|
262
|
+
if (match) {
|
|
263
|
+
return {
|
|
264
|
+
type: "hr",
|
|
265
|
+
raw: match[0],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return undefined;
|
|
269
|
+
},
|
|
270
|
+
renderer() {
|
|
271
|
+
return "";
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
|
|
225
275
|
const mathExtension: TokenizerAndRendererExtension = {
|
|
226
276
|
name: "math",
|
|
227
277
|
level: "inline",
|
|
@@ -332,7 +382,7 @@ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
|
|
|
332
382
|
return (token as { text?: string }).text ?? "";
|
|
333
383
|
},
|
|
334
384
|
};
|
|
335
|
-
markdownParser.use({ extensions: [mathBlockExtension, mathEnvBlockExtension, mathExtension] });
|
|
385
|
+
markdownParser.use({ extensions: [customHrExtension, mathBlockExtension, mathEnvBlockExtension, mathExtension] });
|
|
336
386
|
|
|
337
387
|
// ---------------------------------------------------------------------------
|
|
338
388
|
// Module-level LRU render cache
|
|
@@ -360,16 +410,14 @@ export function clearRenderCache(): void {
|
|
|
360
410
|
}
|
|
361
411
|
|
|
362
412
|
// Stable numeric IDs for structural theme/style objects (no ID field on type).
|
|
363
|
-
//
|
|
364
|
-
const
|
|
365
|
-
type WithObjectId = object & { [kObjectId]?: number };
|
|
413
|
+
// WeakMap-keyed so the ID matches strict object identity and doesn't get copied by spread/cloning.
|
|
414
|
+
const themeObjectIds = new WeakMap<object, number>();
|
|
366
415
|
let nextObjectId = 0;
|
|
367
416
|
function objectId(o: object): number {
|
|
368
|
-
|
|
369
|
-
let id = tagged[kObjectId];
|
|
417
|
+
let id = themeObjectIds.get(o);
|
|
370
418
|
if (id === undefined) {
|
|
371
419
|
id = nextObjectId++;
|
|
372
|
-
|
|
420
|
+
themeObjectIds.set(o, id);
|
|
373
421
|
}
|
|
374
422
|
return id;
|
|
375
423
|
}
|
|
@@ -1150,12 +1198,16 @@ export class Markdown implements Component {
|
|
|
1150
1198
|
break;
|
|
1151
1199
|
}
|
|
1152
1200
|
|
|
1153
|
-
case "hr":
|
|
1154
|
-
|
|
1201
|
+
case "hr": {
|
|
1202
|
+
const raw = "raw" in token && typeof token.raw === "string" ? token.raw.trim() : "";
|
|
1203
|
+
const char = raw[0] || "";
|
|
1204
|
+
const fillChar = getHrChar(char, this.#theme.symbols.hrChar);
|
|
1205
|
+
lines.push(this.#theme.hr(fillChar.repeat(Math.min(width, 80))));
|
|
1155
1206
|
if (nextTokenType && nextTokenType !== "space") {
|
|
1156
1207
|
lines.push(""); // Add spacing after horizontal rules (unless space token follows)
|
|
1157
1208
|
}
|
|
1158
1209
|
break;
|
|
1210
|
+
}
|
|
1159
1211
|
|
|
1160
1212
|
case "html":
|
|
1161
1213
|
if ("raw" in token && typeof token.raw === "string") {
|