Remove borders and accent ring from PaneRow panes

Make parent|current|preview fully borderless: no scrollbox borders and no
accent border highlight on the current column. focused still gates
scroll-following but never surfaces a separator. Update tests to measure
column widths from the header-label row and assert no border glyphs render.
This commit is contained in:
2026-08-10 01:15:19 -04:00
parent d2f6c5c525
commit 12bd6be4bc
2 changed files with 74 additions and 89 deletions

View File

@@ -3,13 +3,13 @@
*
* Verified through the opentui test renderer's captured frames (the same
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
* observable as rendered column widths and border colors.
* observable as rendered column widths.
*
* • Unit: three columns render at 1:2:2 (e.g. 20/40/40 of 100) even when the
* parent and preview children are null, and the blank parent keeps its
* slot with a muted placeholder.
* • Integration: toggling `focused` moves the accent focus ring onto/off the
* current column; parent & preview borders stay muted either way.
* • Integration: the panes are fully borderless — `focused` toggles
* scroll-following but never surfaces a border or accent ring.
*
* Runs via `bun test`. The `[test] preload = "@opentui/solid/preload"` entry
* in bunfig.toml registers the solid JSX transform for the test runner, so
@@ -21,52 +21,37 @@ import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { PaneRow } from "../src/components/PaneRow";
type Span = { text: string; fg: { buffer: ArrayLike<number> } | null };
type Frame = { lines: { spans: Span[] }[] };
type Span = { text: string };
type Frame = { cols: number; lines: { spans: Span[] }[] };
// ── Frame introspection helpers ─────────────────────────────────────────────
function hexOf(fg: Span["fg"]): string | null {
if (!fg?.buffer) return null;
const b = fg.buffer;
if (b[3] === 0) return null;
return (
"#" +
[0, 1, 2]
.map((i) =>
Math.max(0, Math.min(255, Math.round(b[i] * 255)))
.toString(16)
.padStart(2, "0"),
)
.join("")
);
}
/** Column border colors, scanned from the top border row (`┌───┐…`). */
function columnBorders(spans: Frame): string[] {
const line = spans.lines[1];
if (!line) return [];
const out: string[] = [];
/** Column of the first span whose text contains `label` in the given line. */
function labelColumn(line: Frame["lines"][number], label: string): number {
let col = 0;
for (const sp of line.spans) {
for (const ch of sp.text) {
if (ch === "┌") out.push(hexOf(sp.fg) ?? "default");
}
if (sp.text.includes(label)) return col;
col += sp.text.length;
}
return out;
return -1;
}
/** Column widths (including borders), from the top border row. */
/**
* Column widths, measured from the header-label row (`Up|List|Detail`).
* Each label box has a 1-col left padding, so a column's left edge is the
* label start minus 1; the last column runs to the frame's right edge.
*/
function columnWidths(spans: Frame): number[] {
const line = spans.lines[1];
const line = spans.lines[0];
if (!line) return [];
const widths: number[] = [];
for (const sp of line.spans) {
for (const ch of sp.text) {
if (ch === "┌") widths.push(0);
else if (widths.length && ch === "─") widths[widths.length - 1]++;
else if (widths.length && ch === "┐") widths[widths.length - 1] += 2;
}
}
return widths;
const up = labelColumn(line, "Up");
const list = labelColumn(line, "List");
const detail = labelColumn(line, "Detail");
if (up < 0 || list < 0 || detail < 0) return [];
return [list - up, detail - list, spans.cols - detail + 1];
}
/** Entire frame as plain text — used to assert no border glyphs remain. */
function frameText(spans: Frame): string {
return spans.lines.map((l) => l.spans.map((s) => s.text).join("")).join("\n");
}
// Element children must be accessors (`() => JSX`): JSX elements are only
@@ -101,11 +86,18 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
),
{ width: props.width ?? 100, height: props.height ?? 8, useThread: false },
);
for (let i = 0; i < 6; i++) {
// ThemeProvider only mounts its children once the theme resolves (async
// palette/theme loading). Poll the header-label row until it renders, so
// the captured frame below is actually a mounted PaneRow.
let spans: Frame | null = null;
for (let i = 0; i < 40 && !spans; i++) {
await setup.renderOnce();
await new Promise((r) => setTimeout(r, 40));
const frame = setup.captureSpans() as unknown as Frame;
const head = frame.lines[0]?.spans.map((s) => s.text).join("") ?? "";
if (head.includes("Up")) spans = frame;
else await new Promise((r) => setTimeout(r, 100));
}
const spans = setup.captureSpans() as unknown as Frame;
if (!spans) throw new Error("PaneRow did not render before timeout");
return {
spans,
destroy: async () => {
@@ -138,7 +130,7 @@ describe("PaneRow layout", () => {
const widths = columnWidths(spans);
expect(widths).toHaveLength(3);
const [p, c, v] = widths;
// 100-wide row splits as 20 / 40 / 40 (1/5 : 2/5 : 2/5, borders included).
// 100-wide row splits as 20 / 40 / 40 (1/5 : 2/5 : 2/5).
expect(p).toBe(20);
expect(c).toBe(40);
expect(v).toBe(40);
@@ -171,9 +163,14 @@ describe("PaneRow layout", () => {
});
});
// ── Integration: focused toggles the accent ring on the current column ─────
describe("PaneRow focus ring", () => {
test("focused=true puts the accent border on current; parent/preview stay muted", async () => {
// ── Integration: the accent border was removed — no border or highlight ────
describe("PaneRow focus ring (borderless)", () => {
// The current column no longer carries a focus ring: whatever `focused`
// resolves to, no pane renders a border or an accent color. `focused`
// still gates scroll-following, but it must never surface a separator.
const borderGlyphs = /[┌┐└┘─│]/;
test("focused=true renders no borders and no accent ring", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
@@ -182,14 +179,10 @@ describe("PaneRow focus ring", () => {
});
cleanups.push(destroy);
const [parent, current, preview] = columnBorders(spans);
// parent & preview are muted; current is the (different) accent color.
expect(parent).toBe(preview);
expect(current).not.toBe(parent);
expect(current).not.toBe("default");
expect(frameText(spans)).not.toMatch(borderGlyphs);
});
test("focused=false mutes the current column (no accent ring anywhere)", async () => {
test("focused=false renders no borders and no accent ring", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
@@ -198,9 +191,7 @@ describe("PaneRow focus ring", () => {
});
cleanups.push(destroy);
const [parent, current, preview] = columnBorders(spans);
expect(current).toBe(parent);
expect(preview).toBe(parent);
expect(frameText(spans)).not.toMatch(borderGlyphs);
});
test("accepts an accessor for focused (reactive boolean)", async () => {
@@ -211,9 +202,7 @@ describe("PaneRow focus ring", () => {
focused: () => true,
});
cleanups.push(destroy);
const [parent, current] = columnBorders(spans);
expect(current).not.toBe(parent); // accessor resolves true → accent ring
expect(frameText(spans)).not.toMatch(borderGlyphs);
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
parent: null,
@@ -222,18 +211,16 @@ describe("PaneRow focus ring", () => {
focused: () => false,
});
cleanups.push(destroy2);
const [p2, c2] = columnBorders(spans2);
expect(c2).toBe(p2); // accessor resolves false → muted
expect(frameText(spans2)).not.toMatch(borderGlyphs);
});
test("defaults to focused (current column carries the accent ring)", async () => {
test("defaults to focused (still borderless, no accent ring)", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
preview: null,
});
cleanups.push(destroy);
const [parent, current] = columnBorders(spans);
expect(current).not.toBe(parent);
expect(frameText(spans)).not.toMatch(borderGlyphs);
});
});