diff --git a/src/components/PaneRow.tsx b/src/components/PaneRow.tsx index 07626b1..3d94243 100644 --- a/src/components/PaneRow.tsx +++ b/src/components/PaneRow.tsx @@ -1,18 +1,20 @@ /** * PaneRow — the shared parent | current | preview 3-pane layout primitive. * - * Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three bordered columns - * grow at 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list - * tab renders an identical, layout-stable shell. Columns use `flexBasis={0}` - * so the ratio is exact regardless of content width — a column's content can + * Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three columns grow at + * 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list tab + * renders an identical, layout-stable shell. Columns use `flexBasis={0}` so + * the ratio is exact regardless of content width — a column's content can * never stretch its slot. * * Column semantics (per the yazi depth model): * parent — the previous-depth list. Renders a muted `—` placeholder and * 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 - * carries the active-border focus ring when `focused` is truthy. - * preview — detail of the hovered item in `current`; always muted border. + * is the ONLY bordered column (full border, always muted — no + * active-border highlight). + * preview — detail of the hovered item in `current`. Borderless. * * The primitive is purely structural: callers pass their own JSX per column * (static elements or accessors) plus header labels. Theme colors are resolved @@ -33,7 +35,7 @@ import { createMemo, Show } 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 { PANE_RATIO } from "@/utils/navigation"; @@ -53,9 +55,9 @@ export type PaneRowProps = { parentLabel?: PaneLabel; currentLabel?: PaneLabel; previewLabel?: PaneLabel; - /** Whether the current column carries the active-border focus ring. Defaults to - * true; pass `false` (or a signal) when the row is inactive. Parent and - * preview columns always render muted borders. */ + /** Whether the current column's `` receives scroll focus. Defaults to + * true; pass `false` (or a signal) when the row is inactive. Does NOT change + * border colors — the current column's border is always muted. */ focused?: boolean | (() => boolean); /** Number of visible columns. `3` (default) = parent|current|preview; * `2` = parent|current (preview omitted, current grows to fill). */ @@ -96,16 +98,15 @@ function Pane(props: { grow: number; label: () => string; content: () => JSX.Element | undefined; - borderColor: () => RGBA; + border: boolean | BorderSides[]; scrollFocused: () => boolean; }) { const themeContext = useTheme(); const theme = themeContext.theme; const muted = () => theme.muted ?? theme.textMuted ?? theme.text; - // Memoize accessor results so the prop expressions below stay reactive - // when the underlying signals (e.g. `focused`) change. - const borderColor = createMemo(() => props.borderColor()); + // Memoize the scroll-focus accessor result so the prop expression below + // stays reactive when the underlying signal (e.g. `focused`) changes. const scrollFocused = createMemo(() => props.scrollFocused()); return ( @@ -131,8 +132,7 @@ function Pane(props: { { const f = props.focused; return typeof f === "function" ? f() : (f ?? true); @@ -181,15 +179,15 @@ export function PaneRow(props: PaneRowProps) { grow={PANE_RATIO.parent} label={parentLabel} content={parentContent} - borderColor={() => theme.border} + border={false} scrollFocused={() => false} /> - {/* ── current — the focused list; active-border ring when focused ──────────── */} + {/* ── current — the focused list; no border, no highlight ─────────── */} (focused() ? theme.borderActive : theme.border)} + border={false} scrollFocused={() => focused()} /> {/* ── preview (2/5) — hovered-item detail; always muted ────────────── */} @@ -198,7 +196,7 @@ export function PaneRow(props: PaneRowProps) { grow={PANE_RATIO.preview} label={previewLabel} content={previewContent} - borderColor={() => theme.border} + border={false} scrollFocused={() => false} /> diff --git a/tests/yazi-pane-row.test.tsx b/tests/yazi-pane-row.test.tsx index 92da54c..4018267 100644 --- a/tests/yazi-pane-row.test.tsx +++ b/tests/yazi-pane-row.test.tsx @@ -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 } | 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: () => ITEM, @@ -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: () => ITEM, @@ -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: () => ITEM, preview: null, }); cleanups.push(destroy); - const [parent, current] = columnBorders(spans); - expect(current).not.toBe(parent); + expect(frameText(spans)).not.toMatch(borderGlyphs); }); });