fix: up highlight made legible for transparent bg, bring back mouse nav
This commit is contained in:
@@ -50,6 +50,7 @@ export function App() {
|
||||
});
|
||||
|
||||
const backgroundColor = () =>
|
||||
themeContext.transparentBackground() ||
|
||||
themeContext.selected === "system"
|
||||
? "transparent"
|
||||
: themeContext.theme.surface;
|
||||
|
||||
@@ -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 ─────────────────────────────────────────── */}
|
||||
<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>
|
||||
</box>
|
||||
{/* ── 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() ?? <Placeholder color={muted} />}
|
||||
</scrollbox>
|
||||
@@ -163,7 +176,7 @@ export function PaneRow(props: PaneRowProps) {
|
||||
|
||||
return (
|
||||
<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
|
||||
grow={PANE_RATIO.parent}
|
||||
label={parentLabel}
|
||||
@@ -179,7 +192,7 @@ export function PaneRow(props: PaneRowProps) {
|
||||
borderColor={() => (focused() ? theme.borderActive : theme.border)}
|
||||
scrollFocused={() => focused()}
|
||||
/>
|
||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
||||
{/* ── preview (2/5) — hovered-item detail; always muted ────────────── */}
|
||||
<Show when={panes() === 3}>
|
||||
<Pane
|
||||
grow={PANE_RATIO.preview}
|
||||
|
||||
@@ -20,7 +20,8 @@ export const SelectableBox: ParentComponent<
|
||||
backgroundColor={
|
||||
props.selected()
|
||||
? theme.primary
|
||||
: themeContext.selected === "system"
|
||||
: themeContext.transparentBackground() ||
|
||||
themeContext.selected === "system"
|
||||
? "transparent"
|
||||
: themeContext.theme.surface
|
||||
}
|
||||
|
||||
@@ -244,7 +244,9 @@ export function Shell() {
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
backgroundColor={t.surface}
|
||||
backgroundColor={
|
||||
theme.transparentBackground() ? "transparent" : t.surface
|
||||
}
|
||||
>
|
||||
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
|
||||
<box flexDirection="row" flexGrow={1} width="100%">
|
||||
@@ -281,7 +283,11 @@ export function Shell() {
|
||||
flexDirection="row"
|
||||
height={1}
|
||||
width="100%"
|
||||
backgroundColor={t.backgroundPanel ?? t.background}
|
||||
backgroundColor={
|
||||
theme.transparentBackground()
|
||||
? "transparent"
|
||||
: (t.backgroundPanel ?? t.background)
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={nav.mode() === NavMode.COMMAND}
|
||||
|
||||
@@ -53,7 +53,11 @@ export function TabListPane(props: { muted?: boolean }) {
|
||||
? theme.border
|
||||
: undefined;
|
||||
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 (
|
||||
<For each={TAB_ORDER}>
|
||||
@@ -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) ─────────────────────────── */}
|
||||
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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 = () => (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* Renders entirely through `<PaneRow>` (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 (
|
||||
<box
|
||||
|
||||
@@ -29,6 +29,7 @@ const defaultSettings: AppSettings = {
|
||||
fontSize: 14,
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
transparentBackground: false,
|
||||
visualizer: defaultVisualizerSettings,
|
||||
};
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ export type AppSettings = {
|
||||
fontSize: number;
|
||||
playbackSpeed: number;
|
||||
downloadPath: string;
|
||||
/** Render the app background transparent (let the terminal's own bg show). */
|
||||
transparentBackground: boolean;
|
||||
visualizer: VisualizerSettings;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,10 +13,12 @@ export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
export type ThemeJson = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Record<string, ColorValue> & {
|
||||
theme: Record<string, ColorValue | boolean> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
/** Render the app background transparent (let the terminal's own bg show). */
|
||||
transparent?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -32,6 +32,7 @@ const defaultSettings: AppSettings = {
|
||||
fontSize: 14,
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
transparentBackground: false,
|
||||
visualizer: defaultVisualizerSettings,
|
||||
};
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string, RGBA>
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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("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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
89
tests/system-theme.test.ts
Normal file
89
tests/system-theme.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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: () => <text>ITEM</text>,
|
||||
@@ -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: () => <text>x</text>,
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user