From 491a736c322d5f0c724b0ff9752502b30617fa91 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 10 Aug 2026 09:00:24 -0400 Subject: [PATCH] Restore center-pane borders, move title to top-left slot - Current pane gets muted left/right borders only (no full box, no accent ring); border colors are passed only when a border is requested, since opentui flips borderless boxes to bordered when borderColor is supplied. - Remove the Up / / Detail titles above the panes; the current pane's title now renders once, top-left in the parent column's header slot. - Drop the parentLabel/previewLabel props from PaneRow and all callers. - Remove the tab/depth indicator from the bottom-left of the status bar. - Tests measure column widths from the border glyphs and assert the left/right edges render muted regardless of focus. --- src/components/PaneRow.tsx | 76 +++++++++++++++------------ src/components/Shell.tsx | 22 +------- src/pages/Discover/DiscoverPage.tsx | 2 - src/pages/Feed/FeedPage.tsx | 2 - src/pages/MyShows/MyShowsPage.tsx | 2 - src/pages/Player/PlayerPage.tsx | 1 - src/pages/Search/SearchPage.tsx | 2 - src/pages/Settings/SettingsPage.tsx | 8 --- tests/yazi-pane-row.test.tsx | 81 ++++++++++++++++------------- 9 files changed, 89 insertions(+), 107 deletions(-) diff --git a/src/components/PaneRow.tsx b/src/components/PaneRow.tsx index 3d94243..16158bd 100644 --- a/src/components/PaneRow.tsx +++ b/src/components/PaneRow.tsx @@ -10,25 +10,26 @@ * 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). + * Borderless (no left/right/top/bottom edge). Carries the single + * header row: the CURRENT column's title renders top-left in the + * parent's slot (the panes above current/preview were removed). * current — the current-depth list. The only focusable content column; it - * is the ONLY bordered column (full border, always muted — no - * active-border highlight). - * preview — detail of the hovered item in `current`. Borderless. + * is the ONLY bordered column — left/right edges only, always + * muted (no active-border highlight, focused or not). + * preview — detail of the hovered item in `current`. Borderless, no header. * * 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). + * (static elements or accessors) plus the current-column title. 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: * */ @@ -52,9 +53,9 @@ export type PaneRowProps = { /** Preview column content (detail of the hovered item). Omit/undefined * together with `panes={2}` to render a 2-pane parent|current row. */ preview?: PaneContent; - parentLabel?: PaneLabel; + /** Title of the current column — rendered once, top-left in the parent + * pane's header slot (the per-pane Up/Detail headers are gone). */ currentLabel?: PaneLabel; - previewLabel?: PaneLabel; /** 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. */ @@ -116,23 +117,32 @@ function Pane(props: { flexBasis={0} height="100%" > - {/* ── slim header label row ─────────────────────────────────────────── */} - - {props.label()} - - {/* ── bordered scrollbox ────────────────────────────────────────────── */} + {/* ── title row: rendered only when the pane carries a label ────────── */} + + + {props.label()} + + + {/* ── scrollbox; border always muted (focused or not) ──────────────── */} resolveLabel(props.parentLabel)); + // The single title: the CURRENT column's label, rendered in the parent + // pane's header slot (top-left). Current/preview panes have no headers. const currentLabel = createMemo(() => resolveLabel(props.currentLabel)); - const previewLabel = createMemo(() => resolveLabel(props.previewLabel)); // 2-pane mode (parent|current) grows the current column to fill the // preview slot. Defaults to 3 (parent|current|preview). @@ -174,27 +184,27 @@ export function PaneRow(props: PaneRowProps) { return ( - {/* ── parent (1/5) — previous-depth list; always muted ─────────────── */} + {/* ── parent (1/5) — previous-depth list; title row top-left ───────── */} false} /> - {/* ── current — the focused list; no border, no highlight ─────────── */} + {/* ── current — the focused list; left/right borders only ─────────── */} ""} content={currentContent} - border={false} + border={["left", "right"]} scrollFocused={() => focused()} /> - {/* ── preview (2/5) — hovered-item detail; always muted ────────────── */} + {/* ── preview (2/5) — hovered-item detail; no border, no header ────── */} ""} content={previewContent} border={false} scrollFocused={() => false} diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 23608dc..92b4605 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -23,20 +23,11 @@ import { useAppStore } from "@/stores/app"; import { useToast } from "@/ui/toast"; import { emit, on } from "@/utils/event-bus"; import { LayerGraph } from "@/utils/layer-graph"; -import { TABS, TabPaneCount } from "@/utils/navigation"; +import { TABS } from "@/utils/navigation"; import { createDispatcher } from "@/utils/dispatch"; import { TabListPane } from "@/components/TabPanel"; import { PaneRow } from "@/components/PaneRow"; -const TAB_LABEL: Record = { - [TABS.FEED]: "Feed", - [TABS.MYSHOWS]: "My Shows", - [TABS.DISCOVER]: "Discover", - [TABS.SEARCH]: "Search", - [TABS.PLAYER]: "Player", - [TABS.SETTINGS]: "Settings", -}; - export function Shell() { const theme = useTheme(); const t = theme.theme; @@ -271,9 +262,7 @@ export function Shell() { j/k move · l/Enter open a tab } - parentLabel="Up" currentLabel="Tabs" - previewLabel="" focused /> @@ -296,15 +285,6 @@ export function Shell() { {modeLabel()} - - {nav.atRootTab() - ? "Tabs · root" - : `${TAB_LABEL[nav.activeTab()]} · ${ - nav.isDepthTab() - ? `depth ${nav.currentDepth()}` - : `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}` - }`} - 0}> ● {nav.selectedIds().length} diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index 26da6a1..b0fea76 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -376,9 +376,7 @@ function DiscoverPage() { parent={parentContent} current={currentContent} preview={previewContent} - parentLabel={() => (depth() >= 1 ? "Categories" : "Up")} currentLabel={currentLabel} - previewLabel="Detail" focused={isActive} /> ); diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index 4b6a112..614c3fe 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -301,9 +301,7 @@ function FeedPage() { parent={parentContent} current={currentContent} preview={previewContent} - parentLabel="Up" currentLabel={currentLabel} - previewLabel="Detail" focused={isActive} /> ); diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index a89de4e..2790cb4 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -432,9 +432,7 @@ export function MyShowsPage() { parent={parentContent} current={currentContent} preview={previewContent} - parentLabel={() => (depth() >= 1 ? "Shows" : "Up")} currentLabel={currentLabel} - previewLabel="Detail" focused={isActive} /> ); diff --git a/src/pages/Player/PlayerPage.tsx b/src/pages/Player/PlayerPage.tsx index a51c409..d5f51b4 100644 --- a/src/pages/Player/PlayerPage.tsx +++ b/src/pages/Player/PlayerPage.tsx @@ -119,7 +119,6 @@ export function PlayerPage() { (depth() >= 1 ? "Query" : "Up")} currentLabel={currentLabel} - previewLabel="Detail" focused={isActive} /> ); diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 4a529f9..741638b 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -267,12 +267,6 @@ export function SettingsPage() { if (d === 1) return sectionForDepth1()?.label ?? "Items"; return editorItem()?.label ?? "Editor"; }; - const parentLabel = () => { - const d = depth(); - if (d === 1) return "Sections"; - if (d === 2) return sectionForDepth1()?.label ?? ""; - return "Up"; - }; // ── parent pane: previous-depth list (blank at depth 0) ──────────────── // Sibling blocks per depth (mirrors the preview pane) so Solid @@ -384,9 +378,7 @@ export function SettingsPage() { parent={parentContent} current={currentContent} preview={previewContent} - parentLabel={parentLabel} currentLabel={currentLabel} - previewLabel="Detail" focused={isActive} /> ); diff --git a/tests/yazi-pane-row.test.tsx b/tests/yazi-pane-row.test.tsx index 4018267..db8d52d 100644 --- a/tests/yazi-pane-row.test.tsx +++ b/tests/yazi-pane-row.test.tsx @@ -8,8 +8,9 @@ * • 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: the panes are fully borderless — `focused` toggles - * scroll-following but never surfaces a border or accent ring. + * • Integration: the current pane renders muted left/right border edges + * only (no full box, no accent ring) — `focused` toggles scroll-following + * but never changes the border; parent and preview stay borderless. * * Runs via `bun test`. The `[test] preload = "@opentui/solid/preload"` entry * in bunfig.toml registers the solid JSX transform for the test runner, so @@ -24,29 +25,32 @@ import { PaneRow } from "../src/components/PaneRow"; type Span = { text: string }; type Frame = { cols: number; lines: { spans: Span[] }[] }; -/** Column of the first span whose text contains `label` in the given line. */ -function labelColumn(line: Frame["lines"][number], label: string): number { +/** Positions of all `│` border glyphs in the first body line that has any. */ +function borderColumns(frame: Frame): number[] { + const line = frame.lines.find((l) => + l.spans.some((s) => s.text.includes("│")), + ); + if (!line) return []; + const cols: number[] = []; let col = 0; for (const sp of line.spans) { - if (sp.text.includes(label)) return col; - col += sp.text.length; + for (const ch of sp.text) { + if (ch === "│") cols.push(col); + col++; + } } - return -1; + return cols; } /** - * 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. + * Column widths, measured from the current pane's left/right border glyphs: + * the parent runs from column 0 to the left border, the current pane spans + * both borders, the preview runs from the right border to the frame's edge. */ function columnWidths(spans: Frame): number[] { - const line = spans.lines[0]; - if (!line) return []; - 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]; + const [a, b] = borderColumns(spans); + if (b === undefined) return []; + return [a, b - a + 1, spans.cols - b - 1]; } /** Entire frame as plain text — used to assert no border glyphs remain. */ @@ -77,9 +81,7 @@ async function renderPaneRow(props: TestPaneProps): Promise<{ 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} /> @@ -87,14 +89,14 @@ async function renderPaneRow(props: TestPaneProps): Promise<{ { width: props.width ?? 100, height: props.height ?? 8, useThread: false }, ); // 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. + // palette/theme loading). Poll the title 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(); 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; + if (head.includes("List")) spans = frame; else await new Promise((r) => setTimeout(r, 100)); } if (!spans) throw new Error("PaneRow did not render before timeout"); @@ -163,14 +165,14 @@ describe("PaneRow layout", () => { }); }); -// ── 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 = /[┌┐└┘─│]/; +// ── Integration: the current pane carries muted left/right borders only ──── +describe("PaneRow current-pane borders", () => { + // The current column renders left/right edge glyphs (│) only — never a + // full box. `focused` gates scroll-following but never changes the border + // (always muted — no accent ring), and parent/preview stay borderless. + const boxGlyphs = /[┌┐└┘─]/; - test("focused=true renders no borders and no accent ring", async () => { + test("focused=true renders left/right borders on the current pane only", async () => { const { spans, destroy } = await renderPaneRow({ parent: null, current: () => ITEM, @@ -179,10 +181,13 @@ describe("PaneRow focus ring (borderless)", () => { }); cleanups.push(destroy); - expect(frameText(spans)).not.toMatch(borderGlyphs); + // 100-wide row splits as 20 / 40 / 40: the current pane's edges sit at + // columns 20 and 59. No horizontal or corner glyphs — edges only. + expect(borderColumns(spans)).toEqual([20, 59]); + expect(frameText(spans)).not.toMatch(boxGlyphs); }); - test("focused=false renders no borders and no accent ring", async () => { + test("focused=false renders the same muted borders (no accent ring)", async () => { const { spans, destroy } = await renderPaneRow({ parent: null, current: () => ITEM, @@ -191,7 +196,8 @@ describe("PaneRow focus ring (borderless)", () => { }); cleanups.push(destroy); - expect(frameText(spans)).not.toMatch(borderGlyphs); + expect(borderColumns(spans)).toEqual([20, 59]); + expect(frameText(spans)).not.toMatch(boxGlyphs); }); test("accepts an accessor for focused (reactive boolean)", async () => { @@ -202,7 +208,8 @@ describe("PaneRow focus ring (borderless)", () => { focused: () => true, }); cleanups.push(destroy); - expect(frameText(spans)).not.toMatch(borderGlyphs); + expect(borderColumns(spans)).toEqual([20, 59]); + expect(frameText(spans)).not.toMatch(boxGlyphs); const { spans: spans2, destroy: destroy2 } = await renderPaneRow({ parent: null, @@ -211,16 +218,18 @@ describe("PaneRow focus ring (borderless)", () => { focused: () => false, }); cleanups.push(destroy2); - expect(frameText(spans2)).not.toMatch(borderGlyphs); + expect(borderColumns(spans2)).toEqual([20, 59]); + expect(frameText(spans2)).not.toMatch(boxGlyphs); }); - test("defaults to focused (still borderless, no accent ring)", async () => { + test("defaults to focused (same muted borders)", async () => { const { spans, destroy } = await renderPaneRow({ parent: null, current: () => ITEM, preview: null, }); cleanups.push(destroy); - expect(frameText(spans)).not.toMatch(borderGlyphs); + expect(borderColumns(spans)).toEqual([20, 59]); + expect(frameText(spans)).not.toMatch(boxGlyphs); }); });