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:
@@ -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
|
||||
===================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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("+");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user