@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 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.0.11",
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.0.11",
41
- "@oh-my-pi/pi-utils": "16.0.11",
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
  },
@@ -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
- const contentWidth = Math.max(1, width - paddingX * 2);
91
- // bgFn output can change without the function reference changing (theme
92
- // mutation); sample it so a silent palette swap still misses the cache.
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
- result.push(this.#applyBg("", width));
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
- result.push(this.#applyBg(leftPad + line, width));
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
- result.push(this.#applyBg("", width));
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
- // Symbol-keyed so the id travels with the object and is invisible to consumers.
364
- const kObjectId = Symbol("markdown.objectId");
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
- const tagged = o as WithObjectId;
369
- let id = tagged[kObjectId];
417
+ let id = themeObjectIds.get(o);
370
418
  if (id === undefined) {
371
419
  id = nextObjectId++;
372
- tagged[kObjectId] = id;
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
- lines.push(this.#theme.hr(this.#theme.symbols.hrChar.repeat(Math.min(width, 80))));
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") {