fix: up highlight made legible for transparent bg, bring back mouse nav

This commit is contained in:
2026-08-09 22:21:30 -04:00
parent db285530b6
commit 25307f83e9
27 changed files with 363 additions and 72 deletions

View File

@@ -50,6 +50,7 @@ export function App() {
}); });
const backgroundColor = () => const backgroundColor = () =>
themeContext.transparentBackground() ||
themeContext.selected === "system" themeContext.selected === "system"
? "transparent" ? "transparent"
: themeContext.theme.surface; : themeContext.theme.surface;

View File

@@ -1,15 +1,15 @@
/** /**
* PaneRow — the shared parent | current | preview 3-pane layout primitive. * PaneRow — the shared parent | current | preview 3-pane layout primitive.
* *
* Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns * Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three bordered columns
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list * 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}` * 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 * so the ratio is exact regardless of content width — a column's content can
* never stretch its slot. * never stretch its slot.
* *
* Column semantics (per the yazi depth model): * Column semantics (per the yazi depth model):
* parent — the previous-depth list. Renders a muted `—` placeholder and * 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 * current — the current-depth list. The only focusable content column; it
* carries the active-border focus ring when `focused` is truthy. * carries the active-border focus ring when `focused` is truthy.
* preview — detail of the hovered item in `current`; always muted border. * preview — detail of the hovered item in `current`; always muted border.
@@ -43,7 +43,7 @@ type PaneLabel = string | (() => string);
export type PaneRowProps = { export type PaneRowProps = {
/** Parent column content (previous-depth list, or null for a muted /** 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; parent?: PaneContent;
/** Current column content (the focused list). */ /** Current column content (the focused list). */
current?: PaneContent; current?: PaneContent;
@@ -99,7 +99,8 @@ function Pane(props: {
borderColor: () => RGBA; borderColor: () => RGBA;
scrollFocused: () => boolean; scrollFocused: () => boolean;
}) { }) {
const { theme } = useTheme(); const themeContext = useTheme();
const theme = themeContext.theme;
const muted = () => theme.muted ?? theme.textMuted ?? theme.text; const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
// Memoize accessor results so the prop expressions below stay reactive // Memoize accessor results so the prop expressions below stay reactive
@@ -115,7 +116,15 @@ function Pane(props: {
height="100%" height="100%"
> >
{/* ── slim header label row ─────────────────────────────────────────── */} {/* ── slim header label row ─────────────────────────────────────────── */}
<box height={1} paddingLeft={1} backgroundColor={theme.background}> <box
height={1}
paddingLeft={1}
backgroundColor={
themeContext.transparentBackground()
? "transparent"
: theme.background
}
>
<text fg={theme.textSecondary}>{props.label()}</text> <text fg={theme.textSecondary}>{props.label()}</text>
</box> </box>
{/* ── bordered scrollbox ────────────────────────────────────────────── */} {/* ── bordered scrollbox ────────────────────────────────────────────── */}
@@ -124,7 +133,11 @@ function Pane(props: {
focused={scrollFocused()} focused={scrollFocused()}
border border
borderColor={borderColor()} borderColor={borderColor()}
backgroundColor={theme.background} backgroundColor={
themeContext.transparentBackground()
? "transparent"
: theme.background
}
> >
{props.content() ?? <Placeholder color={muted} />} {props.content() ?? <Placeholder color={muted} />}
</scrollbox> </scrollbox>
@@ -163,7 +176,7 @@ export function PaneRow(props: PaneRowProps) {
return ( return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> <box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */} {/* ── parent (1/5) — previous-depth list; always muted ─────────────── */}
<Pane <Pane
grow={PANE_RATIO.parent} grow={PANE_RATIO.parent}
label={parentLabel} label={parentLabel}
@@ -179,7 +192,7 @@ export function PaneRow(props: PaneRowProps) {
borderColor={() => (focused() ? theme.borderActive : theme.border)} borderColor={() => (focused() ? theme.borderActive : theme.border)}
scrollFocused={() => focused()} scrollFocused={() => focused()}
/> />
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */} {/* ── preview (2/5) — hovered-item detail; always muted ────────────── */}
<Show when={panes() === 3}> <Show when={panes() === 3}>
<Pane <Pane
grow={PANE_RATIO.preview} grow={PANE_RATIO.preview}

View File

@@ -20,7 +20,8 @@ export const SelectableBox: ParentComponent<
backgroundColor={ backgroundColor={
props.selected() props.selected()
? theme.primary ? theme.primary
: themeContext.selected === "system" : themeContext.transparentBackground() ||
themeContext.selected === "system"
? "transparent" ? "transparent"
: themeContext.theme.surface : themeContext.theme.surface
} }

View File

@@ -241,11 +241,13 @@ export function Shell() {
return ( return (
<box <box
flexDirection="column" flexDirection="column"
width="100%" width="100%"
height="100%" height="100%"
backgroundColor={t.surface} backgroundColor={
> theme.transparentBackground() ? "transparent" : t.surface
}
>
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */} {/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
<box flexDirection="row" flexGrow={1} width="100%"> <box flexDirection="row" flexGrow={1} width="100%">
<Show <Show
@@ -281,7 +283,11 @@ export function Shell() {
flexDirection="row" flexDirection="row"
height={1} height={1}
width="100%" width="100%"
backgroundColor={t.backgroundPanel ?? t.background} backgroundColor={
theme.transparentBackground()
? "transparent"
: (t.backgroundPanel ?? t.background)
}
> >
<Show <Show
when={nav.mode() === NavMode.COMMAND} when={nav.mode() === NavMode.COMMAND}

View File

@@ -53,7 +53,11 @@ export function TabListPane(props: { muted?: boolean }) {
? theme.border ? theme.border
: undefined; : undefined;
const focusFg = (t: TABS) => const focusFg = (t: TABS) =>
t === cursor() && active() ? theme.surface : theme.text; t === cursor() && active()
? theme.surface
: t === cursor()
? theme.selectedListItemText ?? theme.text
: theme.text;
return ( return (
<For each={TAB_ORDER}> <For each={TAB_ORDER}>
@@ -77,6 +81,14 @@ export function TabListPane(props: { muted?: boolean }) {
flexDirection="row" flexDirection="row"
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(tab)} 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) ─────────────────────────── */} {/* ── selection marker (j/k cursor) ─────────────────────────── */}
<text fg={focusFg(tab)}>{isCursor() ? "" : " "}</text> <text fg={focusFg(tab)}>{isCursor() ? "" : " "}</text>

View File

@@ -11,7 +11,8 @@
// //
// Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select, // 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, // 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) ───────────────────────────────────────────── // ── Movement (within a pane) ─────────────────────────────────────────────
"move-down": ["j", "down"], "move-down": ["j", "down"],
@@ -50,9 +51,11 @@
"tab-goto-5": ["5"], "tab-goto-5": ["5"],
"tab-goto-6": ["6"], "tab-goto-6": ["6"],
// ── Command bar & help & quit ──────────────────────────────────────────── // ── Command palette & help & quit ────────────────────────────────────────
"command": [":"], // q opens the command palette (neovim-style: type q + Enter to quit there).
"quit": ["q", "ctrl-c"], // Q (shift+q) is the instant quick quit. ctrl-c also quits.
"command": [":", "q"],
"quit": ["Q", "ctrl-c"],
"help": ["~", "f1"], "help": ["~", "f1"],
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh) // ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)

View File

@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { createEffect, createMemo, onMount, onCleanup } from "solid-js"; import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
import { createStore, produce } from "solid-js/store"; import { createStore, produce } from "solid-js/store";
import { useRenderer } from "@opentui/solid"; import { useRenderer } from "@opentui/solid";
@@ -10,6 +11,7 @@ import {
generateSubtleSyntax, generateSubtleSyntax,
} from "../utils/syntax-highlighter"; } from "../utils/syntax-highlighter";
import { resolveTerminalTheme, loadThemes } from "../utils/theme"; import { resolveTerminalTheme, loadThemes } from "../utils/theme";
import { detectModeFromBackground } from "../utils/system-theme";
import { createSimpleContext } from "./helper"; import { createSimpleContext } from "./helper";
import { import {
setupThemeSignalHandler, setupThemeSignalHandler,
@@ -84,6 +86,8 @@ export type ThemeResolved = {
muted?: RGBA; muted?: RGBA;
surface?: RGBA; surface?: RGBA;
selectedListItemText?: RGBA; selectedListItemText?: RGBA;
/** Theme declares a transparent (terminal-bg-visible) background. */
transparent?: boolean;
layerBackgrounds?: { layerBackgrounds?: {
layer0: RGBA; layer0: RGBA;
layer1: RGBA; layer1: RGBA;
@@ -94,6 +98,61 @@ export type ThemeResolved = {
thinkingOpacity?: number; 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. * 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( const hasPalette = Boolean(
colors?.palette?.some((value) => Boolean(value)), colors?.palette?.some((value) => Boolean(value)),
); );
@@ -203,13 +272,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
); );
if (!hasPalette && !hasDefaultColors) { if (!hasPalette && !hasDefaultColors) {
// No system colors available, fall back to default // No system colors available — the terminal can't answer OSC queries
// This happens when the terminal doesn't support OSC palette queries // (e.g. inside tmux, or unsupported terminals). Keep the "system"
// (e.g., running inside tmux, or on unsupported terminals) // theme anyway: the detected dark/light mode plus default ANSI colors
// still produce a usable, mode-correct palette.
if (store.active === "system") { if (store.active === "system") {
setStore( setStore(
produce((draft) => { produce((draft) => {
draft.active = "catppuccin"; draft.system = colors ?? EMPTY_TERMINAL_COLORS;
draft.ready = true; draft.ready = true;
}), }),
); );
@@ -293,6 +363,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
mode() { mode() {
return store.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") { setMode(mode: "dark" | "light") {
setStore("mode", mode); setStore("mode", mode);
emitThemeModeChanged(mode); emitThemeModeChanged(mode);

View File

@@ -13,7 +13,7 @@
* *
* parent | current | preview * 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* * `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
* nav model — which column is focused and where its list cursor lives. The * nav model — which column is focused and where its list cursor lives. The
* parent/preview columns are always derived, never focused. * 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). */ /** The tab the root's cursor is hovering (independent of activeTab). */
const tabCursor = (): TABS => tabCursorSignal(); 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). */ /** Move the root's cursor to the adjacent tab (clamped, no wrap). */
const moveTabCursor = (dir: -1 | 1) => { const moveTabCursor = (dir: -1 | 1) => {
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS); setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
@@ -469,6 +472,7 @@ export function createNavigation() {
enterTabContent, enterTabContent,
backToTabRoot, backToTabRoot,
tabCursor, tabCursor,
setTabCursor: setTabCursorTo,
moveTabCursor, moveTabCursor,
activateTabCursor, activateTabCursor,
// pane focus // pane focus

View File

@@ -2,7 +2,7 @@
* DiscoverPage — yazi depth-stack view of discoverable podcasts. * DiscoverPage — yazi depth-stack view of discoverable podcasts.
* *
* depth 0 (current) — category list. Parent pane shows the muted * 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 * depth 1 (current) — podcast results for the drilled category. Parent
* pane = the categories list. * pane = the categories list.
* preview — detail of the hovered item (category summary, or * preview — detail of the hovered item (category summary, or
@@ -146,7 +146,11 @@ function DiscoverPage() {
const focusBg = (i: number, lf: number, active: boolean) => const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined; i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) => 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 = () => const currentLabel = () =>
depth() === 0 depth() === 0

View File

@@ -168,7 +168,11 @@ function FeedPage() {
? theme.border ? theme.border
: undefined; : undefined;
const focusFg = (i: number, listFocus: number, active: boolean) => 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}`; const currentLabel = () => `Feed · ${episodes().length}`;

View File

@@ -2,7 +2,7 @@
* MyShowsPage — yazi depth-stack view of subscribed shows. * MyShowsPage — yazi depth-stack view of subscribed shows.
* *
* depth 0 (current) — subscribed shows. Parent pane shows the muted * 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. * depth 1 (current) — episodes of the drilled show. Parent pane = shows.
* preview — detail of the hovered item in the current column. * 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) => const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined; i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) => 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 showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () => const currentLabel = () =>

View File

@@ -205,7 +205,11 @@ function SearchPage() {
? theme.border ? theme.border
: undefined; : undefined;
const focusFg = (i: number, listFocus: number, active: boolean) => 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) ────────────── // ── parent pane: previous-depth content (tab list at depth 0) ──────────────
const parentContent = () => ( const parentContent = () => (

View File

@@ -39,6 +39,19 @@ export function usePreferencesItems(): SettingItem[] {
app.setTheme(THEME_LABELS[next].value); 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", id: "fontSize",
label: "Font Size", label: "Font Size",

View File

@@ -7,7 +7,7 @@
* *
* Renders entirely through `<PaneRow>` (parent | current | preview): * Renders entirely through `<PaneRow>` (parent | current | preview):
* parent = previous depth's list (sections at depth 1, items at depth 2); * 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 * current = the current-depth list (or editor at depth 2); the only
* focusable column. * focusable column.
* preview = help/preview text for the hovered item in current. * preview = help/preview text for the hovered item in current.
@@ -424,7 +424,12 @@ function Row(props: {
: props.focused : props.focused
? theme.border ? theme.border
: undefined; : 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); const ref = useScrollIntoView(() => props.focused);
return ( return (
<box <box

View File

@@ -29,6 +29,7 @@ const defaultSettings: AppSettings = {
fontSize: 14, fontSize: 14,
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
transparentBackground: false,
visualizer: defaultVisualizerSettings, visualizer: defaultVisualizerSettings,
}; };

View File

@@ -79,6 +79,8 @@ export type AppSettings = {
fontSize: number; fontSize: number;
playbackSpeed: number; playbackSpeed: number;
downloadPath: string; downloadPath: string;
/** Render the app background transparent (let the terminal's own bg show). */
transparentBackground: boolean;
visualizer: VisualizerSettings; visualizer: VisualizerSettings;
}; };

View File

@@ -13,10 +13,12 @@ export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeJson = { export type ThemeJson = {
$schema?: string $schema?: string
defs?: Record<string, HexColor | RefName> defs?: Record<string, HexColor | RefName>
theme: Record<string, ColorValue> & { theme: Record<string, ColorValue | boolean> & {
selectedListItemText?: ColorValue selectedListItemText?: ColorValue
backgroundMenu?: ColorValue backgroundMenu?: ColorValue
thinkingOpacity?: number thinkingOpacity?: number
/** Render the app background transparent (let the terminal's own bg show). */
transparent?: boolean
} }
} }

View File

@@ -179,9 +179,8 @@ export function CommandProvider(props: ParentProps) {
const dialog = useDialog(); const dialog = useDialog();
const keybind = useKeybinds(); const keybind = useKeybinds();
// Open the command palette via the `command` keybind (bound to `:` in // Open the command palette via the `command` keybind (bound to `:` or `q`
// keybinds.jsonc). The old hardcoded "command_list" name was never a // in keybinds.jsonc; the Shell router owns the action and runs it first).
// canonical action, so the palette was unreachable dead code.
useKeyboard((evt) => { useKeyboard((evt) => {
if (value.suspended()) return; if (value.suspended()) return;
if (dialog.isOpen) return; if (dialog.isOpen) return;

View File

@@ -32,6 +32,7 @@ const defaultSettings: AppSettings = {
fontSize: 14, fontSize: 14,
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
transparentBackground: false,
visualizer: defaultVisualizerSettings, visualizer: defaultVisualizerSettings,
}; };

View File

@@ -53,9 +53,10 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
"tab-goto-4": ["4"], "tab-goto-4": ["4"],
"tab-goto-5": ["5"], "tab-goto-5": ["5"],
"tab-goto-6": ["6"], "tab-goto-6": ["6"],
// command / help / quit // command palette / help / quit
command: [":"], // q opens the palette (type q + Enter to quit there); Q is the quick quit.
quit: ["q", "ctrl-c"], command: [":", "q"],
quit: ["Q", "ctrl-c"],
help: ["~", "f1"], help: ["~", "f1"],
// list ops // list ops
search: ["s"], search: ["s"],

View File

@@ -57,13 +57,13 @@ export function rootFrameFor(
// terminal size — more robust than fixed percentages and exactly mirrors // 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).
// //
// NOTE (task 01 leave-behind): the nav-model task intentionally does NOT // Current ratios: parent : current : preview = 1 : 2 : 2, i.e. 1/5 : 2/5 : 2/5
// touch these values. Task 02 re-tunes them to the remake target ratios // (20% / 40% / 40% of the row width). 2-pane tabs drop the preview slot and
// (parent : current : preview = 1 : 3 : 3 i.e. 1/7 : 3/7 : 3/7). Do it there. // give `current` the combined 4/5.
export const PANE_RATIO = { export const PANE_RATIO = {
parent: 1, parent: 1,
current: 3, current: 2,
preview: 3, preview: 2,
} as const; } as const;
// Number of *focusable* content panes per tab. The three visible columns // Number of *focusable* content panes per tab. The three visible columns

View File

@@ -13,19 +13,40 @@ export function clearPaletteCache() {
cached = null; 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( export function generateSystemTheme(
colors: TerminalColors, colors: TerminalColors,
mode: "dark" | "light", mode: "dark" | "light",
): ThemeJson { ): ThemeJson {
cached = colors; cached = colors;
const isDark = mode === "dark";
const bg = RGBA.fromHex( const bg = RGBA.fromHex(
colors.defaultBackground ?? colors.palette[0] ?? "#000000", colors.defaultBackground ??
colors.palette[0] ??
(isDark ? "#000000" : "#ffffff"),
); );
const fg = RGBA.fromHex( 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 transparent = RGBA.fromInts(0, 0, 0, 0);
const isDark = mode === "dark";
const col = (i: number) => { const col = (i: number) => {
const value = colors.palette[i]; const value = colors.palette[i];
@@ -87,6 +108,7 @@ export function generateSystemTheme(
textSelectedTertiary: selectedTertiary, textSelectedTertiary: selectedTertiary,
selectedListItemText: bg, selectedListItemText: bg,
background: transparent, background: transparent,
transparent: true,
backgroundPanel: grays[2], backgroundPanel: grays[2],
backgroundElement: grays[3], backgroundElement: grays[3],
backgroundMenu: grays[3], backgroundMenu: grays[3],

View File

@@ -18,7 +18,7 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
if (value.startsWith("#")) return RGBA.fromHex(value) if (value.startsWith("#")) return RGBA.fromHex(value)
if (defs[value] != null) return resolveColor(defs[value]) if (defs[value] != null) return resolveColor(defs[value])
const ref = theme.theme[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`) throw new Error(`Color reference "${value}" not found in defs or theme`)
} }
return resolveColor(value[mode]) return resolveColor(value[mode])
@@ -26,8 +26,15 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
const resolved = Object.fromEntries( const resolved = Object.fromEntries(
Object.entries(theme.theme) Object.entries(theme.theme)
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity") .filter(
.map(([key, value]) => [key, resolveColor(value)]) (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<string, RGBA> ) as Record<string, RGBA>
const hasSelected = theme.theme.selectedListItemText !== undefined const hasSelected = theme.theme.selectedListItemText !== undefined
@@ -40,6 +47,7 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
: resolved.backgroundElement : resolved.backgroundElement
const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6 const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6
const transparent = theme.theme.transparent === true
const background = resolved.background const background = resolved.background
const backgroundPanel = resolved.backgroundPanel ?? background const backgroundPanel = resolved.backgroundPanel ?? background
@@ -58,5 +66,6 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
}, },
_hasSelectedListItemText: hasSelected, _hasSelectedListItemText: hasSelected,
thinkingOpacity, thinkingOpacity,
transparent,
} }
} }

View File

@@ -11,8 +11,8 @@ const cfg = {
"audio-seek-forward": parseBindingSpec(["shift-."]), "audio-seek-forward": parseBindingSpec(["shift-."]),
"audio-seek-backward": parseBindingSpec(["shift-,"]), "audio-seek-backward": parseBindingSpec(["shift-,"]),
sort: parseBindingSpec([","]), sort: parseBindingSpec([","]),
quit: parseBindingSpec(["q"]), quit: parseBindingSpec(["Q"]),
command: parseBindingSpec([":"]), command: parseBindingSpec([":", "q"]),
"tab-next": parseBindingSpec(["]"]), "tab-next": parseBindingSpec(["]"]),
} as Record<string, ReturnType<typeof parseBindingSpec>>; } as Record<string, ReturnType<typeof parseBindingSpec>>;
@@ -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("gg -> goto-top", sim([E("g"), E("g")]), "goto-top");
check("G -> goto-bottom", sim([E("g", { shift: true })]), "goto-bottom"); check("G -> goto-bottom", sim([E("g", { shift: true })]), "goto-bottom");
check("space -> toggle-select", sim([E("space")]), "toggle-select"); 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(": -> command", sim([E(":")]), "command");
check("] -> tab-next", sim([E("]")]), "tab-next"); check("] -> tab-next", sim([E("]")]), "tab-next");
check( check(

View File

@@ -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", () => { test("tab-switch resets mode/visual/command state", () => {
withNav((nav) => { withNav((nav) => {
nav.enterVisual(); nav.enterVisual();

View File

@@ -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);
});

View File

@@ -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 * Verified through the opentui test renderer's captured frames (the same
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only * mechanism the `.harness` drive uses), since `flexGrow` ratios are only
* observable as rendered column widths and border colors. * 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 * parent and preview children are null, and the blank parent keeps its
* slot with a muted placeholder. * slot with a muted placeholder.
* • Integration: toggling `focused` moves the accent focus ring onto/off the * • 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", () => { 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({ const { spans, destroy } = await renderPaneRow({
parent: null, parent: null,
current: () => <text>ITEM</text>, current: () => <text>ITEM</text>,
@@ -138,15 +138,15 @@ describe("PaneRow layout", () => {
const widths = columnWidths(spans); const widths = columnWidths(spans);
expect(widths).toHaveLength(3); expect(widths).toHaveLength(3);
const [p, c, v] = widths; const [p, c, v] = widths;
// 100-wide row splits as 14 / 43 / 43 (1/7 : 3/7 : 3/7, borders included). // 100-wide row splits as 20 / 40 / 40 (1/5 : 2/5 : 2/5, borders included).
expect(p).toBe(14); expect(p).toBe(20);
expect(c).toBe(43); expect(c).toBe(40);
expect(v).toBe(43); expect(v).toBe(40);
// Exact 1:3:3 proportion (within 1 col rounding). // Exact 1:2:2 proportion (within 1 col rounding).
expect(c).toBeGreaterThanOrEqual(p * 3 - 1); expect(c).toBeGreaterThanOrEqual(p * 2 - 1);
expect(c).toBeLessThanOrEqual(p * 3 + 1); expect(c).toBeLessThanOrEqual(p * 2 + 1);
expect(v).toBeGreaterThanOrEqual(p * 3 - 1); expect(v).toBeGreaterThanOrEqual(p * 2 - 1);
expect(v).toBeLessThanOrEqual(p * 3 + 1); expect(v).toBeLessThanOrEqual(p * 2 + 1);
// Parent keeps a visibly non-zero slot and renders the muted placeholder. // Parent keeps a visibly non-zero slot and renders the muted placeholder.
expect(p).toBeGreaterThan(4); expect(p).toBeGreaterThan(4);
const body = spans.lines const body = spans.lines
@@ -156,7 +156,7 @@ describe("PaneRow layout", () => {
expect(body).toContain("ITEM"); 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({ const { spans, destroy } = await renderPaneRow({
parent: null, parent: null,
current: () => <text>x</text>, current: () => <text>x</text>,
@@ -165,9 +165,9 @@ describe("PaneRow layout", () => {
}); });
cleanups.push(destroy); cleanups.push(destroy);
const [p, c, v] = columnWidths(spans); const [p, c, v] = columnWidths(spans);
expect(p).toBe(10); // 70 → 10 / 30 / 30 expect(p).toBe(14); // 70 → 14 / 28 / 28
expect(c).toBe(30); expect(c).toBe(28);
expect(v).toBe(30); expect(v).toBe(28);
}); });
}); });