diff --git a/bun.lockb b/bun.lockb index 4ab240c..9f18359 100755 Binary files a/bun.lockb and b/bun.lockb differ 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); + }); +});