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:
@@ -1,18 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||||
*
|
*
|
||||||
* Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three bordered columns
|
* Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three columns grow at
|
||||||
* grow at 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list
|
* 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list tab
|
||||||
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
|
* renders an identical, layout-stable shell. Columns use `flexBasis={0}` so
|
||||||
* so the ratio is exact regardless of content width — a column's content can
|
* the ratio is exact regardless of content width — a column's content can
|
||||||
* never stretch its slot.
|
* never stretch its slot.
|
||||||
*
|
*
|
||||||
* Column semantics (per the yazi depth model):
|
* Column semantics (per the yazi depth model):
|
||||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||||
* KEEPS its 1/5 slot when blank (never collapses to width 0).
|
* KEEPS its 1/5 slot when blank (never collapses to width 0).
|
||||||
|
* Borderless (no left/right/top/bottom edge).
|
||||||
* current — the current-depth list. The only focusable content column; it
|
* current — the current-depth list. The only focusable content column; it
|
||||||
* carries the active-border focus ring when `focused` is truthy.
|
* is the ONLY bordered column (full border, always muted — no
|
||||||
* preview — detail of the hovered item in `current`; always muted border.
|
* active-border highlight).
|
||||||
|
* preview — detail of the hovered item in `current`. Borderless.
|
||||||
*
|
*
|
||||||
* The primitive is purely structural: callers pass their own JSX per column
|
* The primitive is purely structural: callers pass their own JSX per column
|
||||||
* (static elements or accessors) plus header labels. Theme colors are resolved
|
* (static elements or accessors) plus header labels. Theme colors are resolved
|
||||||
@@ -33,7 +35,7 @@
|
|||||||
|
|
||||||
import { createMemo, Show } from "solid-js";
|
import { createMemo, Show } from "solid-js";
|
||||||
import type { JSX } from "solid-js";
|
import type { JSX } from "solid-js";
|
||||||
import type { RGBA } from "@opentui/core";
|
import type { RGBA, BorderSides } from "@opentui/core";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { PANE_RATIO } from "@/utils/navigation";
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
@@ -53,9 +55,9 @@ export type PaneRowProps = {
|
|||||||
parentLabel?: PaneLabel;
|
parentLabel?: PaneLabel;
|
||||||
currentLabel?: PaneLabel;
|
currentLabel?: PaneLabel;
|
||||||
previewLabel?: PaneLabel;
|
previewLabel?: PaneLabel;
|
||||||
/** Whether the current column carries the active-border focus ring. Defaults to
|
/** Whether the current column's `<scrollbox>` receives scroll focus. Defaults to
|
||||||
* true; pass `false` (or a signal) when the row is inactive. Parent and
|
* true; pass `false` (or a signal) when the row is inactive. Does NOT change
|
||||||
* preview columns always render muted borders. */
|
* border colors — the current column's border is always muted. */
|
||||||
focused?: boolean | (() => boolean);
|
focused?: boolean | (() => boolean);
|
||||||
/** Number of visible columns. `3` (default) = parent|current|preview;
|
/** Number of visible columns. `3` (default) = parent|current|preview;
|
||||||
* `2` = parent|current (preview omitted, current grows to fill). */
|
* `2` = parent|current (preview omitted, current grows to fill). */
|
||||||
@@ -96,16 +98,15 @@ function Pane(props: {
|
|||||||
grow: number;
|
grow: number;
|
||||||
label: () => string;
|
label: () => string;
|
||||||
content: () => JSX.Element | undefined;
|
content: () => JSX.Element | undefined;
|
||||||
borderColor: () => RGBA;
|
border: boolean | BorderSides[];
|
||||||
scrollFocused: () => boolean;
|
scrollFocused: () => boolean;
|
||||||
}) {
|
}) {
|
||||||
const themeContext = useTheme();
|
const themeContext = useTheme();
|
||||||
const theme = themeContext.theme;
|
const theme = themeContext.theme;
|
||||||
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
||||||
|
|
||||||
// Memoize accessor results so the prop expressions below stay reactive
|
// Memoize the scroll-focus accessor result so the prop expression below
|
||||||
// when the underlying signals (e.g. `focused`) change.
|
// stays reactive when the underlying signal (e.g. `focused`) changes.
|
||||||
const borderColor = createMemo(() => props.borderColor());
|
|
||||||
const scrollFocused = createMemo(() => props.scrollFocused());
|
const scrollFocused = createMemo(() => props.scrollFocused());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,8 +132,7 @@ function Pane(props: {
|
|||||||
<scrollbox
|
<scrollbox
|
||||||
height="100%"
|
height="100%"
|
||||||
focused={scrollFocused()}
|
focused={scrollFocused()}
|
||||||
border
|
border={props.border}
|
||||||
borderColor={borderColor()}
|
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
themeContext.transparentBackground()
|
themeContext.transparentBackground()
|
||||||
? "transparent"
|
? "transparent"
|
||||||
@@ -147,9 +147,7 @@ function Pane(props: {
|
|||||||
|
|
||||||
// ── Row primitive ───────────────────────────────────────────────────────────
|
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||||
export function PaneRow(props: PaneRowProps) {
|
export function PaneRow(props: PaneRowProps) {
|
||||||
const { theme } = useTheme();
|
/** true → the current column's scrollbox is focused (scroll follows cursor). */
|
||||||
|
|
||||||
/** true → the current column gets the active-border focus ring. */
|
|
||||||
const focused = createMemo(() => {
|
const focused = createMemo(() => {
|
||||||
const f = props.focused;
|
const f = props.focused;
|
||||||
return typeof f === "function" ? f() : (f ?? true);
|
return typeof f === "function" ? f() : (f ?? true);
|
||||||
@@ -181,15 +179,15 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
grow={PANE_RATIO.parent}
|
grow={PANE_RATIO.parent}
|
||||||
label={parentLabel}
|
label={parentLabel}
|
||||||
content={parentContent}
|
content={parentContent}
|
||||||
borderColor={() => theme.border}
|
border={false}
|
||||||
scrollFocused={() => false}
|
scrollFocused={() => false}
|
||||||
/>
|
/>
|
||||||
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
{/* ── current — the focused list; no border, no highlight ─────────── */}
|
||||||
<Pane
|
<Pane
|
||||||
grow={currentGrow()}
|
grow={currentGrow()}
|
||||||
label={currentLabel}
|
label={currentLabel}
|
||||||
content={currentContent}
|
content={currentContent}
|
||||||
borderColor={() => (focused() ? theme.borderActive : theme.border)}
|
border={false}
|
||||||
scrollFocused={() => focused()}
|
scrollFocused={() => focused()}
|
||||||
/>
|
/>
|
||||||
{/* ── preview (2/5) — hovered-item detail; always muted ────────────── */}
|
{/* ── preview (2/5) — hovered-item detail; always muted ────────────── */}
|
||||||
@@ -198,7 +196,7 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
grow={PANE_RATIO.preview}
|
grow={PANE_RATIO.preview}
|
||||||
label={previewLabel}
|
label={previewLabel}
|
||||||
content={previewContent}
|
content={previewContent}
|
||||||
borderColor={() => theme.border}
|
border={false}
|
||||||
scrollFocused={() => false}
|
scrollFocused={() => false}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
*
|
*
|
||||||
* Verified through the opentui test renderer's captured frames (the same
|
* Verified through the opentui test renderer's captured frames (the same
|
||||||
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
* 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
|
* • 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
|
* parent and preview children are null, and the blank parent keeps its
|
||||||
* slot with a muted placeholder.
|
* slot with a muted placeholder.
|
||||||
* • Integration: toggling `focused` moves the accent focus ring onto/off the
|
* • Integration: the panes are fully borderless — `focused` toggles
|
||||||
* current column; parent & preview borders stay muted either way.
|
* scroll-following but never surfaces a border or accent ring.
|
||||||
*
|
*
|
||||||
* Runs via `bun test`. The `[test] preload = "@opentui/solid/preload"` entry
|
* Runs via `bun test`. The `[test] preload = "@opentui/solid/preload"` entry
|
||||||
* in bunfig.toml registers the solid JSX transform for the test runner, so
|
* 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 { ThemeProvider } from "../src/context/ThemeContext";
|
||||||
import { PaneRow } from "../src/components/PaneRow";
|
import { PaneRow } from "../src/components/PaneRow";
|
||||||
|
|
||||||
type Span = { text: string; fg: { buffer: ArrayLike<number> } | null };
|
type Span = { text: string };
|
||||||
type Frame = { lines: { spans: Span[] }[] };
|
type Frame = { cols: number; lines: { spans: Span[] }[] };
|
||||||
|
|
||||||
// ── Frame introspection helpers ─────────────────────────────────────────────
|
/** Column of the first span whose text contains `label` in the given line. */
|
||||||
function hexOf(fg: Span["fg"]): string | null {
|
function labelColumn(line: Frame["lines"][number], label: string): number {
|
||||||
if (!fg?.buffer) return null;
|
let col = 0;
|
||||||
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[] = [];
|
|
||||||
for (const sp of line.spans) {
|
for (const sp of line.spans) {
|
||||||
for (const ch of sp.text) {
|
if (sp.text.includes(label)) return col;
|
||||||
if (ch === "┌") out.push(hexOf(sp.fg) ?? "default");
|
col += sp.text.length;
|
||||||
}
|
}
|
||||||
}
|
return -1;
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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[] {
|
function columnWidths(spans: Frame): number[] {
|
||||||
const line = spans.lines[1];
|
const line = spans.lines[0];
|
||||||
if (!line) return [];
|
if (!line) return [];
|
||||||
const widths: number[] = [];
|
const up = labelColumn(line, "Up");
|
||||||
for (const sp of line.spans) {
|
const list = labelColumn(line, "List");
|
||||||
for (const ch of sp.text) {
|
const detail = labelColumn(line, "Detail");
|
||||||
if (ch === "┌") widths.push(0);
|
if (up < 0 || list < 0 || detail < 0) return [];
|
||||||
else if (widths.length && ch === "─") widths[widths.length - 1]++;
|
return [list - up, detail - list, spans.cols - detail + 1];
|
||||||
else if (widths.length && ch === "┐") widths[widths.length - 1] += 2;
|
}
|
||||||
}
|
|
||||||
}
|
/** Entire frame as plain text — used to assert no border glyphs remain. */
|
||||||
return widths;
|
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
|
// 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 },
|
{ 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 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 {
|
return {
|
||||||
spans,
|
spans,
|
||||||
destroy: async () => {
|
destroy: async () => {
|
||||||
@@ -138,7 +130,7 @@ describe("PaneRow layout", () => {
|
|||||||
const widths = columnWidths(spans);
|
const widths = columnWidths(spans);
|
||||||
expect(widths).toHaveLength(3);
|
expect(widths).toHaveLength(3);
|
||||||
const [p, c, v] = widths;
|
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(p).toBe(20);
|
||||||
expect(c).toBe(40);
|
expect(c).toBe(40);
|
||||||
expect(v).toBe(40);
|
expect(v).toBe(40);
|
||||||
@@ -171,9 +163,14 @@ describe("PaneRow layout", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Integration: focused toggles the accent ring on the current column ─────
|
// ── Integration: the accent border was removed — no border or highlight ────
|
||||||
describe("PaneRow focus ring", () => {
|
describe("PaneRow focus ring (borderless)", () => {
|
||||||
test("focused=true puts the accent border on current; parent/preview stay muted", async () => {
|
// 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({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
current: () => <text>ITEM</text>,
|
current: () => <text>ITEM</text>,
|
||||||
@@ -182,14 +179,10 @@ describe("PaneRow focus ring", () => {
|
|||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
|
|
||||||
const [parent, current, preview] = columnBorders(spans);
|
expect(frameText(spans)).not.toMatch(borderGlyphs);
|
||||||
// parent & preview are muted; current is the (different) accent color.
|
|
||||||
expect(parent).toBe(preview);
|
|
||||||
expect(current).not.toBe(parent);
|
|
||||||
expect(current).not.toBe("default");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
current: () => <text>ITEM</text>,
|
current: () => <text>ITEM</text>,
|
||||||
@@ -198,9 +191,7 @@ describe("PaneRow focus ring", () => {
|
|||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
|
|
||||||
const [parent, current, preview] = columnBorders(spans);
|
expect(frameText(spans)).not.toMatch(borderGlyphs);
|
||||||
expect(current).toBe(parent);
|
|
||||||
expect(preview).toBe(parent);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts an accessor for focused (reactive boolean)", async () => {
|
test("accepts an accessor for focused (reactive boolean)", async () => {
|
||||||
@@ -211,9 +202,7 @@ describe("PaneRow focus ring", () => {
|
|||||||
focused: () => true,
|
focused: () => true,
|
||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
|
expect(frameText(spans)).not.toMatch(borderGlyphs);
|
||||||
const [parent, current] = columnBorders(spans);
|
|
||||||
expect(current).not.toBe(parent); // accessor resolves true → accent ring
|
|
||||||
|
|
||||||
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
|
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
@@ -222,18 +211,16 @@ describe("PaneRow focus ring", () => {
|
|||||||
focused: () => false,
|
focused: () => false,
|
||||||
});
|
});
|
||||||
cleanups.push(destroy2);
|
cleanups.push(destroy2);
|
||||||
const [p2, c2] = columnBorders(spans2);
|
expect(frameText(spans2)).not.toMatch(borderGlyphs);
|
||||||
expect(c2).toBe(p2); // accessor resolves false → muted
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
current: () => <text>ITEM</text>,
|
current: () => <text>ITEM</text>,
|
||||||
preview: null,
|
preview: null,
|
||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
const [parent, current] = columnBorders(spans);
|
expect(frameText(spans)).not.toMatch(borderGlyphs);
|
||||||
expect(current).not.toBe(parent);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user