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.
This commit is contained in:
2
bunfig.test.toml
Normal file
2
bunfig.test.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./tests/preload/solid-test-plugin.ts"]
|
||||||
@@ -1 +1,4 @@
|
|||||||
preload = ["@opentui/solid/preload"]
|
preload = ["@opentui/solid/preload"]
|
||||||
|
|
||||||
|
[test]
|
||||||
|
preload = "@opentui/solid/preload"
|
||||||
|
|||||||
163
src/components/YaziPaneRow.tsx
Normal file
163
src/components/YaziPaneRow.tsx
Normal file
@@ -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 `<scrollbox>` receives
|
||||||
|
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* <YaziPaneRow
|
||||||
|
* parent={parentList}
|
||||||
|
* current={currentList}
|
||||||
|
* preview={detail}
|
||||||
|
* parentLabel="Up"
|
||||||
|
* currentLabel="List · 42"
|
||||||
|
* previewLabel="Detail"
|
||||||
|
* focused={isActive}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={props.color()}>—</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<box flexDirection="column" flexGrow={props.grow} flexBasis={0} height="100%">
|
||||||
|
{/* ── slim header label row ─────────────────────────────────────────── */}
|
||||||
|
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||||
|
<text fg={theme.textSecondary}>{props.label()}</text>
|
||||||
|
</box>
|
||||||
|
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
|
||||||
|
<scrollbox
|
||||||
|
height="100%"
|
||||||
|
focused={scrollFocused()}
|
||||||
|
border
|
||||||
|
borderColor={borderColor()}
|
||||||
|
backgroundColor={theme.background}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.content()}
|
||||||
|
fallback={<Placeholder color={muted} />}
|
||||||
|
>
|
||||||
|
{props.content()}
|
||||||
|
</Show>
|
||||||
|
</scrollbox>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
|
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
|
||||||
|
<YaziPane
|
||||||
|
grow={PANE_RATIO.parent}
|
||||||
|
label={parentLabel}
|
||||||
|
content={parentContent}
|
||||||
|
borderColor={() => theme.border}
|
||||||
|
scrollFocused={() => false}
|
||||||
|
/>
|
||||||
|
{/* ── current (3/7) — the focused list; accent ring when focused ───── */}
|
||||||
|
<YaziPane
|
||||||
|
grow={PANE_RATIO.current}
|
||||||
|
label={currentLabel}
|
||||||
|
content={currentContent}
|
||||||
|
borderColor={() => (focused() ? theme.accent : theme.border)}
|
||||||
|
scrollFocused={() => focused()}
|
||||||
|
/>
|
||||||
|
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
||||||
|
<YaziPane
|
||||||
|
grow={PANE_RATIO.preview}
|
||||||
|
label={previewLabel}
|
||||||
|
content={previewContent}
|
||||||
|
borderColor={() => theme.border}
|
||||||
|
scrollFocused={() => false}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,13 +66,13 @@ export const LayerDepths = {
|
|||||||
[TABS.SETTINGS]: SettingsPaneCount,
|
[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
|
// Panes use flexGrow (Yoga) so columns always sum to the row width regardless
|
||||||
// of terminal size — more robust than fixed percentages and exactly mirrors
|
// 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).
|
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
||||||
export const PANE_RATIO = {
|
export const PANE_RATIO = {
|
||||||
parent: 1,
|
parent: 1,
|
||||||
current: 4,
|
current: 3,
|
||||||
preview: 3,
|
preview: 3,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
234
tests/yazi-pane-row.test.tsx
Normal file
234
tests/yazi-pane-row.test.tsx
Normal file
@@ -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<number> } | 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<void>;
|
||||||
|
}> {
|
||||||
|
const setup = await testRender(
|
||||||
|
() => (
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<YaziPaneRow
|
||||||
|
parent={props.parent as any}
|
||||||
|
current={props.current as any}
|
||||||
|
preview={props.preview as any}
|
||||||
|
parentLabel="Up"
|
||||||
|
currentLabel="List"
|
||||||
|
previewLabel="Detail"
|
||||||
|
focused={props.focused as any}
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
|
),
|
||||||
|
{ 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<void>)[] = [];
|
||||||
|
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: () => <text>ITEM</text>,
|
||||||
|
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: () => <text>x</text>,
|
||||||
|
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: () => <text>ITEM</text>,
|
||||||
|
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: () => <text>ITEM</text>,
|
||||||
|
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: () => <text>ITEM</text>,
|
||||||
|
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: () => <text>ITEM</text>,
|
||||||
|
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: () => <text>ITEM</text>,
|
||||||
|
preview: null,
|
||||||
|
});
|
||||||
|
cleanups.push(destroy);
|
||||||
|
const [parent, current] = columnBorders(spans);
|
||||||
|
expect(current).not.toBe(parent);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user