From 5b667367a5b9c08ad920e3bf59cb16fd12f4ef92 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 31 Jul 2026 16:58:33 -0400 Subject: [PATCH] feat(layout): add YaziPaneRow 3-pane layout primitive Add the shared parent | current | preview row primitive that implements yazi's mgr.ratio = [1, 3, 3] contract via Yoga flexGrow, so every list tab renders an identical, layout-stable shell regardless of terminal size. - New YaziPaneRow component: three bordered columns grow at 1/7 : 3/7 : 3/7 with flexBasis=0 (content can never stretch its slot). The current column carries the accent focus ring when `focused` is truthy; parent and preview stay muted. The blank parent keeps its 1/7 slot with a muted placeholder rather than collapsing. - PANE_RATIO.current 4 -> 3 to match yazi's [1, 3, 3]. - Add render-based tests covering the 1:3:3 ratios (incl. null parent/preview) and the focused/unfocused accent ring behavior. - Configure the bun test preload for the solid JSX transform. --- bun.lockb | Bin 115544 -> 117904 bytes bunfig.test.toml | 2 + bunfig.toml | 3 + src/components/YaziPaneRow.tsx | 163 +++++++++++++++++++++++ src/utils/navigation.ts | 4 +- tests/yazi-pane-row.test.tsx | 234 +++++++++++++++++++++++++++++++++ 6 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 bunfig.test.toml create mode 100644 src/components/YaziPaneRow.tsx create mode 100644 tests/yazi-pane-row.test.tsx diff --git a/bun.lockb b/bun.lockb index 4ab240c46aef79c87c016f0b3cd883aa04c754c1..9f1835977daf6311eaa18d895c8c2f91f4edba88 100755 GIT binary patch delta 6685 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 NuTV>G5$nJ8{SU1sor?ef diff --git a/bunfig.test.toml b/bunfig.test.toml new file mode 100644 index 0000000..9f29ed8 --- /dev/null +++ b/bunfig.test.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./tests/preload/solid-test-plugin.ts"] diff --git a/bunfig.toml b/bunfig.toml index 7693482..fe44855 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1 +1,4 @@ preload = ["@opentui/solid/preload"] + +[test] +preload = "@opentui/solid/preload" diff --git a/src/components/YaziPaneRow.tsx b/src/components/YaziPaneRow.tsx new file mode 100644 index 0000000..8f33d0f --- /dev/null +++ b/src/components/YaziPaneRow.tsx @@ -0,0 +1,163 @@ +/** + * YaziPaneRow — the shared parent | current | preview 3-pane layout primitive. + * + * Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns + * grow at 1/7 : 3/7 : 3/7 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/7 slot when blank (never collapses to width 0). + * current — the current-depth list. The only focusable content column; it + * carries the accent focus ring when `focused` is truthy. + * preview — detail of the hovered item in `current`; always muted border. + * + * The primitive is purely structural: callers pass their own JSX per column + * (static elements or accessors) plus header labels. Theme colors are resolved + * internally via `useTheme()`. Only the current column's `` receives + * `focused`, so scroll focus follows the cursor (j/k stay in the current pane). + * + * Example: + * + */ + +import { children as solidChildren, createMemo, Show } from "solid-js"; +import type { JSX } from "solid-js"; +import type { RGBA } from "@opentui/core"; +import { useTheme } from "@/context/ThemeContext"; +import { PANE_RATIO } from "@/utils/navigation"; + +// ── Types ─────────────────────────────────────────────────────────────────── +type PaneContent = JSX.Element | (() => JSX.Element); +type PaneLabel = string | (() => string); + +export type YaziPaneRowProps = { + /** Parent column content (previous-depth list, or null for a muted + * placeholder — the 1/7 slot is always preserved). */ + parent?: PaneContent; + /** Current column content (the focused list). */ + current?: PaneContent; + /** Preview column content (detail of the hovered item). */ + preview?: PaneContent; + parentLabel?: PaneLabel; + currentLabel?: PaneLabel; + previewLabel?: PaneLabel; + /** Whether the current column carries the accent focus ring. Defaults to + * true; pass `false` (or a signal) when the row is inactive. Parent and + * preview columns always render muted borders. */ + focused?: boolean | (() => boolean); +}; + +// ── Helpers ───────────────────────────────────────────────────────────────── +function resolveLabel(v: PaneLabel | undefined): string { + if (v == null) return ""; + return typeof v === "function" ? v() : v; +} + +function Placeholder(props: { color: () => RGBA }) { + return ( + + + + ); +} + +// ── Pane column ───────────────────────────────────────────────────────────── +function YaziPane(props: { + grow: number; + label: () => string; + content: () => JSX.Element | undefined; + borderColor: () => RGBA; + scrollFocused: () => boolean; +}) { + const { theme } = useTheme(); + 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()); + const scrollFocused = createMemo(() => props.scrollFocused()); + + return ( + + {/* ── slim header label row ─────────────────────────────────────────── */} + + {props.label()} + + {/* ── bordered scrollbox ────────────────────────────────────────────── */} + + } + > + {props.content()} + + + + ); +} + +// ── Row primitive ─────────────────────────────────────────────────────────── +export function YaziPaneRow(props: YaziPaneRowProps) { + const { theme } = useTheme(); + + /** true → the current column gets the accent focus ring. */ + const focused = createMemo(() => { + const f = props.focused; + return typeof f === "function" ? f() : f ?? true; + }); + + // Normalize static JSX and accessor children into reactive accessors. + const parentContent = solidChildren(() => props.parent); + const currentContent = solidChildren(() => props.current); + const previewContent = solidChildren(() => props.preview); + + const parentLabel = createMemo(() => resolveLabel(props.parentLabel)); + const currentLabel = createMemo(() => resolveLabel(props.currentLabel)); + const previewLabel = createMemo(() => resolveLabel(props.previewLabel)); + + return ( + + {/* ── parent (1/7) — previous-depth list; always muted ─────────────── */} + theme.border} + scrollFocused={() => false} + /> + {/* ── current (3/7) — the focused list; accent ring when focused ───── */} + (focused() ? theme.accent : theme.border)} + scrollFocused={() => focused()} + /> + {/* ── preview (3/7) — hovered-item detail; always muted ────────────── */} + theme.border} + scrollFocused={() => false} + /> + + ); +} diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts index f1ec5d8..d1610ab 100644 --- a/src/utils/navigation.ts +++ b/src/utils/navigation.ts @@ -66,13 +66,13 @@ export const LayerDepths = { [TABS.SETTINGS]: SettingsPaneCount, }; -// Yazi-style pane grow ratios (parent : current : preview) ≈ [1, 4, 3]. +// Yazi-style pane grow ratios (parent : current : preview) = [1, 3, 3]. // Panes use flexGrow (Yoga) so columns always sum to the row width regardless // of terminal size — more robust than fixed percentages and exactly mirrors // yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs). export const PANE_RATIO = { parent: 1, - current: 4, + current: 3, preview: 3, } as const; diff --git a/tests/yazi-pane-row.test.tsx b/tests/yazi-pane-row.test.tsx new file mode 100644 index 0000000..d7d0b88 --- /dev/null +++ b/tests/yazi-pane-row.test.tsx @@ -0,0 +1,234 @@ +/** + * YaziPaneRow tests — the 1:3:3 parent|current|preview layout primitive. + * + * 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. + * + * • Unit: three columns render at 1:3:3 (e.g. 14/43/43 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. + * + * Runs via `bun test`. The `[test] preload = "@opentui/solid/preload"` entry + * in bunfig.toml registers the solid JSX transform for the test runner, so + * JSX in this file compiles exactly like app code. + */ + +import { describe, test, expect, afterAll } from "bun:test"; +import { testRender } from "@opentui/solid"; +import { ThemeProvider } from "../src/context/ThemeContext"; +import { YaziPaneRow } from "../src/components/YaziPaneRow"; + +type Span = { text: string; fg: { buffer: ArrayLike } | null }; +type Frame = { 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[] = []; + for (const sp of line.spans) { + for (const ch of sp.text) { + if (ch === "┌") out.push(hexOf(sp.fg) ?? "default"); + } + } + return out; +} + +/** Column widths (including borders), from the top border row. */ +function columnWidths(spans: Frame): number[] { + const line = spans.lines[1]; + 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; +} + +// Element children must be accessors (`() => JSX`): JSX elements are only +// constructed inside the renderer context (during the test render pass), so +// creating them eagerly in the test body would throw "No renderer found". +type TestPaneProps = { + parent?: unknown; + current?: (() => unknown) | unknown; + preview?: unknown; + focused?: unknown; + width?: number; + height?: number; +}; + +async function renderPaneRow(props: TestPaneProps): Promise<{ + spans: Frame; + destroy: () => Promise; +}> { + const setup = await testRender( + () => ( + + + + ), + { width: props.width ?? 100, height: props.height ?? 8, useThread: false }, + ); + for (let i = 0; i < 6; i++) { + await setup.renderOnce(); + await new Promise((r) => setTimeout(r, 40)); + } + const spans = setup.captureSpans() as unknown as Frame; + return { spans, destroy: () => setup.renderer.destroy() }; +} + +const cleanups: (() => void | Promise)[] = []; +afterAll(async () => { + for (const c of cleanups) { + try { + await c(); + } catch { + // renderer already torn down — ignore + } + } +}); + +// ── Unit: three columns at 1:3:3 regardless of null children ─────────────── +describe("YaziPaneRow layout", () => { + test("renders three columns at 1:3:3 even with null parent/preview", async () => { + const { spans, destroy } = await renderPaneRow({ + parent: null, + current: () => ITEM, + preview: null, + }); + cleanups.push(destroy); + + const widths = columnWidths(spans); + expect(widths).toHaveLength(3); + const [p, c, v] = widths; + // 100-wide row splits as 14 / 43 / 43 (1/7 : 3/7 : 3/7, borders included). + expect(p).toBe(14); + expect(c).toBe(43); + expect(v).toBe(43); + // Exact 1:3:3 proportion (within 1 col rounding). + expect(c).toBeGreaterThanOrEqual(p * 3 - 1); + expect(c).toBeLessThanOrEqual(p * 3 + 1); + expect(v).toBeGreaterThanOrEqual(p * 3 - 1); + expect(v).toBeLessThanOrEqual(p * 3 + 1); + // Parent keeps a visibly non-zero slot and renders the muted placeholder. + expect(p).toBeGreaterThan(4); + const body = spans.lines + .map((l) => l.spans.map((s) => s.text).join("")) + .join("\n"); + expect(body).toContain("—"); + expect(body).toContain("ITEM"); + }); + + test("keeps the 1/7 parent slot across widths (ratio stable)", async () => { + const { spans, destroy } = await renderPaneRow({ + parent: null, + current: () => x, + preview: null, + width: 70, + }); + cleanups.push(destroy); + const [p, c, v] = columnWidths(spans); + expect(p).toBe(10); // 70 → 10 / 30 / 30 + expect(c).toBe(30); + expect(v).toBe(30); + }); +}); + +// ── Integration: focused toggles the accent ring on the current column ───── +describe("YaziPaneRow focus ring", () => { + test("focused=true puts the accent border on current; parent/preview stay muted", async () => { + const { spans, destroy } = await renderPaneRow({ + parent: null, + current: () => ITEM, + preview: null, + focused: true, + }); + 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"); + }); + + test("focused=false mutes the current column (no accent ring anywhere)", async () => { + const { spans, destroy } = await renderPaneRow({ + parent: null, + current: () => ITEM, + preview: null, + focused: false, + }); + cleanups.push(destroy); + + const [parent, current, preview] = columnBorders(spans); + expect(current).toBe(parent); + expect(preview).toBe(parent); + }); + + test("accepts an accessor for focused (reactive boolean)", async () => { + const { spans, destroy } = await renderPaneRow({ + parent: null, + current: () => ITEM, + preview: null, + focused: () => true, + }); + cleanups.push(destroy); + + const [parent, current] = columnBorders(spans); + expect(current).not.toBe(parent); // accessor resolves true → accent ring + + const { spans: spans2, destroy: destroy2 } = await renderPaneRow({ + parent: null, + current: () => ITEM, + preview: null, + focused: () => false, + }); + cleanups.push(destroy2); + const [p2, c2] = columnBorders(spans2); + expect(c2).toBe(p2); // accessor resolves false → muted + }); + + test("defaults to focused (current column carries the 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); + }); +});