feat(tui): added configurable box border support

- Added `BoxBorder` interface and optional border support to the `Box` component.
- Implemented `setBorder()` to allow dynamic toggling of box outlines.
- Added intelligent border dropping logic to prevent overflows in narrow containers.
- Added comprehensive unit tests for rendering, styling, and boundary conditions.
This commit is contained in:
can1357
2026-06-19 07:39:02 +02:00
parent b137764f0c
commit afef9fc68a
4 changed files with 135 additions and 12 deletions
@@ -15,10 +15,10 @@ Reading HISTORY:
{{/if}}{{/if}}{{#if includedPreviousSummary}}- HISTORY opens with a condensed digest of still-older context that predates the archived turns.
{{/if}}{{#if truncatedChars}}- About {{truncatedChars}} characters of older middle history were dropped to fit the archive budget.
{{/if}}- When an exact earlier detail matters and a section reads unclearly, re-derive it from the workspace (re-read files, re-run commands) rather than guessing.
{{#if files}}
FILES
{{#if files}}FILES
===================
{{files}}
{{/if}}
HISTORY
{{/if}}HISTORY
===================
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- `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.
## [16.0.11] - 2026-06-19
### Breaking Changes
+54 -8
View File
@@ -4,10 +4,24 @@ import { applyBackgroundToLine, getPaddingX, padding, visibleWidth } from "../ut
type Cache = {
width: number;
bgSample: string | undefined;
borderSample: string | undefined;
childLines: (readonly string[])[];
result: string[];
};
/** Box-drawing glyphs plus an optional colorizer for an outline drawn around a {@link Box}. */
export interface BoxBorder {
chars: {
topLeft: string;
topRight: string;
bottomLeft: string;
bottomRight: string;
horizontal: string;
vertical: string;
};
color?: (text: string) => string;
}
/**
* Box component - a container that applies padding and background to all children
*/
@@ -16,6 +30,7 @@ export class Box implements Component {
#paddingX: number;
#paddingY: number;
#bgFn?: (text: string) => string;
#border?: BoxBorder;
#ignoreTight = false;
@@ -28,10 +43,11 @@ export class Box implements Component {
// Cache for rendered output
#cached?: Cache;
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string, border?: BoxBorder) {
this.#paddingX = paddingX;
this.#paddingY = paddingY;
this.#bgFn = bgFn;
this.#border = border;
}
addChild(component: Component): void {
@@ -72,6 +88,11 @@ export class Box implements Component {
// Don't invalidate here - we'll detect bgFn changes by sampling output
}
setBorder(border?: BoxBorder): void {
this.#border = border;
this.#invalidateCache();
}
#invalidateCache(): void {
this.#cached = undefined;
}
@@ -87,10 +108,18 @@ export class Box implements Component {
const children = this.children;
const count = children.length;
const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
const contentWidth = Math.max(1, width - paddingX * 2);
// bgFn output can change without the function reference changing (theme
// mutation); sample it so a silent palette swap still misses the cache.
// A border eats one column on each side; skip it unless the interior can still
// hold the horizontal padding plus at least one content column, so a bordered
// Box never overflows the width it was given.
const border = this.#border && width - 2 >= paddingX * 2 + 1 ? this.#border : undefined;
const innerWidth = border ? width - 2 : width;
const contentWidth = Math.max(1, innerWidth - paddingX * 2);
// bgFn / border output can change without the function reference changing
// (theme mutation); sample both so a silent palette swap still misses the cache.
const bgSample = this.#bgFn ? this.#bgFn("test") : undefined;
const borderSample = border
? `${border.color ? border.color("|") : "|"}${border.chars.topLeft}${border.chars.vertical}`
: undefined;
// Render every child every frame (renders may carry side effects); the
// memo only skips re-deriving the padded/background rows. Per the
@@ -101,6 +130,7 @@ export class Box implements Component {
cached !== undefined &&
cached.width === width &&
cached.bgSample === bgSample &&
cached.borderSample === borderSample &&
cached.childLines.length === count;
const childLines: (readonly string[])[] = new Array(count);
let contentRows = 0;
@@ -115,23 +145,39 @@ export class Box implements Component {
const result: string[] = [];
if (contentRows > 0) {
const leftPad = padding(paddingX);
const interior: string[] = [];
// Top padding
for (let i = 0; i < this.#paddingY; i++) {
result.push(this.#applyBg("", width));
interior.push(this.#applyBg("", innerWidth));
}
// Content
for (const lines of childLines) {
for (const line of lines) {
result.push(this.#applyBg(leftPad + line, width));
interior.push(this.#applyBg(leftPad + line, innerWidth));
}
}
// Bottom padding
for (let i = 0; i < this.#paddingY; i++) {
result.push(this.#applyBg("", width));
interior.push(this.#applyBg("", innerWidth));
}
if (border) {
const paint = border.color ?? (s => s);
const rule = border.chars.horizontal.repeat(Math.max(0, innerWidth));
const side = paint(border.chars.vertical);
result.push(paint(border.chars.topLeft + rule + border.chars.topRight));
for (const row of interior) {
result.push(side + row + side);
}
result.push(paint(border.chars.bottomLeft + rule + border.chars.bottomRight));
} else {
for (const row of interior) {
result.push(row);
}
}
}
this.#cached = { width, bgSample, childLines, result };
this.#cached = { width, bgSample, borderSample, childLines, result };
return result;
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "bun:test";
import { Box, type BoxBorder, Text } from "@oh-my-pi/pi-tui";
const CHARS: BoxBorder["chars"] = {
topLeft: "+",
topRight: "+",
bottomLeft: "+",
bottomRight: "+",
horizontal: "-",
vertical: "|",
};
function borderedBox(border?: Partial<BoxBorder>): Box {
// paddingY 0 keeps the row count predictable; ignoreTight pins paddingX to exactly 1.
// Real SGR escapes so Bun.stripANSI removes both bg and border color before width checks.
const box = new Box(1, 0, t => `\x1b[48;5;236m${t}\x1b[49m`, { chars: CHARS, color: t => `\x1b[31m${t}\x1b[39m`, ...border });
box.setIgnoreTight(true);
box.addChild(new Text("hi", 0, 0));
return box;
}
const widths = (rows: readonly string[]): number[] => rows.map(r => Bun.stringWidth(Bun.stripANSI(r)));
const plain = (rows: readonly string[]): string[] => rows.map(r => Bun.stripANSI(r));
describe("Box border", () => {
it("frames content without exceeding the given width", () => {
const rows = borderedBox().render(20);
// top rule + single content row + bottom rule
expect(rows.length).toBe(3);
for (const w of widths(rows)) expect(w).toBe(20);
const flat = plain(rows);
expect(flat[0]).toBe(`+${"-".repeat(18)}+`);
expect(flat[2]).toBe(`+${"-".repeat(18)}+`);
// Interior rows are wrapped by the vertical glyph on both edges.
expect(flat[1]!.startsWith("|")).toBe(true);
expect(flat[1]!.endsWith("|")).toBe(true);
expect(flat[1]).toContain("hi");
});
it("paints border glyphs with the supplied colorizer", () => {
const rows = borderedBox().render(20);
// The top rule is emitted through the color fn (red SGR), the interior is not.
expect(rows[0]).toContain("\x1b[31m");
});
it("recomputes width when the border is toggled off", () => {
const box = borderedBox();
expect(widths(box.render(20))).toEqual([20, 20, 20]);
box.setBorder(undefined);
const rows = box.render(20);
// No border rows now; the single content row fills the full width.
expect(rows.length).toBe(1);
expect(Bun.stringWidth(Bun.stripANSI(rows[0]!))).toBe(20);
expect(Bun.stripANSI(rows[0]!)).not.toContain("+");
});
it("frames at the boundary width without overflowing", () => {
// paddingX 1 → the border needs width >= 5 (2 borders + 2 padding + 1 content col).
const rows = borderedBox().render(5);
for (const w of widths(rows)) expect(w).toBe(5);
expect(plain(rows)[0]).toBe(`+${"-".repeat(3)}+`);
});
it("drops the border when the interior can't fit padding + content", () => {
for (const width of [3, 4]) {
const rows = borderedBox().render(width);
for (const w of widths(rows)) expect(w).toBeLessThanOrEqual(width);
for (const line of plain(rows)) expect(line).not.toContain("+");
}
});
});