diff --git a/src/App.tsx b/src/App.tsx index d616937..fbd648b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,6 +50,7 @@ export function App() { }); const backgroundColor = () => + themeContext.transparentBackground() || themeContext.selected === "system" ? "transparent" : themeContext.theme.surface; diff --git a/src/components/PaneRow.tsx b/src/components/PaneRow.tsx index bc4f0e4..07626b1 100644 --- a/src/components/PaneRow.tsx +++ b/src/components/PaneRow.tsx @@ -1,15 +1,15 @@ /** * PaneRow — 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 + * 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 * 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). + * KEEPS its 1/5 slot when blank (never collapses to width 0). * 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. @@ -43,7 +43,7 @@ type PaneLabel = string | (() => string); export type PaneRowProps = { /** Parent column content (previous-depth list, or null for a muted - * placeholder — the 1/7 slot is always preserved). */ + * placeholder — the 1/5 slot is always preserved). */ parent?: PaneContent; /** Current column content (the focused list). */ current?: PaneContent; @@ -99,7 +99,8 @@ function Pane(props: { borderColor: () => RGBA; scrollFocused: () => boolean; }) { - const { theme } = useTheme(); + 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 @@ -115,7 +116,15 @@ function Pane(props: { height="100%" > {/* ── slim header label row ─────────────────────────────────────────── */} - + {props.label()} {/* ── bordered scrollbox ────────────────────────────────────────────── */} @@ -124,7 +133,11 @@ function Pane(props: { focused={scrollFocused()} border borderColor={borderColor()} - backgroundColor={theme.background} + backgroundColor={ + themeContext.transparentBackground() + ? "transparent" + : theme.background + } > {props.content() ?? } @@ -163,7 +176,7 @@ export function PaneRow(props: PaneRowProps) { return ( - {/* ── parent (1/7) — previous-depth list; always muted ─────────────── */} + {/* ── parent (1/5) — previous-depth list; always muted ─────────────── */} (focused() ? theme.borderActive : theme.border)} scrollFocused={() => focused()} /> - {/* ── preview (3/7) — hovered-item detail; always muted ────────────── */} + {/* ── preview (2/5) — hovered-item detail; always muted ────────────── */} + flexDirection="column" + width="100%" + height="100%" + backgroundColor={ + theme.transparentBackground() ? "transparent" : t.surface + } + > {/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */} - t === cursor() && active() ? theme.surface : theme.text; + t === cursor() && active() + ? theme.surface + : t === cursor() + ? theme.selectedListItemText ?? theme.text + : theme.text; return ( @@ -77,6 +81,14 @@ export function TabListPane(props: { muted?: boolean }) { flexDirection="row" paddingRight={1} backgroundColor={focusBg(tab)} + onMouseDown={() => { + // Click = hover + open, the yazi "open" of the row + // (switches to the tab and enters its content), the same + // as l/Enter. Restores mouse support the tab-strip + // refactor dropped. + nav.setTabCursor(tab); + nav.activateTabCursor(); + }} > {/* ── selection marker (j/k cursor) ─────────────────────────── */} {isCursor() ? "❯" : " "} diff --git a/src/config/keybinds.jsonc b/src/config/keybinds.jsonc index bd89544..d740afc 100644 --- a/src/config/keybinds.jsonc +++ b/src/config/keybinds.jsonc @@ -11,7 +11,8 @@ // // Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select, // v visual mode, gg/G top/bottom, [ ] switch tabs, 1-6 goto tab, - // : command bar, q quit, ~ help. Audio transport kept on shifted keys / ctrl. + // : / q command palette (q + Enter quits there), Q quick quit, ~ help. + // Audio transport kept on shifted keys / ctrl. // ── Movement (within a pane) ───────────────────────────────────────────── "move-down": ["j", "down"], @@ -50,9 +51,11 @@ "tab-goto-5": ["5"], "tab-goto-6": ["6"], - // ── Command bar & help & quit ──────────────────────────────────────────── - "command": [":"], - "quit": ["q", "ctrl-c"], + // ── Command palette & help & quit ──────────────────────────────────────── + // q opens the command palette (neovim-style: type q + Enter to quit there). + // Q (shift+q) is the instant quick quit. ctrl-c also quits. + "command": [":", "q"], + "quit": ["Q", "ctrl-c"], "help": ["~", "f1"], // ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh) diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 28805a9..ec09501 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { createEffect, createMemo, onMount, onCleanup } from "solid-js"; import { createStore, produce } from "solid-js/store"; import { useRenderer } from "@opentui/solid"; @@ -10,6 +11,7 @@ import { generateSubtleSyntax, } from "../utils/syntax-highlighter"; import { resolveTerminalTheme, loadThemes } from "../utils/theme"; +import { detectModeFromBackground } from "../utils/system-theme"; import { createSimpleContext } from "./helper"; import { setupThemeSignalHandler, @@ -84,6 +86,8 @@ export type ThemeResolved = { muted?: RGBA; surface?: RGBA; selectedListItemText?: RGBA; + /** Theme declares a transparent (terminal-bg-visible) background. */ + transparent?: boolean; layerBackgrounds?: { layer0: RGBA; layer1: RGBA; @@ -94,6 +98,61 @@ export type ThemeResolved = { thinkingOpacity?: number; }; +/** + * A TerminalColors with no values — used to keep the "system" theme rendering + * with default ANSI colors + the detected dark/light mode when the terminal + * cannot answer OSC queries (e.g. inside tmux without OSC forwarding). + */ +const EMPTY_TERMINAL_COLORS: TerminalColors = { + palette: Array.from({ length: 16 }, () => null), + defaultForeground: null, + defaultBackground: null, + cursorColor: null, + mouseForeground: null, + mouseBackground: null, + tekForeground: null, + tekBackground: null, + highlightBackground: null, + highlightForeground: null, +}; + +/** Cached macOS appearance (dark/light), independent of the terminal. */ +let cachedOsMode: "dark" | "light" | null = null; + +/** + * Detect the terminal's dark/light mode. + * + * Priority: + * 1. The terminal's real background color (OSC 11 response) — terminal-specific. + * 2. The macOS appearance via `defaults read -g AppleInterfaceStyle` — works + * even inside tmux, where OSC queries are usually not forwarded. + * An unset value means light mode (macOS defaults to light). + * 3. null → keep whatever mode is currently active. + */ +function detectSystemMode( + colors: TerminalColors | null, +): "dark" | "light" | null { + const fromBg = detectModeFromBackground(colors?.defaultBackground); + if (fromBg) return fromBg; + + if (process.platform === "darwin" && cachedOsMode === null) { + let style: string | null = null; + try { + style = execFileSync("defaults", ["read", "-g", "AppleInterfaceStyle"], { + encoding: "utf8", + timeout: 2000, + }) + .trim() + .toLowerCase(); + } catch { + // Unset → light appearance (macOS default). + } + cachedOsMode = style?.includes("dark") ? "dark" : "light"; + } + + return cachedOsMode; +} + /** * Theme context using the createSimpleContext pattern. * @@ -195,6 +254,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } } + // ── dark/light mode detection ───────────────────────────────────────── + // The provider starts with a hardcoded mode (e.g. "dark"); detect the + // real one from the terminal's background color (OSC 11) or, when that + // is unavailable (tmux without OSC forwarding), the OS appearance. + const detectedMode = detectSystemMode(colors); + if (detectedMode && detectedMode !== store.mode) { + setStore("mode", detectedMode); + emitThemeModeChanged(detectedMode); + } + const hasPalette = Boolean( colors?.palette?.some((value) => Boolean(value)), ); @@ -203,13 +272,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ ); if (!hasPalette && !hasDefaultColors) { - // No system colors available, fall back to default - // This happens when the terminal doesn't support OSC palette queries - // (e.g., running inside tmux, or on unsupported terminals) + // No system colors available — the terminal can't answer OSC queries + // (e.g. inside tmux, or unsupported terminals). Keep the "system" + // theme anyway: the detected dark/light mode plus default ANSI colors + // still produce a usable, mode-correct palette. if (store.active === "system") { setStore( produce((draft) => { - draft.active = "catppuccin"; + draft.system = colors ?? EMPTY_TERMINAL_COLORS; draft.ready = true; }), ); @@ -293,6 +363,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ mode() { return store.mode; }, + /** Whether the app background should be transparent (no solid fill): + * either the global preference is on, or the selected theme declares + * transparency (e.g. the system theme). */ + transparentBackground() { + return ( + appStore.state().settings.transparentBackground || + values().transparent === true + ); + }, setMode(mode: "dark" | "light") { setStore("mode", mode); emitThemeModeChanged(mode); diff --git a/src/context/navigation-store.ts b/src/context/navigation-store.ts index 2ad7c16..c6b85c1 100644 --- a/src/context/navigation-store.ts +++ b/src/context/navigation-store.ts @@ -13,7 +13,7 @@ * * parent | current | preview * - * Layout ratios (1/7 : 3/7 : 3/7 in the final remake) live in + * Layout ratios (1/5 : 2/5 : 2/5 in the final remake) live in * `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable* * nav model — which column is focused and where its list cursor lives. The * parent/preview columns are always derived, never focused. @@ -267,6 +267,9 @@ export function createNavigation() { /** The tab the root's cursor is hovering (independent of activeTab). */ const tabCursor = (): TABS => tabCursorSignal(); + /** Directly set the root's tab cursor (e.g. a mouse click on a tab row). */ + const setTabCursorTo = (tab: TABS) => setTabCursor(tab); + /** Move the root's cursor to the adjacent tab (clamped, no wrap). */ const moveTabCursor = (dir: -1 | 1) => { setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS); @@ -469,6 +472,7 @@ export function createNavigation() { enterTabContent, backToTabRoot, tabCursor, + setTabCursor: setTabCursorTo, moveTabCursor, activateTabCursor, // pane focus diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index a25f81f..26da6a1 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -2,7 +2,7 @@ * DiscoverPage — yazi depth-stack view of discoverable podcasts. * * depth 0 (current) — category list. Parent pane shows the muted - * placeholder (1/7 slot kept). + * placeholder (1/5 slot kept). * depth 1 (current) — podcast results for the drilled category. Parent * pane = the categories list. * preview — detail of the hovered item (category summary, or @@ -146,7 +146,11 @@ function DiscoverPage() { const focusBg = (i: number, lf: number, active: boolean) => i === lf && active ? theme.primary : i === lf ? theme.border : undefined; const focusFg = (i: number, lf: number, active: boolean) => - i === lf && active ? theme.surface : theme.text; + i === lf && active + ? theme.surface + : i === lf + ? theme.selectedListItemText ?? theme.text + : theme.text; const currentLabel = () => depth() === 0 diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index 5aedd4d..4b6a112 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -168,7 +168,11 @@ function FeedPage() { ? theme.border : undefined; const focusFg = (i: number, listFocus: number, active: boolean) => - i === listFocus && active ? theme.surface : theme.text; + i === listFocus && active + ? theme.surface + : i === listFocus + ? theme.selectedListItemText ?? theme.text + : theme.text; const currentLabel = () => `Feed · ${episodes().length}`; diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index 4a19050..a89de4e 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -2,7 +2,7 @@ * MyShowsPage — yazi depth-stack view of subscribed shows. * * depth 0 (current) — subscribed shows. Parent pane shows the muted - * placeholder (1/7 slot kept). + * placeholder (1/5 slot kept). * depth 1 (current) — episodes of the drilled show. Parent pane = shows. * preview — detail of the hovered item in the current column. * @@ -199,7 +199,11 @@ export function MyShowsPage() { const focusBg = (i: number, lf: number, active: boolean) => i === lf && active ? theme.primary : i === lf ? theme.border : undefined; const focusFg = (i: number, lf: number, active: boolean) => - i === lf && active ? theme.surface : theme.text; + i === lf && active + ? theme.surface + : i === lf + ? theme.selectedListItemText ?? theme.text + : theme.text; const showTitle = (f: Feed) => f.customName || f.podcast.title; const currentLabel = () => diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 104a6f4..0fbaad5 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -205,7 +205,11 @@ function SearchPage() { ? theme.border : undefined; const focusFg = (i: number, listFocus: number, active: boolean) => - i === listFocus && active ? theme.surface : theme.text; + i === listFocus && active + ? theme.surface + : i === listFocus + ? theme.selectedListItemText ?? theme.text + : theme.text; // ── parent pane: previous-depth content (tab list at depth 0) ────────────── const parentContent = () => ( diff --git a/src/pages/Settings/PreferencesPanel.tsx b/src/pages/Settings/PreferencesPanel.tsx index 45dbd97..210a497 100644 --- a/src/pages/Settings/PreferencesPanel.tsx +++ b/src/pages/Settings/PreferencesPanel.tsx @@ -39,6 +39,19 @@ export function usePreferencesItems(): SettingItem[] { app.setTheme(THEME_LABELS[next].value); }, }, + { + id: "transparentBackground", + label: "Transparent Background", + kind: "toggle", + display: () => + settings().transparentBackground ? "On" : "Off", + help: () => + `Let the terminal's own background show through (no app background fill).\nType: toggle\nDefault: false\nCurrent: ${settings().transparentBackground ? "On" : "Off"}\nSpace/Enter to toggle.`, + toggle: () => + app.updateSettings({ + transparentBackground: !settings().transparentBackground, + }), + }, { id: "fontSize", label: "Font Size", diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 3ccce53..4a529f9 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -7,7 +7,7 @@ * * Renders entirely through `` (parent | current | preview): * parent = previous depth's list (sections at depth 1, items at depth 2); - * blank placeholder at depth 0 (1/7 slot kept). + * blank placeholder at depth 0 (1/5 slot kept). * current = the current-depth list (or editor at depth 2); the only * focusable column. * preview = help/preview text for the hovered item in current. @@ -424,7 +424,12 @@ function Row(props: { : props.focused ? theme.border : undefined; - const fg = () => (props.focused && props.active ? theme.surface : theme.text); + const fg = () => + props.focused && props.active + ? theme.surface + : props.focused + ? theme.selectedListItemText ?? theme.text + : theme.text; const ref = useScrollIntoView(() => props.focused); return ( - theme: Record & { + theme: Record & { selectedListItemText?: ColorValue backgroundMenu?: ColorValue thinkingOpacity?: number + /** Render the app background transparent (let the terminal's own bg show). */ + transparent?: boolean } } diff --git a/src/ui/command.tsx b/src/ui/command.tsx index 9c6c347..ad2e1c9 100644 --- a/src/ui/command.tsx +++ b/src/ui/command.tsx @@ -179,9 +179,8 @@ export function CommandProvider(props: ParentProps) { const dialog = useDialog(); const keybind = useKeybinds(); - // Open the command palette via the `command` keybind (bound to `:` in - // keybinds.jsonc). The old hardcoded "command_list" name was never a - // canonical action, so the palette was unreachable dead code. + // Open the command palette via the `command` keybind (bound to `:` or `q` + // in keybinds.jsonc; the Shell router owns the action and runs it first). useKeyboard((evt) => { if (value.suspended()) return; if (dialog.isOpen) return; diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index 70f61a9..1be4613 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -32,6 +32,7 @@ const defaultSettings: AppSettings = { fontSize: 14, playbackSpeed: 1, downloadPath: "", + transparentBackground: false, visualizer: defaultVisualizerSettings, }; diff --git a/src/utils/keybinds-persistence.ts b/src/utils/keybinds-persistence.ts index 38d070b..b315ecf 100644 --- a/src/utils/keybinds-persistence.ts +++ b/src/utils/keybinds-persistence.ts @@ -53,9 +53,10 @@ const DEFAULT_KEYBINDS: KeybindsResolved = { "tab-goto-4": ["4"], "tab-goto-5": ["5"], "tab-goto-6": ["6"], - // command / help / quit - command: [":"], - quit: ["q", "ctrl-c"], + // command palette / help / quit + // q opens the palette (type q + Enter to quit there); Q is the quick quit. + command: [":", "q"], + quit: ["Q", "ctrl-c"], help: ["~", "f1"], // list ops search: ["s"], diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts index 547411e..1580f9d 100644 --- a/src/utils/navigation.ts +++ b/src/utils/navigation.ts @@ -57,13 +57,13 @@ export function rootFrameFor( // 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). // -// NOTE (task 01 leave-behind): the nav-model task intentionally does NOT -// touch these values. Task 02 re-tunes them to the remake target ratios -// (parent : current : preview = 1 : 3 : 3 i.e. 1/7 : 3/7 : 3/7). Do it there. +// Current ratios: parent : current : preview = 1 : 2 : 2, i.e. 1/5 : 2/5 : 2/5 +// (20% / 40% / 40% of the row width). 2-pane tabs drop the preview slot and +// give `current` the combined 4/5. export const PANE_RATIO = { parent: 1, - current: 3, - preview: 3, + current: 2, + preview: 2, } as const; // Number of *focusable* content panes per tab. The three visible columns diff --git a/src/utils/system-theme.ts b/src/utils/system-theme.ts index f0798a4..00b1480 100644 --- a/src/utils/system-theme.ts +++ b/src/utils/system-theme.ts @@ -13,19 +13,40 @@ export function clearPaletteCache() { cached = null; } +/** Relative luminance of a hex color (0 = black, 1 = white). */ +function luminance(hex: string): number { + const c = RGBA.fromHex(hex); + return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b; +} + +/** + * Infer the terminal's dark/light mode from its default background color + * (the OSC 11 query response). Returns null when no background is available. + */ +export function detectModeFromBackground( + background: string | null | undefined, +): "dark" | "light" | null { + if (!background) return null; + return luminance(background) < 0.5 ? "dark" : "light"; +} + export function generateSystemTheme( colors: TerminalColors, mode: "dark" | "light", ): ThemeJson { cached = colors; + const isDark = mode === "dark"; const bg = RGBA.fromHex( - colors.defaultBackground ?? colors.palette[0] ?? "#000000", + colors.defaultBackground ?? + colors.palette[0] ?? + (isDark ? "#000000" : "#ffffff"), ); const fg = RGBA.fromHex( - colors.defaultForeground ?? colors.palette[7] ?? "#ffffff", + colors.defaultForeground ?? + colors.palette[7] ?? + (isDark ? "#ffffff" : "#000000"), ); const transparent = RGBA.fromInts(0, 0, 0, 0); - const isDark = mode === "dark"; const col = (i: number) => { const value = colors.palette[i]; @@ -87,6 +108,7 @@ export function generateSystemTheme( textSelectedTertiary: selectedTertiary, selectedListItemText: bg, background: transparent, + transparent: true, backgroundPanel: grays[2], backgroundElement: grays[3], backgroundMenu: grays[3], diff --git a/src/utils/theme-resolver.ts b/src/utils/theme-resolver.ts index 8c565b3..efd459f 100644 --- a/src/utils/theme-resolver.ts +++ b/src/utils/theme-resolver.ts @@ -18,7 +18,7 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) { if (value.startsWith("#")) return RGBA.fromHex(value) if (defs[value] != null) return resolveColor(defs[value]) const ref = theme.theme[value] - if (ref != null) return resolveColor(ref) + if (ref != null && typeof ref !== "boolean") return resolveColor(ref) throw new Error(`Color reference "${value}" not found in defs or theme`) } return resolveColor(value[mode]) @@ -26,8 +26,15 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) { const resolved = Object.fromEntries( Object.entries(theme.theme) - .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity") - .map(([key, value]) => [key, resolveColor(value)]) + .filter( + (entry): entry is [string, ColorValue] => + entry[0] !== "selectedListItemText" && + entry[0] !== "backgroundMenu" && + entry[0] !== "thinkingOpacity" && + entry[0] !== "transparent" && + typeof entry[1] !== "boolean", + ) + .map(([key, value]) => [key, resolveColor(value)]), ) as Record const hasSelected = theme.theme.selectedListItemText !== undefined @@ -40,6 +47,7 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) { : resolved.backgroundElement const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6 + const transparent = theme.theme.transparent === true const background = resolved.background const backgroundPanel = resolved.backgroundPanel ?? background @@ -58,5 +66,6 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) { }, _hasSelectedListItemText: hasSelected, thinkingOpacity, + transparent, } } diff --git a/tests/keybind-matcher.test.ts b/tests/keybind-matcher.test.ts index 5dde396..4fb4ca9 100644 --- a/tests/keybind-matcher.test.ts +++ b/tests/keybind-matcher.test.ts @@ -11,8 +11,8 @@ const cfg = { "audio-seek-forward": parseBindingSpec(["shift-."]), "audio-seek-backward": parseBindingSpec(["shift-,"]), sort: parseBindingSpec([","]), - quit: parseBindingSpec(["q"]), - command: parseBindingSpec([":"]), + quit: parseBindingSpec(["Q"]), + command: parseBindingSpec([":", "q"]), "tab-next": parseBindingSpec(["]"]), } as Record>; @@ -95,7 +95,8 @@ check("down -> move-down", sim([E("down")]), "move-down"); check("gg -> goto-top", sim([E("g"), E("g")]), "goto-top"); check("G -> goto-bottom", sim([E("g", { shift: true })]), "goto-bottom"); check("space -> toggle-select", sim([E("space")]), "toggle-select"); -check("q -> quit", sim([E("q")]), "quit"); +check("q -> command", sim([E("q")]), "command"); +check("Q -> quit (shift+q)", sim([E("q", { shift: true })]), "quit"); check(": -> command", sim([E(":")]), "command"); check("] -> tab-next", sim([E("]")]), "tab-next"); check( diff --git a/tests/nav-model.test.ts b/tests/nav-model.test.ts index 9b74366..6a7a222 100644 --- a/tests/nav-model.test.ts +++ b/tests/nav-model.test.ts @@ -214,6 +214,17 @@ test("direct tab switches re-sync the tab cursor", () => { }); }); +test("setTabCursor moves the cursor directly (mouse click support), activateTabCursor opens it", () => { + withNav((nav) => { + nav.setTabCursor(TABS.SETTINGS); + expect(nav.tabCursor()).toBe(TABS.SETTINGS); + expect(nav.activeTab()).toBe(TABS.FEED); // cursor only — active tab untouched + nav.activateTabCursor(); + expect(nav.activeTab()).toBe(TABS.SETTINGS); + expect(nav.atRootTab()).toBe(false); + }); +}); + test("tab-switch resets mode/visual/command state", () => { withNav((nav) => { nav.enterVisual(); diff --git a/tests/system-theme.test.ts b/tests/system-theme.test.ts new file mode 100644 index 0000000..fe35e33 --- /dev/null +++ b/tests/system-theme.test.ts @@ -0,0 +1,89 @@ +/** + * system-theme.test.ts — dark/light mode detection for the "system" theme. + * + * Covers the pure luminance helper `detectModeFromBackground` and the + * `generateSystemTheme` defaults when the terminal cannot answer OSC queries + * (empty palette): the fallback background/foreground must follow the detected + * mode (dark → white-on-black, light → black-on-white) instead of always + * assuming a dark terminal. + */ +import { test, expect } from "bun:test"; +import { RGBA, type TerminalColors } from "@opentui/core"; +import { + detectModeFromBackground, + generateSystemTheme, +} from "../src/utils/system-theme"; + +/** A TerminalColors with no real values — simulates a terminal that can't + * answer OSC palette/background queries (e.g. tmux without forwarding). */ +const EMPTY: TerminalColors = { + palette: Array.from({ length: 16 }, () => null), + defaultForeground: null, + defaultBackground: null, + cursorColor: null, + mouseForeground: null, + mouseBackground: null, + tekForeground: null, + tekBackground: null, + highlightBackground: null, + highlightForeground: null, +}; + +test("detectModeFromBackground maps dark backgrounds to dark", () => { + expect(detectModeFromBackground("#181825")).toBe("dark"); + expect(detectModeFromBackground("#000000")).toBe("dark"); +}); + +test("detectModeFromBackground maps light backgrounds to light", () => { + expect(detectModeFromBackground("#ffffff")).toBe("light"); + expect(detectModeFromBackground("#f0f0f0")).toBe("light"); + expect(detectModeFromBackground("#c8c8c8")).toBe("light"); +}); + +test("detectModeFromBackground returns null without a background", () => { + expect(detectModeFromBackground(null)).toBeNull(); + expect(detectModeFromBackground(undefined)).toBeNull(); + expect(detectModeFromBackground("")).toBeNull(); +}); + +test("empty palette in dark mode falls back to light-on-dark text", () => { + const theme = generateSystemTheme(EMPTY, "dark").theme; + // fg defaults to #ffffff; r/g/b are 0..1 floats + expect((theme.text as RGBA).r).toBeGreaterThan(0.9); + expect((theme.text as RGBA).g).toBeGreaterThan(0.9); + expect((theme.text as RGBA).b).toBeGreaterThan(0.9); +}); + +test("system theme declares a transparent background", () => { + const theme = generateSystemTheme(EMPTY, "dark").theme; + // The system theme lets the terminal's own background show through. + expect(theme.transparent).toBe(true); + expect((theme.background as RGBA).a).toBe(0); +}); + +test("empty palette in light mode falls back to dark-on-light text", () => { + const theme = generateSystemTheme(EMPTY, "light").theme; + // fg defaults to #000000 + expect((theme.text as RGBA).r).toBeLessThan(0.1); + expect((theme.text as RGBA).g).toBeLessThan(0.1); + expect((theme.text as RGBA).b).toBeLessThan(0.1); +}); + +test("empty palette in light mode uses a light background, not black", () => { + const theme = generateSystemTheme(EMPTY, "light").theme; + // bg defaults to #ffffff; the diff/panel grays derive from it, so the + // backgroundPanel must be light (high luminance), not near-black. + expect((theme.backgroundPanel as RGBA).r).toBeGreaterThan(0.5); +}); + +test("a real light background yields a light theme regardless of mode arg", () => { + const colors: TerminalColors = { + ...EMPTY, + defaultBackground: "#f5f5f5", + defaultForeground: "#111111", + }; + const theme = generateSystemTheme(colors, "dark").theme; + // The actual terminal background wins over the mode default. + expect(detectModeFromBackground(colors.defaultBackground)).toBe("light"); + expect((theme.backgroundPanel as RGBA).r).toBeGreaterThan(0.5); +}); diff --git a/tests/yazi-pane-row.test.tsx b/tests/yazi-pane-row.test.tsx index e2f124d..92da54c 100644 --- a/tests/yazi-pane-row.test.tsx +++ b/tests/yazi-pane-row.test.tsx @@ -1,11 +1,11 @@ /** - * PaneRow tests — the 1:3:3 parent|current|preview layout primitive. + * PaneRow tests — the 1:2:2 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 + * • 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 @@ -125,9 +125,9 @@ afterAll(async () => { } }); -// ── Unit: three columns at 1:3:3 regardless of null children ─────────────── +// ── Unit: three columns at 1:2:2 regardless of null children ─────────────── describe("PaneRow layout", () => { - test("renders three columns at 1:3:3 even with null parent/preview", async () => { + test("renders three columns at 1:2:2 even with null parent/preview", async () => { const { spans, destroy } = await renderPaneRow({ parent: null, current: () => ITEM, @@ -138,15 +138,15 @@ describe("PaneRow layout", () => { 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); + // 100-wide row splits as 20 / 40 / 40 (1/5 : 2/5 : 2/5, borders included). + expect(p).toBe(20); + expect(c).toBe(40); + expect(v).toBe(40); + // Exact 1:2:2 proportion (within 1 col rounding). + expect(c).toBeGreaterThanOrEqual(p * 2 - 1); + expect(c).toBeLessThanOrEqual(p * 2 + 1); + expect(v).toBeGreaterThanOrEqual(p * 2 - 1); + expect(v).toBeLessThanOrEqual(p * 2 + 1); // Parent keeps a visibly non-zero slot and renders the muted placeholder. expect(p).toBeGreaterThan(4); const body = spans.lines @@ -156,7 +156,7 @@ describe("PaneRow layout", () => { expect(body).toContain("ITEM"); }); - test("keeps the 1/7 parent slot across widths (ratio stable)", async () => { + test("keeps the 1/5 parent slot across widths (ratio stable)", async () => { const { spans, destroy } = await renderPaneRow({ parent: null, current: () => x, @@ -165,9 +165,9 @@ describe("PaneRow layout", () => { }); 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); + expect(p).toBe(14); // 70 → 14 / 28 / 28 + expect(c).toBe(28); + expect(v).toBe(28); }); });