6 Commits

Author SHA1 Message Date
b0bfa41028 bump VERSION to 0.3.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m12s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-09 22:24:58 -04:00
25307f83e9 fix: up highlight made legible for transparent bg, bring back mouse nav 2026-08-09 22:21:30 -04:00
db285530b6 feat: private feeds, all input fields supersede keyboard nav 2026-08-09 15:39:02 -04:00
e1cdd6b2a5 finished hygenie 2026-08-09 14:38:33 -04:00
2abdbaa4e9 cleaning up code 2026-08-09 09:54:52 -04:00
1d06156b8b docs: repoint Homebrew tap to mikefreno/tap after repo rename 2026-08-09 09:19:05 -04:00
93 changed files with 1109 additions and 3130 deletions

View File

@@ -159,11 +159,11 @@ Releases are built and published from **tags**
4. A release is auto-created with all 4 tarballs attached. `brew` never
sees the new version: the **tap self-updates**: the
`mikefreno/homebrew-podtui` repo has a scheduled workflow (hourly) that
`mikefreno/homebrew-tap` repo has a scheduled workflow (hourly) that
polls GitHub releases, and when a new tag appears, rewrites
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
test: `brew install mikefreno/podtui/podtui`.
test: `brew install mikefreno/tap/podtui`.
5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is
staged, not yet published (AUR account registrations are closed; see the
README note in section 3). On each release, keep the AUR sources in sync
@@ -177,7 +177,7 @@ Releases are built and published from **tags**
If you ever need to sync the tap by hand (or before the hourly job runs):
```bash
cd <clone of mikefreno/homebrew-podtui>
cd <clone of mikefreno/homebrew-tap>
./scripts/sync-formula.sh 0.2.0
git commit -am 'podtui 0.2.0' && git push
```

View File

@@ -47,7 +47,7 @@ Linux (arm64/x64). Pick whichever fits your platform.
### 1. Homebrew (macOS)
```sh
brew install mikefreno/podtui/podtui # requires mpv: brew install mpv
brew install mikefreno/tap/podtui # requires mpv: brew install mpv
```
> The formula installs the standalone binary plus its two native libraries

BIN
bun.lockb

Binary file not shown.

View File

@@ -18,21 +18,15 @@
},
"devDependencies": {
"@types/bun": "latest",
"@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2",
"typescript": "^5.9.3"
},
"dependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@opentui/core": "^0.1.77",
"@opentui/solid": "^0.1.77",
"babel-preset-solid": "1.9.9",
"date-fns": "^4.1.0",
"solid-js": "^1.9.9",
"uuid": "^13.0.0",
"zustand": "^5.0.11"
"solid-js": "^1.9.9"
}
}

View File

@@ -132,7 +132,7 @@ for r in $REMOTES; do
done
echo ""
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
echo "binaries + GitHub Release) and the homebrew-podtui tap update.${NC}"
echo "binaries + GitHub Release) and the homebrew-tap tap update.${NC}"
echo ""
read -p "Proceed? (y/n) " -n 1 -r
echo ""
@@ -236,5 +236,5 @@ echo ""
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}"
echo " 2. mikefreno/homebrew-podtui self-updates within the hour (Formula"
echo " 2. mikefreno/homebrew-tap self-updates within the hour (Formula"
echo " URLs + sha256s); brew upgrade podtui afterwards."

View File

@@ -205,7 +205,7 @@ function parseFlags(rest: string[]): {
} else if (a === "--from") {
flags.from = rest[++i];
} else {
flags[a.slice(2)] = rest[++i] ?? true;
throw new Error(`unknown flag: ${a}`);
}
} else {
positional.push(a);
@@ -222,52 +222,68 @@ function parseMods(positional: string[]): Mod[] {
return mods;
}
function buildAction(cmd: string, positional: string[]): Action | null {
// Per-command builders. Leading positional tokens that name a modifier
// (ctrl/shift/...) are stripped as mods; the rest is the command's data.
const modsOrUndefined = (positional: string[]): Mod[] | undefined => {
const mods = parseMods(positional);
const first = positional[0];
switch (cmd) {
case "key":
if (!first) throw new Error("key requires a <key> argument");
return { t: "key", k: first, mods: mods.length ? mods : undefined };
case "arrow":
if (!first || !["up", "down", "left", "right"].includes(first))
throw new Error("arrow requires up|down|left|right");
return {
t: "arrow",
d: first as any,
mods: mods.length ? mods : undefined,
};
case "enter":
case "escape":
case "tab":
case "space":
case "backspace":
return { t: cmd, mods: mods.length ? mods : undefined };
case "type":
if (first === undefined) throw new Error("type requires <text>");
// Re-join the rest in case text had spaces; positional[0] already is first token,
// caller should quote. We join all positional as the text.
return { t: "type", s: positional.join(" ") };
case "wait":
if (!first) throw new Error("wait requires <ms>");
return { t: "wait", ms: parseInt(first, 10) || 0 };
case "resize":
if (!first || !positional[1]) throw new Error("resize requires <w> <h>");
return {
t: "resize",
w: parseInt(first, 10) || 100,
h: parseInt(positional[1], 10) || 30,
};
case "frame":
case "state":
case "reset":
case "actions":
case "init":
case "seed":
return null;
default:
throw new Error(`unknown command: ${cmd}`);
}
return mods.length ? mods : undefined;
};
const BUILDERS: Record<string, (positional: string[]) => Action> = {
key: (p) => {
if (!p[0]) throw new Error("key requires a <key> argument");
return { t: "key", k: p[0], mods: modsOrUndefined(p) };
},
arrow: (p) => {
if (!p[0] || !["up", "down", "left", "right"].includes(p[0]))
throw new Error("arrow requires up|down|left|right");
return {
t: "arrow",
d: p[0] as "up" | "down" | "left" | "right",
mods: modsOrUndefined(p),
};
},
enter: (p) => ({ t: "enter", mods: modsOrUndefined(p) }),
escape: (p) => ({ t: "escape", mods: modsOrUndefined(p) }),
tab: (p) => ({ t: "tab", mods: modsOrUndefined(p) }),
space: (p) => ({ t: "space", mods: modsOrUndefined(p) }),
backspace: (p) => ({ t: "backspace", mods: modsOrUndefined(p) }),
type: (p) => {
if (p[0] === undefined) throw new Error("type requires <text>");
// Re-join the rest in case text had spaces; p[0] already is first token,
// caller should quote. We join all positional as the text.
return { t: "type", s: p.join(" ") };
},
wait: (p) => {
if (!p[0]) throw new Error("wait requires <ms>");
return { t: "wait", ms: parseInt(p[0], 10) || 0 };
},
resize: (p) => {
if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>");
return {
t: "resize",
w: parseInt(p[0], 10) || 100,
h: parseInt(p[1], 10) || 30,
};
},
};
function buildAction(cmd: string, positional: string[]): Action | null {
const builder = BUILDERS[cmd];
if (builder) return builder(positional);
// Local-only commands return early in main before this is reached; keep
// the null contract so the public behavior is unchanged.
if (
cmd === "frame" ||
cmd === "state" ||
cmd === "reset" ||
cmd === "actions" ||
cmd === "init" ||
cmd === "seed"
)
return null;
// Single table-miss error for any unknown command.
throw new Error(`unknown command: ${cmd}`);
}
// ── Execute one action against a mounted setup ──────────────────────────────
@@ -317,26 +333,53 @@ async function execAction(setup: any, a: Action): Promise<void> {
await new Promise((r) => setTimeout(r, 40));
}
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
activateSandbox();
captureIssues();
// ── Mount, snapshot & output (extracted from main) ─────────────────────────
// A line is "visually empty" if it's either fully blank OR contains only
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
// 24-row pane costs 1 line, not 18.
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
const isVisuallyEmpty = (l: string): boolean =>
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
const argv = process.argv.slice(2);
const cmd = argv[0] ?? "frame";
const { flags, positional } = parseFlags(argv.slice(1));
function trimFrame(plainFrame: string): string {
const lines = plainFrame
.replace(/\n+$/, "")
.split("\n")
.map((l) => l.replace(/\s+$/, ""));
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
lines.pop();
const out: string[] = [];
let blank = 0;
const flushBlanks = () => {
if (blank >= 3) out.push(`${blank} empty`);
else for (let i = 0; i < blank; i++) out.push("");
blank = 0;
};
for (const l of lines) {
if (isVisuallyEmpty(l)) {
blank++;
} else {
flushBlanks();
out.push(l);
}
}
flushBlanks();
return out.join("\n");
}
// Local-only commands that don't mount.
// Local-only commands that don't mount. Returns true if handled (main returns).
function runLocal(cmd: string, flags: Record<string, string | boolean>): boolean {
if (cmd === "reset") {
saveActions([]);
console.log("✔ actions log cleared.");
return;
return true;
}
if (cmd === "actions") {
const a = loadActions();
console.log(`Action log (${a.length}):`);
console.log(JSON.stringify(a, null, 2));
return;
return true;
}
if (cmd === "seed") {
const from = String(
@@ -349,9 +392,29 @@ async function main() {
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
cpSync(from, dest, { recursive: true });
console.log(`✔ seeded sandbox config from ${from}${dest}`);
return;
return true;
}
return false;
}
type FrameCapture = {
lines: { spans: Span[] }[];
cols: number;
rows: number;
cursor: [number, number];
};
async function mountApp(
flags: Record<string, string | boolean>,
cmd: string,
positional: string[],
): Promise<{
setup: any;
spans: FrameCapture;
plainFrame: string;
audioControls: any;
actions: Action[];
}> {
// Size settings.
let width = 100;
let height = 30;
@@ -448,12 +511,7 @@ async function main() {
// Final settle + capture.
await setup.renderOnce();
await new Promise((r) => setTimeout(r, 60));
const spans = setup.captureSpans() as {
lines: { spans: Span[] }[];
cols: number;
rows: number;
cursor: [number, number];
};
const spans = setup.captureSpans() as FrameCapture;
const plainFrame = setup.captureCharFrame();
// Dump structured spans + plain frame.
@@ -462,6 +520,10 @@ async function main() {
writeFileSync(FRAME_TXT, plainFrame);
} catch {}
return { setup, spans, plainFrame, audioControls, actions };
}
async function snapshotState(audioControls: any): Promise<Record<string, unknown>> {
// Store state snapshot.
const state: Record<string, unknown> = {};
try {
@@ -514,54 +576,31 @@ async function main() {
try {
writeFileSync(STATE_JSON, JSON.stringify(state));
} catch {}
return state;
}
// ── Output ──────────────────────────────────────────────────────────────
function emitOutput(p: {
spans: FrameCapture;
plainFrame: string;
state: Record<string, unknown>;
actions: Action[];
cmd: string;
flags: Record<string, string | boolean>;
positional: string[];
}): void {
// Compact by default: trimmed frame, one-line state per section, no styles
// block, no boilerplate footer. Use --styles / --verbose to opt back in.
const verbose = !!flags.verbose;
const scope = cmd === "state" ? String(positional[0] || "all") : "all";
// A line is "visually empty" if it's either fully blank OR contains only
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
// 24-row pane costs 1 line, not 18.
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
const isVisuallyEmpty = (l: string): boolean =>
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
const frameTrimmed = (() => {
const lines = plainFrame
.replace(/\n+$/, "")
.split("\n")
.map((l) => l.replace(/\s+$/, ""));
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
lines.pop();
const out: string[] = [];
let blank = 0;
const flushBlanks = () => {
if (blank >= 3) out.push(`${blank} empty`);
else for (let i = 0; i < blank; i++) out.push("");
blank = 0;
};
for (const l of lines) {
if (isVisuallyEmpty(l)) {
blank++;
} else {
flushBlanks();
out.push(l);
}
}
flushBlanks();
return out.join("\n");
})();
const verbose = !!p.flags.verbose;
const scope = p.cmd === "state" ? String(p.positional[0] || "all") : "all";
console.log(
`FRAME ${spans.cols}x${spans.rows} cur=${spans.cursor[0]},${spans.cursor[1]} acts=${actions.length} ${cmd}`,
`FRAME ${p.spans.cols}x${p.spans.rows} cur=${p.spans.cursor[0]},${p.spans.cursor[1]} acts=${p.actions.length} ${p.cmd}`,
);
console.log(frameTrimmed);
console.log(trimFrame(p.plainFrame));
// ── distinct styles: opt-in only (--styles OR --verbose) ──
if (scope === "all" && (flags.styles || verbose)) {
const styles = distinctStyles(spans);
if (scope === "all" && (p.flags.styles || verbose)) {
const styles = distinctStyles(p.spans);
if (styles.length) {
console.log("-- styles (top 20) --");
for (const s of styles) console.log(` ${s.tag} ×${s.n}${s.sample}`);
@@ -572,9 +611,9 @@ async function main() {
const want = (k: string) => scope === "all" || scope === k;
const compact = (obj: unknown): string =>
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
if (want("nav")) console.log("nav " + compact(state.nav));
if (want("audio")) console.log("audio " + compact(state.audio));
if (want("feed")) console.log("feed " + compact(state.feed));
if (want("nav")) console.log("nav " + compact(p.state.nav));
if (want("audio")) console.log("audio " + compact(p.state.audio));
if (want("feed")) console.log("feed " + compact(p.state.feed));
if (want("app")) console.log("app (not dumped in v1)");
// ── issues: terse ──
@@ -586,12 +625,14 @@ async function main() {
}
// Footer is identical every run — only print on init or --verbose.
if (cmd === "init" || verbose) {
if (p.cmd === "init" || verbose) {
console.log(
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
);
}
}
async function teardown(setup: any, audioControls: any): Promise<void> {
// Tear down child processes (audio backend) before exit to avoid orphans.
try {
if (audioControls?.stop) await audioControls.stop().catch(() => {});
@@ -606,6 +647,32 @@ async function main() {
process.exit(0);
}
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
activateSandbox();
captureIssues();
const argv = process.argv.slice(2);
const cmd = argv[0] ?? "frame";
const { flags, positional } = parseFlags(argv.slice(1));
// Local-only commands that don't mount.
if (runLocal(cmd, flags)) return;
const m = await mountApp(flags, cmd, positional);
const state = await snapshotState(m.audioControls);
emitOutput({
spans: m.spans,
plainFrame: m.plainFrame,
state,
actions: m.actions,
cmd,
flags,
positional,
});
await teardown(m.setup, m.audioControls);
}
main().catch((err) => {
console.error("HARNESS FAILED:", err?.stack || err);
process.exit(1);

View File

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

View File

@@ -1,73 +0,0 @@
import type { Feed } from "../types/feed"
import type { Episode } from "../types/episode"
import type { Podcast } from "../types/podcast"
import type { PodcastSource } from "../types/source"
import { parseRSSFeed } from "@/api/rss-parser"
import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler"
export const fetchEpisodes = async (feedUrl: string): Promise<Episode[]> => {
try {
const response = await fetch(feedUrl)
if (!response.ok) return []
const xml = await response.text()
return parseRSSFeed(xml, feedUrl).episodes
} catch {
return []
}
}
export const fetchFeeds = async (
sourceIds: string[],
sources: PodcastSource[]
): Promise<Feed[]> => {
const active = sources.filter((source) => sourceIds.includes(source.id))
const feeds: Feed[] = []
await Promise.all(
active.map(async (source) => {
try {
if (source.type === "rss") {
const rssFeeds = await handleRSSSource(source)
feeds.push(...rssFeeds)
} else if (source.type === "api") {
const apiFeeds = await handleAPISource(source, "")
feeds.push(...apiFeeds)
} else {
const customFeeds = await handleCustomSource(source, "")
feeds.push(...customFeeds)
}
} catch {
// ignore individual source errors
}
})
)
return feeds
}
export const searchPodcasts = async (
query: string,
sources: PodcastSource[]
): Promise<Podcast[]> => {
const results: Podcast[] = []
await Promise.all(
sources.map(async (source) => {
try {
if (source.type === "rss") {
const feeds = await handleRSSSource(source)
results.push(...feeds.map((feed: Feed) => feed.podcast))
} else if (source.type === "api") {
const feeds = await handleAPISource(source, query)
results.push(...feeds.map((feed: Feed) => feed.podcast))
} else {
const feeds = await handleCustomSource(source, query)
results.push(...feeds.map((feed: Feed) => feed.podcast))
}
} catch {
// ignore errors
}
})
)
return results
}

View File

@@ -1,94 +0,0 @@
import { FeedVisibility } from "../types/feed"
import type { Feed } from "../types/feed"
import type { PodcastSource } from "../types/source"
import type { Podcast } from "../types/podcast"
import { parseRSSFeed } from "./rss-parser"
const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => {
return {
id: `${sourceId}-${podcast.id}`,
podcast,
episodes: [],
visibility: FeedVisibility.PUBLIC,
sourceId,
lastUpdated: new Date(),
isPinned: false,
}
}
export const handleRSSSource = async (source: PodcastSource): Promise<Feed[]> => {
if (!source.baseUrl) return []
const response = await fetch(source.baseUrl)
if (!response.ok) return []
const xml = await response.text()
const parsed = parseRSSFeed(xml, source.baseUrl)
return [
{
id: `${source.id}-${parsed.feedUrl}`,
podcast: {
id: parsed.id,
title: parsed.title,
description: parsed.description,
feedUrl: parsed.feedUrl,
author: parsed.author,
categories: parsed.categories,
lastUpdated: parsed.lastUpdated,
isSubscribed: true,
},
episodes: parsed.episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: source.id,
lastUpdated: parsed.lastUpdated,
isPinned: false,
},
]
}
export const handleAPISource = async (
source: PodcastSource,
query: string
): Promise<Feed[]> => {
const url = new URL(source.baseUrl || "https://itunes.apple.com/search")
url.searchParams.set("term", query || "podcast")
url.searchParams.set("media", "podcast")
url.searchParams.set("entity", "podcast")
url.searchParams.set("country", source.country || "US")
url.searchParams.set("lang", source.language || "en_us")
const response = await fetch(url.toString())
if (!response.ok) return []
const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> }
const results = data.results ?? []
return results
.filter((item) => item.collectionName && item.feedUrl)
.map((item) => {
const podcast: Podcast = {
id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`,
title: item.collectionName || "Untitled Podcast",
description: item.collectionName || "",
feedUrl: item.feedUrl || "",
author: item.artistName,
lastUpdated: new Date(),
isSubscribed: false,
}
return buildFeedFromPodcast(podcast, source.id)
})
}
export const handleCustomSource = async (
source: PodcastSource,
query: string
): Promise<Feed[]> => {
if (!query) return []
const podcast: Podcast = {
id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`,
title: `${query} Highlights`,
description: `Curated results for ${query}`,
feedUrl: source.baseUrl || "",
author: source.name,
lastUpdated: new Date(),
isSubscribed: false,
}
return [buildFeedFromPodcast(podcast, source.id)]
}

View File

@@ -3,7 +3,6 @@ import { useTheme } from "@/context/ThemeContext";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
//TODO: Watch for actual loading state (fetching feeds)
export function LoadingIndicator() {
const { theme } = useTheme();
const [index, setIndex] = createSignal(0);

View File

@@ -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;
@@ -69,16 +69,13 @@ function resolveLabel(v: PaneLabel | undefined): string {
}
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
* We deliberately do NOT use Solid's `children()` helper here: that helper
* flattens accessor children into a stable resolved-nodes array and is the
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane
* switching between a depth-1 list fragment and a depth-2 editor — both
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy
* root swap, freezing the previous subtree in place. Instead we hand the
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below,
* which Solid compiles into a tracked `insert` effect that disposes the old
* subtree and mounts the new whenever the accessor returns a different
* element identity. */
* We deliberately avoid Solid's `children()` helper: it flattens accessor
* children into a stable resolved-nodes array and won't re-resolve on a
* truthy→truthy root swap (e.g. the current pane switching between a
* depth-1 list fragment and a depth-2 editor), freezing the previous
* subtree. Instead the raw accessor feeds a reactive `{ expr ?? <Placeholder/> }`
* expression — a tracked `insert` effect that disposes the old subtree and
* mounts the new whenever the accessor returns a different element identity. */
function normalizeContent(
v: PaneContent | undefined,
): () => JSX.Element | undefined {
@@ -102,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
@@ -118,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 ────────────────────────────────────────────── */}
@@ -127,22 +133,13 @@ function Pane(props: {
focused={scrollFocused()}
border
borderColor={borderColor()}
backgroundColor={theme.background}
backgroundColor={
themeContext.transparentBackground()
? "transparent"
: theme.background
}
>
{/*
* Render the content accessor directly via a reactive expression.
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
* effect that re-runs whenever the accessor's tracked signals
* change (e.g. `depth()` swapping the root from a list fragment to
* an editor). Solid disposes the previously-rendered subtree and
* mounts the new element identity. `null`/`undefined` falls back
* to the muted placeholder so the parent pane keeps its 1/7 slot
* visibly blank at depth 0. This is the correct tool for root
* swapping — unlike Solid's `children()` / `<Show>`-children,
* which only react to truthiness flips, not truthy<@->truthy root
* identity changes.
*/}
{props.content() ?? <Placeholder color={muted} />}
{props.content() ?? <Placeholder color={muted} />}
</scrollbox>
</box>
);
@@ -179,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}
@@ -195,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}

View File

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

View File

@@ -12,15 +12,16 @@
*/
import { createSignal, Show, For } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useKeyboard, useRenderer } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { useNavigation, NavMode } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { useAudioNavStore } from "@/stores/audio-nav";
import { useFeedStore } from "@/stores/feed";
import { useAppStore } from "@/stores/app";
import { useToast } from "@/ui/toast";
import { emit } from "@/utils/event-bus";
import { emit, on } from "@/utils/event-bus";
import { LayerGraph } from "@/utils/layer-graph";
import { TABS, TabPaneCount } from "@/utils/navigation";
import { createDispatcher } from "@/utils/dispatch";
@@ -42,12 +43,25 @@ export function Shell() {
const nav = useNavigation();
const k = useKeybinds();
const audio = useAudio();
const renderer = useRenderer();
const audioNav = useAudioNavStore();
const toast = useToast();
const feedStore = useFeedStore();
const [showHelp, setShowHelp] = createSignal(false);
// ── Auto jump to Player on podcast start ───────────────────────────────────
// Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see
// "player.started" — distinct from "player.play", which also fires on
// resume), switch to the Player tab and drop into its content pane.
on("player.started", () => {
const app = useAppStore();
if (app.state().preferences.autoJumpToPlayer) {
nav.setActiveTab(TABS.PLAYER);
nav.enterTabContent(); // PLAYER is a depth-tab — enter its content.
}
});
/** Play the episode adjacent (offset ±1) to the currently-playing one,
* within its feed's episode list. Updates audio-nav context accordingly. */
function advanceEpisode(offset: number) {
@@ -83,74 +97,60 @@ export function Shell() {
}
// ── Command bar dispatch ────────────────────────────────────────────────────
const COMMANDS: Record<string, (arg: string) => void> = {
quit: () => process.exit(0),
exit: () => process.exit(0),
q: () => process.exit(0),
refresh: () =>
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
}),
r: () =>
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
}),
play: () => audio.togglePlayback().catch(() => {}),
pause: () => audio.togglePlayback().catch(() => {}),
p: () => audio.togglePlayback().catch(() => {}),
next: () => advanceEpisode(1),
n: () => advanceEpisode(1),
prev: () => advanceEpisode(-1),
seek: (arg) => {
const n = Number(arg) || 0;
audio.seek(n).catch(() => {});
},
feed: () => nav.setActiveTab(TABS.FEED),
f: () => nav.setActiveTab(TABS.FEED),
shows: () => nav.setActiveTab(TABS.MYSHOWS),
myshows: () => nav.setActiveTab(TABS.MYSHOWS),
discover: () => nav.setActiveTab(TABS.DISCOVER),
d: () => nav.setActiveTab(TABS.DISCOVER),
search: () => nav.setActiveTab(TABS.SEARCH),
player: () => nav.setActiveTab(TABS.PLAYER),
settings: () => nav.setActiveTab(TABS.SETTINGS),
set: () => nav.setActiveTab(TABS.SETTINGS),
help: () => setShowHelp((v) => !v),
h: () => setShowHelp((v) => !v),
};
function runCommand(raw: string) {
const cmd = raw.trim();
if (!cmd) return;
const name = cmd.split(/\s+/)[0].toLowerCase();
const arg = cmd.slice(name.length).trim();
switch (name) {
case "q":
case "quit":
case "exit":
return process.exit(0);
case "refresh":
case "r":
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
});
break;
case "play":
case "pause":
case "p":
audio.togglePlayback().catch(() => {});
break;
case "next":
case "n":
advanceEpisode(1);
break;
case "prev":
advanceEpisode(-1);
break;
case "seek": {
const n = Number(arg) || 0;
audio.seek(n).catch(() => {});
break;
}
case "feed":
case "f":
nav.setActiveTab(TABS.FEED);
break;
case "shows":
case "myshows":
nav.setActiveTab(TABS.MYSHOWS);
break;
case "discover":
case "d":
nav.setActiveTab(TABS.DISCOVER);
break;
case "search":
nav.setActiveTab(TABS.SEARCH);
break;
case "player":
nav.setActiveTab(TABS.PLAYER);
break;
case "settings":
case "set":
nav.setActiveTab(TABS.SETTINGS);
break;
case "help":
case "h":
setShowHelp((v) => !v);
break;
default:
nav.setCommandError(`unknown command: ${name}`);
// re-enter command mode so the user sees the error + can correct
nav.enterCommand();
nav.setCommandBuffer(cmd);
}
const unknownCommand = () => {
nav.setCommandError(`unknown command: ${name}`);
// re-enter command mode so the user sees the error + can correct
nav.enterCommand();
nav.setCommandBuffer(cmd);
};
(COMMANDS[name] ?? unknownCommand)(arg);
}
// ── Command-mode key handling ───────────────────────────────────────────────
@@ -206,6 +206,11 @@ export function Shell() {
if (evt.name === "escape") {
evt.preventDefault();
nav.setInputFocused(false);
// Actually blur the focused renderable too — setting the flag alone
// leaves the opentui input owning keys, so nav keys would still be
// typed into it. Blurring fires our useInputFocusNav BLURRED handler
// (and re-blurs the SearchPage input via its `focused` prop).
renderer.currentFocusedRenderable?.blur();
}
return;
}
@@ -236,11 +241,13 @@ export function Shell() {
return (
<box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={t.surface}
>
flexDirection="column"
width="100%"
height="100%"
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%">
<Show
@@ -276,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}
@@ -458,18 +469,5 @@ function k_match_escape(evt: any): boolean {
);
}
/** Exposed so App can route an externally-triggered "play episode" (e.g. from
* search) into the player tab. */
export function playEpisodeAndSwitch(
nav: ReturnType<typeof useNavigation>,
audio: ReturnType<typeof useAudio>,
episode: import("@/types/episode").Episode,
) {
audio.play(episode);
nav.setActiveTab(TABS.PLAYER);
nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane.
useAudioNavStore().setSource(AudioSource.FEED);
}
// Re-export Episode type for callers building pane trees.
export type { Episode } from "@/types/episode";

View File

@@ -1,27 +0,0 @@
import { For } from "solid-js";
import { shortcuts } from "@/config/shortcuts";
import { useTheme } from "@/context/ThemeContext";
/** Yazi-style keybind reference. The Shell has its own overlay; this component
* is kept for embedding inside Settings or other surfaces. */
export function ShortcutHelp() {
const { theme } = useTheme();
return (
<box
border
title="Shortcuts"
style={{ flexDirection: "column", padding: 1 }}
>
<box style={{ flexDirection: "column" }}>
<For each={shortcuts}>
{(s) => (
<box style={{ flexDirection: "row" }} gap={2}>
<text fg={theme.accent}>{s.keys}</text>
<text fg={theme.text}>{s.action}</text>
</box>
)}
</For>
</box>
</box>
);
}

View File

@@ -1,55 +0,0 @@
import { useTheme } from "@/context/ThemeContext";
import { TABS, TabsCount } from "@/utils/navigation";
import { For } from "solid-js";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
export const tabs: TabDefinition[] = [
{ id: TABS.FEED, label: "Feed" },
{ id: TABS.MYSHOWS, label: "My Shows" },
{ id: TABS.DISCOVER, label: "Discover" },
{ id: TABS.SEARCH, label: "Search" },
{ id: TABS.PLAYER, label: "Player" },
{ id: TABS.SETTINGS, label: "Settings" },
];
export function TabNavigation() {
const { theme } = useTheme();
const { activeTab, setActiveTab, activeDepth } = useNavigation();
return (
<box
border
borderColor={activeDepth() !== 0 ? theme.border : theme.accent}
backgroundColor={"transparent"}
style={{
flexDirection: "column",
width: 12,
height: TabsCount * 3 + 2,
}}
>
<For each={tabs}>
{(tab) => (
<SelectableBox
border
height={3}
selected={() => tab.id == activeTab()}
onMouseDown={() => setActiveTab(tab.id)}
>
<SelectableText
selected={() => tab.id == activeTab()}
primary
alignSelf="center"
>
{tab.label}
</SelectableText>
</SelectableBox>
)}
</For>
</box>
);
}
export type TabDefinition = {
id: TABS;
label: string;
};

View File

@@ -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>

View File

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

View File

@@ -1,27 +0,0 @@
/**
* Yazi-style keybind reference (mirrors src/config/keybinds.jsonc).
* Shown in help overlays; the canonical source remains keybinds.jsonc.
* Edit that file (or ~/.config/podtui/keybinds.jsonc) to remap.
*/
export const shortcuts = [
{ keys: "j / k", action: "Move down / up (within pane)" },
{ keys: "h / l", action: "Swipe to prev / next pane" },
{ keys: "J / K", action: "Jump 5 lines down / up" },
{ keys: "ctrl-d / u", action: "Half page down / up" },
{ keys: "g g / G", action: "Go to top / bottom of list" },
{ keys: "1-6", action: "Go to tab 1-6" },
{ keys: "[ / ]", action: "Previous / next tab" },
{ keys: "Enter", action: "Open / activate focused item" },
{ keys: "Space", action: "Toggle selection on item" },
{ keys: "v", action: "Enter visual (range) select mode" },
{ keys: "ctrl-a / ctrl-r", action: "Select all / invert selection" },
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
{ keys: "r / s / f", action: "Refresh / search / filter" },
{ keys: "x", action: "Unsubscribe focused show (My Shows)" },
{ keys: ", / .", action: "Sort / toggle hidden" },
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
{ keys: "< / >", action: "Seek backward / forward 10s" },
{ keys: "~ / F1", action: "Help" },
{ keys: "q", action: "Quit" },
] as const;

View File

@@ -1,12 +0,0 @@
export const syncFormats = {
json: {
version: "1.0",
extension: ".json",
},
xml: {
version: "1.0",
extension: ".xml",
},
}
export const supportedSyncVersions = [syncFormats.json.version, syncFormats.xml.version]

View File

@@ -72,20 +72,7 @@ export type KeybindActionName =
| "audio-next"
| "audio-prev"
| "audio-seek-forward"
| "audio-seek-backward"
// legacy compat (kept so older callers don't crash)
| "select"
| "leader"
| "inverseModifier"
| "cycle"
| "dive"
| "out"
| "up"
| "down"
| "left"
| "right"
| "audio-pause"
| "audio-play";
| "audio-seek-backward";
/** Resolved config: action -> list of alternative stroke-sequences. */
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
@@ -146,7 +133,7 @@ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
}
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
export function strokeFromEvent(evt: {
function strokeFromEvent(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
@@ -154,7 +141,7 @@ export function strokeFromEvent(evt: {
}): Stroke {
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
return {
key: (evt.name ?? "").toLowerCase(),
key: evt.name.toLowerCase(),
ctrl: !!evt.ctrl,
shift: !!evt.shift,
meta: !!evt.meta,
@@ -171,7 +158,7 @@ function strokeEq(a: Stroke, b: Stroke): boolean {
}
/** A human label for a stroke, for the status bar / help. */
export function strokeLabel(s: Stroke): string {
function strokeLabel(s: Stroke): string {
let out = "";
if (s.ctrl) out += "C-";
if (s.meta) out += "M-";
@@ -180,7 +167,7 @@ export function strokeLabel(s: Stroke): string {
return out;
}
export function sequenceLabel(seq: Stroke[]): string {
function sequenceLabel(seq: Stroke[]): string {
return seq.map(strokeLabel).join(" ");
}
@@ -338,17 +325,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
return best;
}
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
// so it always reports false. Migrated callers should use tryMatch().
function isInverting(_evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): boolean {
return false;
}
onMount(() => {
load().catch(() => {});
});
@@ -366,7 +342,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
pending,
match,
tryMatch,
isInverting,
print,
save,
load,

View File

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

View File

@@ -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
@@ -488,11 +492,7 @@ export function createNavigation() {
exitVisual,
// modes
setActiveTabSignal: setActiveTab,
setActiveDepth: setPane, // legacy alias
activeDepth: activePane, // legacy alias
setInputFocused,
nextPane: () => {}, // legacy noop; swipe() replaces this
prevPane: () => {},
setMode,
enterCommand,
enterInput,

View File

@@ -138,7 +138,6 @@ function startPolling(): void {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
// Update platform media position
const media = useMediaRegistry();
media.setPosition(pos);
}
@@ -215,6 +214,9 @@ async function play(episode: Episode): Promise<void> {
startPolling();
emit("player.play", { episodeId: episode.id });
// Distinct from "player.play" (which also fires on resume): signals a
// fresh episode start so Shell can honor the auto-jump-to-player pref.
emit("player.started", { episodeId: episode.id });
} catch (err) {
setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false);
@@ -285,7 +287,6 @@ async function stop(): Promise<void> {
stopPolling();
emit("player.stop", {});
// Clear platform media controls
const media = useMediaRegistry();
media.clearNowPlaying();
} catch (err) {
@@ -332,12 +333,8 @@ async function doSetSpeed(spd: number): Promise<void> {
setSpeed(clamped);
// Sync back to app store
try {
const appStore = useAppStore();
appStore.updateSettings({ playbackSpeed: clamped });
} catch {
// Store may not be available
}
const appStore = useAppStore();
appStore.updateSettings({ playbackSpeed: clamped });
}
async function switchBackend(name: BackendName): Promise<void> {
@@ -347,14 +344,12 @@ async function switchBackend(name: BackendName): Promise<void> {
const vol = volume();
const spd = speed();
// Stop current backend
if (backend) {
stopPolling();
backend.dispose();
backend = null;
}
// Create new backend
backend = createAudioBackend(name);
setBackendName(backend.name);
setAvailablePlayers(detectPlayers());
@@ -388,14 +383,10 @@ export function useAudio(): AudioControls {
// Sync initial speed from app store
if (refCount === 0) {
try {
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
if (storeSpeed && storeSpeed !== speed()) {
setSpeed(storeSpeed);
}
} catch {
// Store may not be available yet
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
if (storeSpeed && storeSpeed !== speed()) {
setSpeed(storeSpeed);
}
}

View File

@@ -1,34 +0,0 @@
import { createSignal, onCleanup } from "solid-js"
type CacheOptions<T> = {
fetcher: () => Promise<T>
intervalMs?: number
}
export const useCachedData = <T,>(options: CacheOptions<T>) => {
const [data, setData] = createSignal<T | null>(null)
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const refresh = async () => {
setLoading(true)
setError(null)
try {
const value = await options.fetcher()
setData(() => value)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data")
} finally {
setLoading(false)
}
}
refresh()
if (options.intervalMs) {
const interval = setInterval(refresh, options.intervalMs)
onCleanup(() => clearInterval(interval))
}
return { data, loading, error, refresh }
}

View File

@@ -0,0 +1,65 @@
/**
* useInputFocusNav — returns a `ref` callback for an `<input>` (or any
* focusable renderable) that holds the navigation store's `inputFocused`
* flag true while the renderable has focus.
*
* Why: the Shell keyboard router (see `components/Shell.tsx`) yields keys to
* whatever is focused only when `nav.inputFocused()` is true; otherwise it
* dispatches navigation keybinds (j/k/h/…). Forms rendered inside the
* depth-stack (e.g. the Settings "Add Source" RSS form) don't drive that
* flag, so typing into them *also* fired the navigation keybinds. Wiring the
* flag to each input's real focus/blur state fixes that.
*
* A module-level counter guards the blur→focus ordering gap that occurs when
* tabbing between two inputs in the same form (the old input blurs before the
* new one focuses) so the flag never flickers off mid-handoff.
*/
import { onCleanup } from "solid-js";
import { RenderableEvents } from "@opentui/core";
import { useNavigation } from "@/context/NavigationContext";
// Inputs (managed by this hook) currently holding focus.
let focusedCount = 0;
export function useInputFocusNav() {
const nav = useNavigation();
let current: any | undefined;
const onFocused = () => {
focusedCount++;
nav.setInputFocused(true);
};
const onBlurred = () => {
focusedCount = Math.max(0, focusedCount - 1);
if (focusedCount === 0) nav.setInputFocused(false);
};
const detach = (el: any) => {
el.off(RenderableEvents.FOCUSED, onFocused);
el.off(RenderableEvents.BLURRED, onBlurred);
// Treat a focused element being torn down as a blur so the counter
// doesn't leak and leave inputFocused stuck on.
if (el.focused) onBlurred();
};
const ref = (el: any) => {
if (current && current !== el) detach(current);
current = el;
if (el) {
el.on(RenderableEvents.FOCUSED, onFocused);
el.on(RenderableEvents.BLURRED, onBlurred);
// If the renderable is already focused when attached, count it.
if (el.focused) onFocused();
}
};
onCleanup(() => {
if (current) {
detach(current);
current = undefined;
}
});
return ref;
}

View File

@@ -21,20 +21,6 @@ export type MediaKeyAction =
| "media.seekBackward"
| "media.speedCycle";
/** Key-to-action mappings for multimedia controls */
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
// Common terminal media keys — these overlap with Player.tsx local
// bindings, but Player guards on `props.focused` so the global
// handler fires independently when the player tab is *not* active.
//
// When Player IS focused both handlers fire, but since the audio
// actions are idempotent (toggle = toggle, seek = additive) having
// them called twice for the same keypress is avoided by the event
// bus approach — the audio hook only processes event-bus events, and
// Player.tsx calls audio methods directly. We therefore guard with
// a "playerFocused" flag passed via options.
};
export interface MultimediaKeysOptions {
/** When true, skip handling (Player.tsx handles keys locally) */
playerFocused?: () => boolean;

View File

@@ -1,4 +1,7 @@
const VERSION = "0.2.1";
import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.3.0";
interface CliArgs {
version: boolean;
@@ -37,160 +40,173 @@ if (cliArgs.version) {
process.exit(0);
}
// ── CLI handlers ──────────────────────────────────────────────────────
/** Find the most recent episode across all feeds */
function findLatestEpisode(
feeds: Feed[],
): { feed: Feed; episode: Episode } | null {
let latest: { feed: Feed; episode: Episode } | null = null
let latestDate = 0
for (const feed of feeds) {
if (feed.episodes.length === 0) continue
const ep = feed.episodes[0]
const epDate =
ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate)
if (epDate > latestDate) {
latestDate = epDate
latest = { feed, episode: ep }
}
}
return latest
}
/** Search feeds by title and print matching shows */
function handleQuery(feeds: Feed[], query: string): void {
const normalizedQuery = query.toLowerCase()
const matches = feeds.filter((feed) => {
const title = feed.podcast.title.toLowerCase()
return title.includes(normalizedQuery)
})
if (matches.length === 0) {
console.log(`No shows found matching: ${query}`)
if (feeds.length > 0) {
console.log("\nAvailable shows:")
feeds.slice(0, 5).forEach((feed) => {
console.log(` - ${feed.podcast.title}`)
})
if (feeds.length > 5) {
console.log(` ... and ${feeds.length - 5} more`)
}
}
process.exit(0)
}
if (matches.length === 1) {
const feed = matches[0]
console.log(`\n${feed.podcast.title}`)
if (feed.podcast.description) {
console.log(
feed.podcast.description.substring(0, 200) +
(feed.podcast.description.length > 200 ? "..." : ""),
)
}
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`)
feed.episodes.slice(0, 5).forEach((ep, idx) => {
const date =
ep.pubDate instanceof Date
? ep.pubDate.toLocaleDateString()
: String(ep.pubDate)
console.log(` ${idx + 1}. ${ep.title} (${date})`)
})
process.exit(0)
}
console.log(`\nClosest matches for "${query}":`)
matches.slice(0, 5).forEach((feed, idx) => {
console.log(` ${idx + 1}. ${feed.podcast.title}`)
})
process.exit(0)
}
/** Resolve and play an episode from `arg` (title path or "latest") */
async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
const normalizedArg = arg.toLowerCase()
let feedResult: Feed | null = null
let episodeResult: Episode | null = null
if (normalizedArg === "latest") {
const latest = findLatestEpisode(feeds)
if (latest) {
feedResult = latest.feed
episodeResult = latest.episode
}
} else {
const parts = normalizedArg.split("/")
const showQuery = parts[0]
const episodeQuery = parts[1]
const matchingFeeds = feeds.filter((feed) =>
feed.podcast.title.toLowerCase().includes(showQuery),
)
if (matchingFeeds.length === 0) {
console.log(`No show found matching: ${showQuery}`)
process.exit(1)
}
const feed = matchingFeeds[0]
if (!episodeQuery) {
if (feed.episodes.length > 0) {
feedResult = feed
episodeResult = feed.episodes[0]
} else {
console.log(`No episodes available for: ${feed.podcast.title}`)
process.exit(1)
}
} else if (episodeQuery === "latest") {
feedResult = feed
episodeResult = feed.episodes[0]
} else {
const matchingEpisode = feed.episodes.find((ep) =>
ep.title.toLowerCase().includes(episodeQuery),
)
if (matchingEpisode) {
feedResult = feed
episodeResult = matchingEpisode
} else {
console.log(`Episode not found: ${episodeQuery}`)
console.log(`Available episodes for ${feed.podcast.title}:`)
feed.episodes.slice(0, 5).forEach((ep, idx) => {
console.log(` ${idx + 1}. ${ep.title}`)
})
process.exit(1)
}
}
}
if (!feedResult || !episodeResult) {
console.log("Could not find episode to play")
process.exit(1)
}
console.log(`\nPlaying: ${episodeResult.title}`)
console.log(`Show: ${feedResult.podcast.title}`)
try {
const { createAudioBackend } = await import("./utils/audio-player")
const backend = createAudioBackend()
if (episodeResult.audioUrl) {
await backend.play(episodeResult.audioUrl)
console.log("Playback started (use the UI to control)")
} else {
console.log("No audio URL available for this episode")
process.exit(1)
}
} catch (err) {
console.error("Playback error:", err)
process.exit(1)
}
}
if (cliArgs.query !== null || cliArgs.play !== null) {
import("./utils/feeds-persistence")
.then(async ({ loadFeedsFromFile }) => {
const feeds = await loadFeedsFromFile();
if (cliArgs.query !== null) {
const query = cliArgs.query;
const normalizedQuery = query.toLowerCase();
const matches = feeds.filter((feed) => {
const title = feed.podcast.title.toLowerCase();
return title.includes(normalizedQuery);
});
if (matches.length === 0) {
console.log(`No shows found matching: ${query}`);
if (feeds.length > 0) {
console.log("\nAvailable shows:");
feeds.slice(0, 5).forEach((feed) => {
console.log(` - ${feed.podcast.title}`);
});
if (feeds.length > 5) {
console.log(` ... and ${feeds.length - 5} more`);
}
}
process.exit(0);
}
if (matches.length === 1) {
const feed = matches[0];
console.log(`\n${feed.podcast.title}`);
if (feed.podcast.description) {
console.log(
feed.podcast.description.substring(0, 200) +
(feed.podcast.description.length > 200 ? "..." : ""),
);
}
console.log(
`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`,
);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
const date =
ep.pubDate instanceof Date
? ep.pubDate.toLocaleDateString()
: String(ep.pubDate);
console.log(` ${idx + 1}. ${ep.title} (${date})`);
});
process.exit(0);
}
console.log(`\nClosest matches for "${query}":`);
matches.slice(0, 5).forEach((feed, idx) => {
console.log(` ${idx + 1}. ${feed.podcast.title}`);
});
process.exit(0);
handleQuery(feeds, cliArgs.query)
}
if (cliArgs.play !== null) {
const playArg = cliArgs.play;
const normalizedArg = playArg.toLowerCase();
let feedResult: (typeof feeds)[0] | null = null;
let episodeResult: (typeof feeds)[0]["episodes"][0] | null = null;
if (normalizedArg === "latest") {
let latestFeed: (typeof feeds)[0] | null = null;
let latestEpisode: (typeof feeds)[0]["episodes"][0] | null = null;
let latestDate = 0;
for (const feed of feeds) {
if (feed.episodes.length > 0) {
const ep = feed.episodes[0];
const epDate =
ep.pubDate instanceof Date
? ep.pubDate.getTime()
: Number(ep.pubDate);
if (epDate > latestDate) {
latestDate = epDate;
latestFeed = feed;
latestEpisode = ep;
}
}
}
feedResult = latestFeed;
episodeResult = latestEpisode;
} else {
const parts = normalizedArg.split("/");
const showQuery = parts[0];
const episodeQuery = parts[1];
const matchingFeeds = feeds.filter((feed) =>
feed.podcast.title.toLowerCase().includes(showQuery),
);
if (matchingFeeds.length === 0) {
console.log(`No show found matching: ${showQuery}`);
process.exit(1);
}
const feed = matchingFeeds[0];
if (!episodeQuery) {
if (feed.episodes.length > 0) {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
console.log(`No episodes available for: ${feed.podcast.title}`);
process.exit(1);
}
} else if (episodeQuery === "latest") {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
const matchingEpisode = feed.episodes.find((ep) =>
ep.title.toLowerCase().includes(episodeQuery),
);
if (matchingEpisode) {
feedResult = feed;
episodeResult = matchingEpisode;
} else {
console.log(`Episode not found: ${episodeQuery}`);
console.log(`Available episodes for ${feed.podcast.title}:`);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
console.log(` ${idx + 1}. ${ep.title}`);
});
process.exit(1);
}
}
}
if (!feedResult || !episodeResult) {
console.log("Could not find episode to play");
process.exit(1);
}
console.log(`\nPlaying: ${episodeResult.title}`);
console.log(`Show: ${feedResult.podcast.title}`);
try {
const { createAudioBackend } = await import("./utils/audio-player");
const backend = createAudioBackend();
if (episodeResult.audioUrl) {
await backend.play(episodeResult.audioUrl);
console.log("Playback started (use the UI to control)");
} else {
console.log("No audio URL available for this episode");
process.exit(1);
}
} catch (err) {
console.error("Playback error:", err);
process.exit(1);
}
await handlePlay(feeds, cliArgs.play)
}
})
.catch((err) => {

View File

@@ -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
@@ -154,7 +158,6 @@ function DiscoverPage() {
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
const parentContent = () => (

View File

@@ -1,85 +0,0 @@
/**
* PodcastCard component - Reusable card for displaying podcast info
*/
import { Show, For } from "solid-js";
import type { Podcast } from "@/types/podcast";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
type PodcastCardProps = {
podcast: Podcast;
selected: boolean;
compact?: boolean;
onSelect?: () => void;
onSubscribe?: () => void;
};
export function PodcastCard(props: PodcastCardProps) {
const { theme } = useTheme();
const handleSubscribeClick = () => {
props.onSubscribe?.();
};
return (
<SelectableBox
selected={() => props.selected}
flexDirection="column"
padding={1}
onMouseDown={props.onSelect}
>
<box flexDirection="row" gap={2} alignItems="center">
<SelectableText selected={() => props.selected} primary>
<strong>{props.podcast.title}</strong>
</SelectableText>
<Show when={props.podcast.isSubscribed}>
<text fg={theme.success}>[+]</text>
</Show>
</box>
{/* Author */}
<Show when={props.podcast.author && !props.compact}>
<SelectableText
selected={() => props.selected}
tertiary
>
by {props.podcast.author}
</SelectableText>
</Show>
{/* Description */}
<Show when={props.podcast.description && !props.compact}>
<SelectableText
selected={() => props.selected}
tertiary
>
{props.podcast.description!.length > 80
? props.podcast.description!.slice(0, 80) + "..."
: props.podcast.description}
</SelectableText>
</Show>
{/**<box
flexDirection="row"
justifyContent="space-between"
marginTop={props.compact ? 0 : 1}
/>**/}
<box flexDirection="row" gap={1}>
<Show when={(props.podcast.categories ?? []).length > 0}>
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</Show>
</box>
<Show when={props.selected}>
<box onMouseDown={handleSubscribeClick}>
<text fg={props.podcast.isSubscribed ? theme.error : theme.success}>
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
</text>
</box>
</Show>
</SelectableBox>
);
}

View File

@@ -1,194 +0,0 @@
/**
* Feed detail view component for PodTUI
* Shows podcast info and episode list
*/
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import type { Feed } from "@/types/feed";
import type { Episode } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface FeedDetailProps {
feed: Feed;
focused?: boolean;
onBack?: () => void;
onPlayEpisode?: (episode: Episode) => void;
}
export function FeedDetail(props: FeedDetailProps) {
const { theme } = useTheme();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const [showInfo, setShowInfo] = createSignal(true);
const episodes = () => {
// Sort episodes by publication date (newest first)
return [...props.feed.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) {
return `${hrs}h ${mins % 60}m`;
}
return `${mins}m`;
};
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const handleKeyPress = (key: { name: string }) => {
const eps = episodes();
if (key.name === "escape" && props.onBack) {
props.onBack();
return;
}
if (key.name === "i") {
setShowInfo((v) => !v);
return;
}
if (key.name === "v") {
props.feed.podcast.onToggleVisibility?.(props.feed.id);
return;
}
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
} else if (key.name === "return") {
const episode = eps[selectedIndex()];
if (episode && props.onPlayEpisode) {
props.onPlayEpisode(episode);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(eps.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 10));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
}
};
useKeyboard((key) => {
if (!props.focused) return;
handleKeyPress(key);
});
return (
<box flexDirection="column" gap={1}>
{/* Header with back button */}
<box flexDirection="row" justifyContent="space-between">
<box border padding={0} onMouseDown={props.onBack} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[Esc] Back</SelectableText>
</box>
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[i] {showInfo() ? "Hide" : "Show"} Info</SelectableText>
</box>
<box border padding={0} onMouseDown={() => props.feed.podcast.onToggleVisibility?.(props.feed.id)} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[v] Toggle Visibility</SelectableText>
</box>
</box>
{/* Podcast info section */}
<Show when={showInfo()}>
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
<SelectableText selected={() => false} primary>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</SelectableText>
{props.feed.podcast.author && (
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>by</SelectableText>
<SelectableText selected={() => false} primary>{props.feed.podcast.author}</SelectableText>
</box>
)}
<box height={1} />
<SelectableText selected={() => false} tertiary>
{props.feed.podcast.description?.slice(0, 200)}
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
</SelectableText>
<box height={1} />
<box flexDirection="row" gap={2}>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Episodes:</SelectableText>
<SelectableText selected={() => false} tertiary>{props.feed.episodes.length}</SelectableText>
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Updated:</SelectableText>
<SelectableText selected={() => false} tertiary>{formatDate(props.feed.lastUpdated)}</SelectableText>
</box>
<SelectableText selected={() => false} tertiary>
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
</SelectableText>
{props.feed.isPinned && <SelectableText selected={() => false} tertiary>[Pinned]</SelectableText>}
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>[v] Toggle Visibility</SelectableText>
</box>
</box>
</Show>
{/* Episodes header */}
<box flexDirection="row" justifyContent="space-between">
<SelectableText selected={() => false} primary>
<strong>Episodes</strong>
</SelectableText>
<SelectableText selected={() => false} tertiary>({episodes().length} total)</SelectableText>
</box>
{/* Episode list */}
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
<For each={episodes()}>
{(episode, index) => (
<SelectableBox
selected={() => index() === selectedIndex()}
flexDirection="column"
gap={0}
padding={1}
onMouseDown={() => {
setSelectedIndex(index());
if (props.onPlayEpisode) {
props.onPlayEpisode(episode);
}
}}
>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{index() === selectedIndex() ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
{episode.title}
</SelectableText>
<box flexDirection="row" gap={2} paddingLeft={2}>
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDate(episode.pubDate)}</SelectableText>
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDuration(episode.duration)}</SelectableText>
</box>
</SelectableBox>
)}
</For>
</scrollbox>
{/* Help text */}
<text fg={theme.textMuted}>
j/k to navigate, Enter to play, i to toggle info, Esc to go back
</text>
</box>
);
}

View File

@@ -1,207 +0,0 @@
/**
* Feed filter component for PodTUI
* Toggle and filter options for feed list
*/
import { createSignal } from "solid-js";
import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { FeedFilter } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
interface FeedFilterProps {
filter: FeedFilter;
focused?: boolean;
onFilterChange: (filter: FeedFilter) => void;
}
type FilterField = "visibility" | "sort" | "pinned" | "private" | "search";
export function FeedFilterComponent(props: FeedFilterProps) {
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
const [searchValue, setSearchValue] = createSignal(
props.filter.searchQuery || "",
);
const fields: FilterField[] = ["visibility", "sort", "pinned", "private", "search"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "visibility") {
cycleVisibility();
} else if (focusField() === "sort") {
cycleSort();
} else if (focusField() === "pinned") {
togglePinned();
} else if (focusField() === "private") {
togglePrivate();
}
} else if (key.name === "space") {
if (focusField() === "pinned") {
togglePinned();
} else if (focusField() === "private") {
togglePrivate();
}
}
};
const cycleVisibility = () => {
const current = props.filter.visibility;
let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all";
props.onFilterChange({ ...props.filter, visibility: next });
};
const cycleSort = () => {
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
];
const currentIndex = sortOptions.indexOf(
props.filter.sortBy as FeedSortField,
);
const nextIndex = (currentIndex + 1) % sortOptions.length;
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] });
};
const togglePinned = () => {
props.onFilterChange({
...props.filter,
pinnedOnly: !props.filter.pinnedOnly,
});
};
const togglePrivate = () => {
props.onFilterChange({
...props.filter,
showPrivate: !props.filter.showPrivate,
});
};
const handleSearchInput = (value: string) => {
setSearchValue(value);
props.onFilterChange({ ...props.filter, searchQuery: value });
};
const visibilityLabel = () => {
const vis = props.filter.visibility;
if (vis === "all") return "All";
if (vis === "public") return "Public";
return "Private";
};
const visibilityColor = () => {
const vis = props.filter.visibility;
if (vis === "public") return theme.success;
if (vis === "private") return theme.warning;
return theme.text;
};
const sortLabel = () => {
const sort = props.filter.sortBy;
switch (sort) {
case "title":
return "Title";
case "episodeCount":
return "Episodes";
case "latestEpisode":
return "Latest";
case "updated":
default:
return "Updated";
}
};
return (
<box flexDirection="column" border padding={1} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>Filter Feeds</strong>
</text>
<box flexDirection="row" gap={2} flexWrap="wrap">
{/* Visibility filter */}
<box
border
padding={0}
backgroundColor={focusField() === "visibility" ? theme.backgroundElement : undefined}
borderColor={theme.border}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "visibility" ? theme.primary : theme.textMuted}>
Show:
</text>
<text fg={visibilityColor()}>{visibilityLabel()}</text>
</box>
</box>
{/* Sort filter */}
<box
border
padding={0}
backgroundColor={focusField() === "sort" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "sort" ? theme.primary : theme.textMuted}>Sort:</text>
<text fg={theme.text}>{sortLabel()}</text>
</box>
</box>
{/* Pinned filter */}
<box
border
padding={0}
backgroundColor={focusField() === "pinned" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "pinned" ? theme.primary : theme.textMuted}>
Pinned:
</text>
<text fg={props.filter.pinnedOnly ? theme.warning : theme.textMuted}>
{props.filter.pinnedOnly ? "Yes" : "No"}
</text>
</box>
</box>
{/* Private filter */}
<box
border
padding={0}
backgroundColor={focusField() === "private" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "private" ? theme.primary : theme.textMuted}>
Private:
</text>
<text fg={props.filter.showPrivate ? theme.warning : theme.textMuted}>
{props.filter.showPrivate ? "Yes" : "No"}
</text>
</box>
</box>
</box>
{/* Search box */}
<box flexDirection="row" gap={1}>
<text fg={focusField() === "search" ? theme.primary : theme.textMuted}>Search:</text>
<input
value={searchValue()}
onInput={handleSearchInput}
placeholder="Filter by name..."
focused={props.focused && focusField() === "search"}
width={25}
/>
</box>
<text fg={theme.textMuted}>Tab to navigate, Enter/Space to toggle</text>
</box>
);
}

View File

@@ -1,154 +0,0 @@
/**
* Feed item component for PodTUI
* Displays a single feed/podcast in the list
*/
import type { Feed, FeedVisibility } from "@/types/feed";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface FeedItemProps {
feed: Feed;
isSelected: boolean;
showEpisodeCount?: boolean;
showLastUpdated?: boolean;
compact?: boolean;
}
export function FeedItem(props: FeedItemProps) {
const formatDate = (date: Date): string => {
return format(date, "MMM d");
};
const episodeCount = () => props.feed.episodes.length;
const unplayedCount = () => {
// This would be calculated based on episode status
return props.feed.episodes.length;
};
const visibilityIcon = () => {
return props.feed.visibility === "public" ? "[P]" : "[*]";
};
const visibilityColor = () => {
return props.feed.visibility === "public" ? theme.success : theme.warning;
};
const pinnedIndicator = () => {
return props.feed.isPinned ? "*" : " ";
};
const { theme } = useTheme();
if (props.compact) {
// Compact single-line view
return (
<SelectableBox
selected={() => props.isSelected}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
onMouseDown={() => {}}
>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.isSelected ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
tertiary
>
{visibilityIcon()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.feed.customName || props.feed.podcast.title}
</SelectableText>
{props.showEpisodeCount && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
({episodeCount()})
</SelectableText>
)}
</SelectableBox>
);
}
// Full view with details
return (
<SelectableBox
selected={() => props.isSelected}
flexDirection="column"
gap={0}
padding={1}
onMouseDown={() => {}}
>
{/* Title row */}
<box flexDirection="row" gap={1}>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.isSelected ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
tertiary
>
{visibilityIcon()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
secondary
>
{pinnedIndicator()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
primary
>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</SelectableText>
</box>
<box flexDirection="row" gap={2} paddingLeft={4}>
{props.showEpisodeCount && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
{episodeCount()} episodes ({unplayedCount()} new)
</SelectableText>
)}
{props.showLastUpdated && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
Updated: {formatDate(props.feed.lastUpdated)}
</SelectableText>
)}
</box>
{props.feed.podcast.description && (
<SelectableText
selected={() => props.isSelected}
paddingLeft={4}
paddingTop={0}
tertiary
>
{props.feed.podcast.description.slice(0, 60)}
{props.feed.podcast.description.length > 60 ? "..." : ""}
</SelectableText>
)}
</SelectableBox>
);
}

View File

@@ -1,198 +0,0 @@
/**
* Feed list component for PodTUI
* Scrollable list of feeds with keyboard navigation and mouse support
*/
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { FeedItem } from "./FeedItem";
import { useFeedStore } from "@/stores/feed";
import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { Feed } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
interface FeedListProps {
focused?: boolean;
compact?: boolean;
showEpisodeCount?: boolean;
showLastUpdated?: boolean;
onSelectFeed?: (feed: Feed) => void;
onOpenFeed?: (feed: Feed) => void;
onFocusChange?: (focused: boolean) => void;
}
export function FeedList(props: FeedListProps) {
const { theme } = useTheme();
const feedStore = useFeedStore();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredFeeds = () => feedStore.getFilteredFeeds();
const handleKeyPress = (key: { name: string }) => {
if (key.name === "escape") {
props.onFocusChange?.(false);
return;
}
const feeds = filteredFeeds();
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
} else if (key.name === "return") {
const feed = feeds[selectedIndex()];
if (feed && props.onOpenFeed) {
props.onOpenFeed(feed);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(feeds.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 5));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
} else if (key.name === "p") {
// Toggle pin on selected feed
const feed = feeds[selectedIndex()];
if (feed) {
feedStore.togglePinned(feed.id);
}
} else if (key.name === "v") {
// Toggle visibility on selected feed
const feed = feeds[selectedIndex()];
if (feed) {
const newVisibility = feed.visibility === FeedVisibility.PUBLIC ? FeedVisibility.PRIVATE : FeedVisibility.PUBLIC;
feedStore.updateFeed(feed.id, { visibility: newVisibility });
}
} else if (key.name === "f") {
// Cycle visibility filter
cycleVisibilityFilter();
} else if (key.name === "s") {
// Cycle sort
cycleSortField();
}
// Notify selection change
const selectedFeed = feeds[selectedIndex()];
if (selectedFeed && props.onSelectFeed) {
props.onSelectFeed(selectedFeed);
}
};
useKeyboard((key) => {
if (!props.focused) return;
handleKeyPress(key);
});
const cycleVisibilityFilter = () => {
const current = feedStore.filter().visibility;
let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all";
feedStore.setFilter({ ...feedStore.filter(), visibility: next });
};
const cycleSortField = () => {
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
];
const current = feedStore.filter().sortBy as FeedSortField;
const idx = sortOptions.indexOf(current);
const next = sortOptions[(idx + 1) % sortOptions.length];
feedStore.setFilter({ ...feedStore.filter(), sortBy: next });
};
const visibilityLabel = () => {
const vis = feedStore.filter().visibility;
if (vis === "all") return "All";
if (vis === "public") return "Public";
return "Private";
};
const sortLabel = () => {
const sort = feedStore.filter().sortBy;
switch (sort) {
case "title":
return "Title";
case "episodeCount":
return "Episodes";
case "latestEpisode":
return "Latest";
default:
return "Updated";
}
};
const handleFeedClick = (feed: Feed, index: number) => {
setSelectedIndex(index);
if (props.onSelectFeed) {
props.onSelectFeed(feed);
}
};
const handleFeedDoubleClick = (feed: Feed) => {
if (props.onOpenFeed) {
props.onOpenFeed(feed);
}
};
return (
<box flexDirection="column" gap={1}>
{/* Header with filter controls */}
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
<text fg={theme.text}>
<strong>My Feeds</strong>
</text>
<text fg={theme.textMuted}>({filteredFeeds().length} feeds)</text>
<box flexDirection="row" gap={1}>
<box border padding={0} onMouseDown={cycleVisibilityFilter} borderColor={theme.border}>
<text fg={theme.primary}>[f] {visibilityLabel()}</text>
</box>
<box border padding={0} onMouseDown={cycleSortField} borderColor={theme.border}>
<text fg={theme.primary}>[s] {sortLabel()}</text>
</box>
</box>
</box>
{/* Feed list in scrollbox */}
<Show
when={filteredFeeds().length > 0}
fallback={
<box border padding={2} borderColor={theme.border}>
<text fg={theme.textMuted}>
No feeds found. Add podcasts from the Discover or Search tabs.
</text>
</box>
}
>
<scrollbox height={15} focused={props.focused}>
<For each={filteredFeeds()}>
{(feed, index) => (
<box onMouseDown={() => handleFeedClick(feed, index())}>
<FeedItem
feed={feed}
isSelected={index() === selectedIndex()}
compact={props.compact}
showEpisodeCount={props.showEpisodeCount ?? true}
showLastUpdated={props.showLastUpdated ?? true}
/>
</box>
)}
</For>
</scrollbox>
</Show>
{/* Navigation help */}
<box paddingTop={0}>
<text fg={theme.textMuted}>
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
</text>
</box>
</box>
);
}

View File

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

View File

@@ -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 = () =>
@@ -208,7 +212,6 @@ export function MyShowsPage() {
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
const parentContent = () => (

View File

@@ -116,7 +116,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
}
reader.start(position, speed);
// Start render loop
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
@@ -140,11 +139,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Read available PCM samples from the stream
const count = reader.read(sampleBuffer);
if (count === 0) return;
// Feed samples to cavacore → get frequency bars
const input =
count < sampleBuffer.length
? sampleBuffer.subarray(0, count)
@@ -198,7 +195,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
}),
);
// Cleanup on unmount
onCleanup(() => {
stopVisualization();
if (reader) {
@@ -222,7 +218,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
const bars = barData();
const count = numBars();
// If no data yet, show empty placeholder
if (bars.length === 0) {
const placeholder = ".".repeat(count);
return (

View File

@@ -1,95 +0,0 @@
import { Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
type ResultCardProps = {
result: SearchResult;
selected: boolean;
onSelect: () => void;
onSubscribe?: () => void;
};
export function ResultCard(props: ResultCardProps) {
const { theme } = useTheme();
const podcast = () => props.result.podcast;
return (
<SelectableBox
selected={() => props.selected}
flexDirection="column"
padding={1}
onMouseDown={props.onSelect}
>
<box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<box flexDirection="row" gap={2} alignItems="center">
<SelectableText
selected={() => props.selected}
primary
>
<strong>{podcast().title}</strong>
</SelectableText>
<SourceBadge
sourceId={props.result.sourceId}
sourceName={props.result.sourceName}
sourceType={props.result.sourceType}
/>
</box>
<Show when={podcast().isSubscribed}>
<text fg={theme.success}>[Subscribed]</text>
</Show>
</box>
<Show when={podcast().author}>
<SelectableText
selected={() => props.selected}
tertiary
>
by {podcast().author}
</SelectableText>
</Show>
<Show when={podcast().description}>
{(description) => (
<SelectableText
selected={() => props.selected}
tertiary
>
{description().length > 120
? description().slice(0, 120) + "..."
: description()}
</SelectableText>
)}
</Show>
<Show when={(podcast().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(podcast().categories ?? []).slice(0, 3).map((category) => (
<text fg={theme.warning}>[{category}]</text>
))}
</box>
</Show>
<Show when={!podcast().isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={(event) => {
event.stopPropagation?.();
props.onSubscribe?.();
}}
>
<text fg={theme.primary}>[+] Add to Feeds</text>
</box>
</Show>
</SelectableBox>
);
}

View File

@@ -1,75 +0,0 @@
import { Show } from "solid-js";
import { format } from "date-fns";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
import { useTheme } from "@/context/ThemeContext";
type ResultDetailProps = {
result?: SearchResult;
onSubscribe?: (result: SearchResult) => void;
};
export function ResultDetail(props: ResultDetailProps) {
const { theme } = useTheme();
return (
<box flexDirection="column" border padding={1} gap={1} height="100%" borderColor={theme.border}>
<Show
when={props.result}
fallback={ <text fg={theme.textMuted}>Select a result to see details.</text>}
>
{(result) => (
<>
<text fg={theme.text}>
<strong>{result().podcast.title}</strong>
</text>
<SourceBadge
sourceId={result().sourceId}
sourceName={result().sourceName}
sourceType={result().sourceType}
/>
<Show when={result().podcast.author}>
<text fg={theme.textMuted}>by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textMuted}>{result().podcast.description}</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(result().podcast.categories ?? []).map((category) => (
<text fg={theme.warning}>[{category}]</text>
))}
</box>
</Show>
<text fg={theme.textMuted}>Feed: {result().podcast.feedUrl}</text>
<text fg={theme.textMuted}>
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
</text>
<Show when={!result().podcast.isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={() => props.onSubscribe?.(result())}
>
<text fg={theme.primary}>[+] Add to Feeds</text>
</box>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
</>
)}
</Show>
</box>
);
}

View File

@@ -1,89 +0,0 @@
/**
* SearchHistory component for displaying and managing search history
*/
import { For, Show } from "solid-js"
import { useTheme } from "@/context/ThemeContext"
import { SelectableBox, SelectableText } from "@/components/Selectable"
type SearchHistoryProps = {
history: string[]
focused: boolean
selectedIndex: number
onSelect?: (query: string) => void
onRemove?: (query: string) => void
onClear?: () => void
onChange?: (index: number) => void
}
export function SearchHistory(props: SearchHistoryProps) {
const { theme } = useTheme();
const handleSearchClick = (index: number, query: string) => {
props.onChange?.(index)
props.onSelect?.(query)
}
const handleRemoveClick = (query: string) => {
props.onRemove?.(query)
}
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Recent Searches</text>
<Show when={props.history.length > 0}>
<box onMouseDown={() => props.onClear?.()} padding={0}>
<text fg={theme.error}>[Clear All]</text>
</box>
</Show>
</box>
<Show
when={props.history.length > 0}
fallback={
<box padding={1}>
<text fg={theme.textMuted}>No recent searches</text>
</box>
}
>
<scrollbox height={10}>
<box flexDirection="column">
<For each={props.history}>
{(query, index) => {
const isSelected = () => index() === props.selectedIndex && props.focused
return (
<SelectableBox
selected={isSelected}
flexDirection="row"
justifyContent="space-between"
padding={0}
paddingLeft={1}
paddingRight={1}
onMouseDown={() => handleSearchClick(index(), query)}
>
<SelectableText
selected={isSelected}
tertiary
>
{">"}
</SelectableText>
<SelectableText
selected={isSelected}
primary
>
{query}
</SelectableText>
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
<text fg={theme.error}>[x]</text>
</box>
</SelectableBox>
)
}}
</For>
</box>
</scrollbox>
</Show>
</box>
)
}

View File

@@ -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 = () => (
@@ -385,8 +389,7 @@ function SearchPage() {
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textSecondary}>
{result().podcast.description!.slice(0, 400) ??
"No description available."}
{result().podcast.description!.slice(0, 400)}
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
</text>
</Show>

View File

@@ -1,80 +0,0 @@
/**
* SearchResults component for displaying podcast search results
*/
import { For, Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { ResultCard } from "./ResultCard";
import { ResultDetail } from "./ResultDetail";
type SearchResultsProps = {
results: SearchResult[];
selectedIndex: number;
focused: boolean;
onSelect?: (result: SearchResult) => void;
onChange?: (index: number) => void;
isSearching?: boolean;
error?: string | null;
};
export function SearchResults(props: SearchResultsProps) {
const handleSelect = (index: number) => {
props.onChange?.(index);
};
return (
<Show
when={!props.isSearching}
fallback={
<box padding={1}>
<text fg="yellow">Searching...</text>
</box>
}
>
<Show
when={!props.error}
fallback={
<box padding={1}>
<text fg="red">{props.error}</text>
</box>
}
>
<Show
when={props.results.length > 0}
fallback={
<box padding={1}>
<text fg="gray">
No results found. Try a different search term.
</text>
</box>
}
>
<box flexDirection="row" gap={1} height="100%">
<box flexDirection="column" flexGrow={1}>
<scrollbox height="100%">
<box flexDirection="column" gap={1}>
<For each={props.results}>
{(result, index) => (
<ResultCard
result={result}
selected={index() === props.selectedIndex}
onSelect={() => handleSelect(index())}
onSubscribe={() => props.onSelect?.(result)}
/>
)}
</For>
</box>
</scrollbox>
</box>
<box width={36}>
<ResultDetail
result={props.results[props.selectedIndex]}
onSubscribe={(result) => props.onSelect?.(result)}
/>
</box>
</box>
</Show>
</Show>
</Show>
);
}

View File

@@ -1,38 +0,0 @@
import { SourceType } from "@/types/source";
import { useTheme } from "@/context/ThemeContext";
type SourceBadgeProps = {
sourceId: string;
sourceName?: string;
sourceType?: SourceType;
};
const typeLabel = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return "API";
if (sourceType === SourceType.RSS) return "RSS";
if (sourceType === SourceType.CUSTOM) return "Custom";
return "Source";
};
// No module-level typeColor here — it needs the theme from the component.
// The correct definition lives inside SourceBadge below.
export function SourceBadge(props: SourceBadgeProps) {
const { theme } = useTheme();
const label = () => props.sourceName || props.sourceId;
const typeColor = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return theme.primary;
if (sourceType === SourceType.RSS) return theme.success;
if (sourceType === SourceType.CUSTOM) return theme.warning;
return theme.textMuted;
};
return (
<box flexDirection="row" gap={1} padding={0}>
<text fg={typeColor(props.sourceType)}>
[{typeLabel(props.sourceType)}]
</text>
<text fg={theme.textMuted}>{label()}</text>
</box>
);
}

View File

@@ -1,38 +1,55 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value
return [() => current, (next) => {
current = next
}]
}
let current = value;
return [
() => current,
(next) => {
current = next;
},
];
};
import { SyncStatus } from "./SyncStatus"
import { useTheme } from "@/context/ThemeContext"
import { SyncStatus } from "./SyncStatus";
import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
export function ExportDialog() {
const { theme } = useTheme();
const filename = createSignal("podcast-sync.json")
const format = createSignal<"json" | "xml">("json")
const { theme } = useTheme();
const filename = createSignal("podcast-sync.json");
const format = createSignal<"json" | "xml">("json");
// Yield navigation keybinds to the Shell router while the input is focused.
const filenameRef = useInputFocusNav();
return (
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
<box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>File:</text>
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} />
</box>
<box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>Format:</text>
<tab_select
options={[
{ name: "JSON", description: "Portable" },
{ name: "XML", description: "Structured" },
]}
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
/>
</box>
<box border borderColor={theme.border}>
<text fg={theme.text}>Export {format[0]()} to {filename[0]()}</text>
</box>
<SyncStatus />
</box>
)
return (
<box
border
title="Export"
style={{ padding: 1, flexDirection: "column", gap: 1 }}
>
<box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>File:</text>
<input
ref={filenameRef}
value={filename[0]()}
onInput={filename[1]}
style={{ width: 30 }}
/>
</box>
<box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>Format:</text>
<tab_select
options={[
{ name: "JSON", description: "Portable" },
{ name: "XML", description: "Structured" },
]}
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
/>
</box>
<box border borderColor={theme.border}>
<text fg={theme.text}>
Export {format[0]()} to {filename[0]()}
</text>
</box>
<SyncStatus />
</box>
);
}

View File

@@ -1,24 +1,28 @@
import { detectFormat } from "@/utils/file-detector";
import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
type FilePickerProps = {
value: string;
onChange: (value: string) => void;
value: string;
onChange: (value: string) => void;
};
export function FilePicker(props: FilePickerProps) {
const { theme } = useTheme();
const format = detectFormat(props.value);
const { theme } = useTheme();
// Yield navigation keybinds to the Shell router while the input is focused.
const inputRef = useInputFocusNav();
const format = detectFormat(props.value);
return (
<box style={{ flexDirection: "column", gap: 1 }}>
<input
value={props.value}
onInput={props.onChange}
placeholder="/path/to/sync-file.json"
style={{ width: 40 }}
/>
<text fg={theme.text}>Format: {format}</text>
</box>
);
return (
<box style={{ flexDirection: "column", gap: 1 }}>
<input
ref={inputRef}
value={props.value}
onInput={props.onChange}
placeholder="/path/to/sync-file.json"
style={{ width: 40 }}
/>
<text fg={theme.text}>Format: {format}</text>
</box>
);
}

View File

@@ -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",
@@ -90,5 +103,17 @@ export function usePreferencesItems(): SettingItem[] {
autoDownload: !prefs().autoDownload,
}),
},
{
id: "autoJumpToPlayer",
label: "Auto Jump to Player",
kind: "toggle",
display: () => (prefs().autoJumpToPlayer ? "On" : "Off"),
help: () =>
`Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () =>
app.updatePreferences({
autoJumpToPlayer: !prefs().autoJumpToPlayer,
}),
},
];
}

View File

@@ -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

View File

@@ -14,6 +14,7 @@
import { createSignal, For, Show } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
import { SourceType } from "@/types/source";
import type { PodcastSource } from "@/types/source";
import type { SettingItem } from "./types";
@@ -61,6 +62,9 @@ function AddSourceForm() {
const [name, setName] = createSignal("");
const [url, setUrl] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
// Yield navigation keybinds to the Shell router while either input is focused.
const nameRef = useInputFocusNav();
const urlRef = useInputFocusNav();
const submit = () => {
const u = url().trim();
@@ -94,6 +98,7 @@ function AddSourceForm() {
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Name:</text>
<input
ref={nameRef}
value={name()}
onInput={setName}
placeholder="My Custom Feed"
@@ -103,6 +108,7 @@ function AddSourceForm() {
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>URL:</text>
<input
ref={urlRef}
value={url()}
onInput={(v) => {
setUrl(v);

View File

@@ -3,21 +3,13 @@
* Export dialogs render as depth-2 editors. No own useKeyboard.
*/
import { createSignal } from "solid-js";
import { ImportDialog } from "./ImportDialog";
import { ExportDialog } from "./ExportDialog";
import { SyncStatus } from "./SyncStatus";
import type { SettingItem } from "./types";
// Module-level state so the action items can open their dialogs as depth-2
// editors. The SettingsPage reads `syncEditor()` to decide which dialog to show.
const [syncEditor, setSyncEditor] = createSignal<"import" | "export" | null>(
null,
);
export { syncEditor };
export function closeSyncEditor() {
setSyncEditor(null);
}
// closeSyncEditor kept for SettingsPage's cleanup hook; its backing state
// (the syncEditor signal) was removed as dead — nothing ever read it.
export function closeSyncEditor() {}
export function useSyncItems(): SettingItem[] {
return [
@@ -49,9 +41,3 @@ export function useSyncItems(): SettingItem[] {
},
];
}
/** Renders the live sync status block (used by the Settings page header for the
* Sync section, when relevant). */
export function SyncStatusBlock() {
return <SyncStatus />;
}

View File

@@ -29,12 +29,14 @@ const defaultSettings: AppSettings = {
fontSize: 14,
playbackSpeed: 1,
downloadPath: "",
transparentBackground: false,
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
autoJumpToPlayer: true,
};
const defaultState: AppState = {
@@ -43,7 +45,7 @@ const defaultState: AppState = {
customTheme: DEFAULT_THEME,
};
export function createAppStore() {
function createAppStore() {
// Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState);

View File

@@ -36,7 +36,7 @@ const defaultNavState: AudioNavState = {
};
/** Create audio navigation store */
export function createAudioNavStore() {
function createAudioNavStore() {
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
/** Persist current navigation state to file (fire-and-forget) */

View File

@@ -127,7 +127,6 @@ export function createDiscoverStore() {
return;
}
// Build the podcast list from the manifest entries
const fetched = manifest.podcasts.map(entryToPodcast);
cachedAt = now;
setPodcasts(fetched);
@@ -173,7 +172,6 @@ export function createDiscoverStore() {
const unsubscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) {
// Remove the feed from the feed store
const feedStore = useFeedStore();
feedStore.removeFeedByUrl(podcast.feedUrl);
}

View File

@@ -38,7 +38,7 @@ interface QueueItem {
}
/** Create download store */
export function createDownloadStore() {
function createDownloadStore() {
const [downloads, setDownloads] = createSignal<
Map<string, DownloadedEpisode>
>(new Map());
@@ -48,7 +48,6 @@ export function createDownloadStore() {
/** Active AbortControllers keyed by episodeId */
const abortControllers = new Map<string, AbortController>();
// Load persisted downloads on init
(async () => {
const loaded = await loadDownloads();
if (loaded.size > 0) setDownloads(loaded);
@@ -153,7 +152,6 @@ export function createDownloadStore() {
const slotsAvailable = MAX_CONCURRENT - current;
const toStart = q.slice(0, slotsAvailable);
// Remove started items from queue
if (toStart.length > 0) {
setQueue((prev) => prev.slice(toStart.length));
}
@@ -250,7 +248,6 @@ export function createDownloadStore() {
return; // Already downloading or queued
}
// Create download entry
const entry: DownloadedEpisode = {
episodeId: episode.id,
feedId,
@@ -269,7 +266,6 @@ export function createDownloadStore() {
return next;
});
// Add to queue
const queueItem: QueueItem = {
episodeId: episode.id,
feedId,
@@ -291,10 +287,8 @@ export function createDownloadStore() {
abortControllers.delete(episodeId);
}
// Remove from queue
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
// Update status
updateDownload(episodeId, {
status: DownloadStatus.NONE,
progress: 0,

View File

@@ -43,7 +43,7 @@ function saveSources(sources: PodcastSource[]): void {
}
/** Create feed store */
export function createFeedStore() {
function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([
...DEFAULT_SOURCES,
@@ -62,22 +62,18 @@ export function createFeedStore() {
let result = [...feeds()];
const f = filter();
// Filter by visibility
if (f.visibility && f.visibility !== "all") {
result = result.filter((feed) => feed.visibility === f.visibility);
}
// Filter by source
if (f.sourceId) {
result = result.filter((feed) => feed.sourceId === f.sourceId);
}
// Filter by pinned
if (f.pinnedOnly) {
result = result.filter((feed) => feed.isPinned);
}
// Filter by search query
if (f.searchQuery) {
const query = f.searchQuery.toLowerCase();
result = result.filter(
@@ -88,7 +84,6 @@ export function createFeedStore() {
);
}
// Sort by selected field
const sortDir = f.sortDirection === "asc" ? 1 : -1;
result.sort((a, b) => {
switch (f.sortBy) {
@@ -111,7 +106,6 @@ export function createFeedStore() {
}
});
// Pinned feeds always first
result.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
@@ -224,25 +218,21 @@ export function createFeedStore() {
newEpisodes: Episode[],
count: number,
) => {
try {
const dlStore = useDownloadStore();
// Sort by pubDate descending (newest first)
const sorted = [...newEpisodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
// count = 0 means download all new episodes
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
for (const ep of toDownload) {
const status = dlStore.getDownloadStatus(ep.id);
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
dlStore.startDownload(ep, feedId);
}
const dlStore = useDownloadStore();
// Sort by pubDate descending (newest first)
const sorted = [...newEpisodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
// count = 0 means download all new episodes
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
for (const ep of toDownload) {
const status = dlStore.getDownloadStatus(ep.id);
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
dlStore.startDownload(ep, feedId);
}
} catch {
// Download store may not be available yet
}
};

View File

@@ -4,7 +4,7 @@
*/
import { createSignal } from "solid-js";
import { searchPodcasts } from "../utils/search";
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source";
@@ -80,10 +80,18 @@ export function createSearchStore() {
setIsSearching(true);
setError(null);
// Add to history
addToHistory(q);
try {
// A query that is a direct RSS feed URL (e.g. a private feed that
// isn't in any public directory) resolves to that feed directly,
// independent of enabled search sources.
const urlResults = await searchByFeedUrl(q);
if (urlResults.length > 0) {
setResults(applySubscribedStatus(urlResults));
return;
}
const sources = feedStore.sources();
const enabledSourceIds = sources
.filter((s) => s.enabled)
@@ -122,7 +130,6 @@ export function createSearchStore() {
/** Add query to history */
const addToHistory = (q: string) => {
setHistory((prev) => {
// Remove duplicates and add to front
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated);

View File

@@ -1,12 +1,4 @@
import type {
DesktopTheme,
ThemeColors,
ThemeDefinition,
ThemeName,
ThemeToken,
ThemeVariant,
} from "../types/settings"
import type { ColorValue } from "./theme-schema"
import type { ThemeColors } from "../types/settings"
// Base theme colors
export const BASE_THEME_COLORS: ThemeColors = {
@@ -37,156 +29,3 @@ export const BASE_LAYER_BACKGROUND: ThemeColors["layerBackgrounds"] = {
layer2: "#161b22",
layer3: "#0d1117",
}
// Theme tokens
export const BASE_THEME_TOKENS: ThemeToken = {
"background": "transparent",
"surface": "#1b1f27",
"primary": "#6fa8ff",
"secondary": "#a9b1d6",
"accent": "#f6c177",
"text": "#e6edf3",
"muted": "#7d8590",
"warning": "#f0b429",
"error": "#f47067",
"success": "#3fb950",
"layer0": "transparent",
"layer1": "#1e222e",
"layer2": "#161b22",
"layer3": "#0d1117",
}
// Desktop theme structure
export const THEMES_DESKTOP: DesktopTheme = {
name: "PodTUI",
variants: [
{
name: "catppuccin",
colors: {
background: "transparent",
surface: "#1e1e2e",
primary: "#89b4fa",
secondary: "#cba6f7",
accent: "#f9e2af",
text: "#cdd6f4",
textPrimary: "#cdd6f4",
textSecondary: "#cba6f7",
textTertiary: "#7f849c",
textSelectedPrimary: "#1e1e2e",
textSelectedSecondary: "#cdd6f4",
textSelectedTertiary: "#cba6f7",
muted: "#7f849c",
warning: "#fab387",
error: "#f38ba8",
success: "#a6e3a1",
layerBackgrounds: {
layer0: "transparent",
layer1: "#181825",
layer2: "#11111b",
layer3: "#0a0a0f",
},
},
},
{
name: "gruvbox",
colors: {
background: "transparent",
surface: "#282828",
primary: "#fabd2f",
secondary: "#83a598",
accent: "#fe8019",
text: "#ebdbb2",
textPrimary: "#ebdbb2",
textSecondary: "#83a598",
textTertiary: "#928374",
textSelectedPrimary: "#282828",
textSelectedSecondary: "#ebdbb2",
textSelectedTertiary: "#83a598",
muted: "#928374",
warning: "#fabd2f",
error: "#fb4934",
success: "#b8bb26",
layerBackgrounds: {
layer0: "transparent",
layer1: "#32302a",
layer2: "#1d2021",
layer3: "#0d0c0c",
},
},
},
{
name: "tokyo",
colors: {
background: "transparent",
surface: "#1a1b26",
primary: "#7aa2f7",
secondary: "#bb9af7",
accent: "#e0af68",
text: "#c0caf5",
textPrimary: "#c0caf5",
textSecondary: "#bb9af7",
textTertiary: "#565f89",
textSelectedPrimary: "#1a1b26",
textSelectedSecondary: "#c0caf5",
textSelectedTertiary: "#bb9af7",
muted: "#565f89",
warning: "#e0af68",
error: "#f7768e",
success: "#9ece6a",
layerBackgrounds: {
layer0: "transparent",
layer1: "#16161e",
layer2: "#0f0f15",
layer3: "#08080b",
},
},
},
{
name: "nord",
colors: {
background: "transparent",
surface: "#2e3440",
primary: "#88c0d0",
secondary: "#81a1c1",
accent: "#ebcb8b",
text: "#eceff4",
textPrimary: "#eceff4",
textSecondary: "#81a1c1",
textTertiary: "#4c566a",
textSelectedPrimary: "#2e3440",
textSelectedSecondary: "#eceff4",
textSelectedTertiary: "#81a1c1",
muted: "#4c566a",
warning: "#ebcb8b",
error: "#bf616a",
success: "#a3be8c",
layerBackgrounds: {
layer0: "transparent",
layer1: "#3b4252",
layer2: "#242933",
layer3: "#1a1c23",
},
},
},
],
defaultVariant: "catppuccin",
tokens: BASE_THEME_TOKENS,
}
// Helper function to get theme by name
export function getThemeByName(name: ThemeName): ThemeVariant | undefined {
return THEMES_DESKTOP.variants.find((variant) => variant.name === name)
}
// Helper function to get default theme
export function getDefaultTheme(): ThemeVariant {
return THEMES_DESKTOP.variants.find(
(variant) => variant.name === THEMES_DESKTOP.defaultVariant
)!
}
export type ThemeJsonFile = ThemeDefinition
export function isColorReference(value: ColorValue): value is string {
return typeof value === "string" && !value.startsWith("#")
}

View File

@@ -97,14 +97,6 @@ export interface FeedListOptions {
compact: boolean
}
/** Default feed list options */
export const DEFAULT_FEED_LIST_OPTIONS: FeedListOptions = {
showEpisodeCount: true,
showLastUpdated: true,
showSource: false,
compact: false,
}
/** Feed statistics */
export interface FeedStats {
/** Total feed count */

View File

@@ -79,12 +79,16 @@ 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;
};
export type UserPreferences = {
showExplicit: boolean;
autoDownload: boolean;
/** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean;
};
export type AppState = {

View File

@@ -1,24 +0,0 @@
export type SyncData = {
version: string
lastSyncedAt: string
feeds: {
id: string
title: string
url: string
isPrivate: boolean
}[]
sources: {
id: string
name: string
url: string
}[]
settings: {
theme: string
playbackSpeed: number
downloadPath: string
}
preferences: {
showExplicit: boolean
autoDownload: boolean
}
}

View File

@@ -1,28 +0,0 @@
export type SyncDataXML = {
version: string
lastSyncedAt: string
feeds: {
feed: {
id: string
title: string
url: string
isPrivate: boolean
}[]
}
sources: {
source: {
id: string
name: string
url: string
}[]
}
settings: {
theme: string
playbackSpeed: number
downloadPath: string
}
preferences: {
showExplicit: boolean
autoDownload: boolean
}
}

View File

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

View File

@@ -85,7 +85,6 @@ function init() {
);
const suspended = () => suspendCount() > 0;
// Handle keybind shortcuts
useKeyboard((evt) => {
if (suspended()) return;
if (dialog.isOpen) return;
@@ -180,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;
@@ -258,7 +256,6 @@ function CommandDialog(props: {
return;
}
// Handle text input
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
setFilter((f) => f + evt.name);
return;

View File

@@ -12,7 +12,7 @@ export type DialogSize = "medium" | "large"
/**
* Dialog component that renders a modal overlay with content.
*/
export function Dialog(
function Dialog(
props: ParentProps<{
size?: DialogSize
onClose: () => void

View File

@@ -32,12 +32,14 @@ const defaultSettings: AppSettings = {
fontSize: 14,
playbackSpeed: 1,
downloadPath: "",
transparentBackground: false,
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
autoJumpToPlayer: true,
};
const defaultState: AppState = {

View File

@@ -135,10 +135,8 @@ export class AudioStreamReader {
this.writePos = 0;
this.totalSamplesWritten = 0;
// Capture generation for this run
const myGeneration = this.generation;
// Start async reading loop
this.readLoop(myGeneration);
// Detect process exit

View File

@@ -1,103 +0,0 @@
/**
* Audio waveform analysis for PodTUI
*
* Extracts amplitude data from audio files using ffmpeg (when available)
* Results are cache in-memory keyed by audio URL.
*/
/** Number of amplitude data points to generate */
const DEFAULT_RESOLUTION = 128;
/** In-memory cache: audioUrl -> amplitude data */
const waveformCache = new Map<string, number[]>();
/**
* Try to extract real waveform data from an audio URL using ffmpeg.
* Returns null if ffmpeg is not available or the extraction fails.
*/
async function extractWithFfmpeg(
audioUrl: string,
resolution: number,
): Promise<number[] | null> {
try {
if (!Bun.which("ffmpeg")) return null;
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
// -t 300: read at most 5 minutes (enough data to fill the waveform)
const proc = Bun.spawn(
[
"ffmpeg",
"-i",
audioUrl,
"-t",
"300",
"-ac",
"1", // mono
"-ar",
"8000", // low sample rate to keep data small
"-f",
"s16le", // raw signed 16-bit PCM
"-v",
"quiet",
"-",
],
{ stdout: "pipe", stderr: "ignore" },
);
const output = await new Response(proc.stdout).arrayBuffer();
await proc.exited;
if (output.byteLength === 0) return null;
const samples = new Int16Array(output);
if (samples.length === 0) return null;
// Downsample to `resolution` buckets by taking the max absolute amplitude
// in each bucket.
const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
const data: number[] = [];
for (let i = 0; i < resolution; i++) {
const start = i * bucketSize;
const end = Math.min(start + bucketSize, samples.length);
let maxAbs = 0;
for (let j = start; j < end; j++) {
const abs = Math.abs(samples[j]);
if (abs > maxAbs) maxAbs = abs;
}
// Normalise to 0-1
data.push(Number((maxAbs / 32768).toFixed(3)));
}
return data;
} catch {
return null;
}
}
/**
* Get waveform data for an audio URL.
*
* Returns cached data if available, otherwise attempts ffmpeg extraction
*/
export async function getWaveformData(
audioUrl: string,
resolution: number = DEFAULT_RESOLUTION,
): Promise<number[]> {
const cacheKey = `${audioUrl}:${resolution}`;
const cached = waveformCache.get(cacheKey);
if (cached) return cached;
const real = await extractWithFfmpeg(audioUrl, resolution);
if (real) {
waveformCache.set(cacheKey, real);
return real;
} else {
console.error("generation failure");
return [];
}
}
export function clearWaveformCache(): void {
waveformCache.clear();
}

View File

@@ -1,57 +0,0 @@
type CacheEntry<T> = {
value: T
timestamp: number
}
const CACHE_KEY = "podtui_cache"
const DEFAULT_TTL = 1000 * 60 * 60
const loadCache = (): Record<string, CacheEntry<unknown>> => {
if (typeof localStorage === "undefined") return {}
try {
const raw = localStorage.getItem(CACHE_KEY)
return raw ? (JSON.parse(raw) as Record<string, CacheEntry<unknown>>) : {}
} catch {
return {}
}
}
const saveCache = (cache: Record<string, CacheEntry<unknown>>) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
} catch {
// ignore
}
}
const cache = loadCache()
export const cacheValue = <T,>(key: string, value: T) => {
cache[key] = { value, timestamp: Date.now() }
saveCache(cache)
}
export const getCachedValue = <T,>(key: string, ttl = DEFAULT_TTL): T | null => {
const entry = cache[key] as CacheEntry<T> | undefined
if (!entry) return null
if (Date.now() - entry.timestamp > ttl) {
delete cache[key]
saveCache(cache)
return null
}
return entry.value
}
export const invalidateCache = (prefix?: string) => {
if (!prefix) {
Object.keys(cache).forEach((key) => delete cache[key])
saveCache(cache)
return
}
Object.keys(cache)
.filter((key) => key.startsWith(prefix))
.forEach((key) => delete cache[key])
saveCache(cache)
}

View File

@@ -106,7 +106,7 @@ export namespace Clipboard {
/**
* Read text from the clipboard.
*/
export async function readText(): Promise<string | undefined> {
async function readText(): Promise<string | undefined> {
const os = platform()
if (os === "darwin") {

View File

@@ -13,7 +13,7 @@ import path from "path"
const APP_DIR_NAME = "podtui"
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
export function getXdgConfigHome(): string {
function getXdgConfigHome(): string {
const xdg = process.env.XDG_CONFIG_HOME
if (xdg) return xdg
@@ -44,7 +44,7 @@ export async function ensureConfigDir(): Promise<string> {
}
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
export function getXdgDataHome(): string {
function getXdgDataHome(): string {
const xdg = process.env.XDG_DATA_HOME
if (xdg) return xdg
@@ -55,12 +55,12 @@ export function getXdgDataHome(): string {
}
/** Get the application-specific data directory path */
export function getDataDir(): string {
function getDataDir(): string {
return path.join(getXdgDataHome(), APP_DIR_NAME)
}
/** Get the downloads directory path */
export function getDownloadsDir(): string {
function getDownloadsDir(): string {
return path.join(getDataDir(), "downloads")
}

View File

@@ -1,150 +0,0 @@
/**
* Validates JSON structure of config files, handles corrupted files
* gracefully (falling back to defaults), and provides a single
*/
import { getConfigFilePath } from "./config-dir";
// --- Validation helpers ---
/** Check that a value is a non-null object */
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v);
}
/** Validate AppState JSON structure */
export function validateAppState(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) {
return { valid: false, errors: ["app-state.json is not an object"] };
}
// settings
if (data.settings !== undefined) {
if (!isObject(data.settings)) {
errors.push("settings must be an object");
} else {
const s = data.settings as Record<string, unknown>;
if (s.theme !== undefined && typeof s.theme !== "string")
errors.push("settings.theme must be a string");
if (s.fontSize !== undefined && typeof s.fontSize !== "number")
errors.push("settings.fontSize must be a number");
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number")
errors.push("settings.playbackSpeed must be a number");
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string")
errors.push("settings.downloadPath must be a string");
}
}
// preferences
if (data.preferences !== undefined) {
if (!isObject(data.preferences)) {
errors.push("preferences must be an object");
} else {
const p = data.preferences as Record<string, unknown>;
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
errors.push("preferences.showExplicit must be a boolean");
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
errors.push("preferences.autoDownload must be a boolean");
}
}
// customTheme
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
errors.push("customTheme must be an object");
}
return { valid: errors.length === 0, errors };
}
/** Validate feeds JSON structure */
export function validateFeeds(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!Array.isArray(data)) {
return { valid: false, errors: ["feeds.json is not an array"] };
}
for (let i = 0; i < data.length; i++) {
const feed = data[i];
if (!isObject(feed)) {
errors.push(`feeds[${i}] is not an object`);
continue;
}
if (typeof feed.id !== "string")
errors.push(`feeds[${i}].id must be a string`);
if (!isObject(feed.podcast))
errors.push(`feeds[${i}].podcast must be an object`);
if (!Array.isArray(feed.episodes))
errors.push(`feeds[${i}].episodes must be an array`);
}
return { valid: errors.length === 0, errors };
}
/** Validate progress JSON structure */
export function validateProgress(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) {
return { valid: false, errors: ["progress.json is not an object"] };
}
for (const [key, value] of Object.entries(data)) {
if (!isObject(value)) {
errors.push(`progress["${key}"] is not an object`);
continue;
}
const p = value as Record<string, unknown>;
if (typeof p.episodeId !== "string")
errors.push(`progress["${key}"].episodeId must be a string`);
if (typeof p.position !== "number")
errors.push(`progress["${key}"].position must be a number`);
if (typeof p.duration !== "number")
errors.push(`progress["${key}"].duration must be a number`);
}
return { valid: errors.length === 0, errors };
}
// --- Safe config file reading ---
/**
* Safely read and validate a config file.
* Returns the parsed data if valid, or null if the file is missing/corrupt.
*/
export async function safeReadConfigFile<T>(
filename: string,
validator: (data: unknown) => { valid: boolean; errors: string[] },
): Promise<{ data: T | null; errors: string[] }> {
try {
const filePath = getConfigFilePath(filename);
const file = Bun.file(filePath);
if (!(await file.exists())) {
return { data: null, errors: [] };
}
const text = await file.text();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { data: null, errors: [`${filename}: invalid JSON`] };
}
const result = validator(parsed);
if (!result.valid) {
return { data: null, errors: result.errors };
}
return { data: parsed as T, errors: [] };
} catch (err) {
return { data: null, errors: [`${filename}: ${String(err)}`] };
}
}

View File

@@ -73,11 +73,6 @@ export function updateConfig(patch: Partial<PodTuiConfig>): void {
});
}
/** Await all pending config writes (used by sync/export flows). */
export async function flushConfig(): Promise<void> {
await writeChain;
}
/** Guards so migration runs exactly once per process. */
let migrationDone = false;
let migrationPromise: Promise<void> | null = null;
@@ -98,7 +93,7 @@ async function migrateOnce(): Promise<void> {
* Safe to call on every startup — no-op once config.json exists (except for
* backup cleanup, which runs unconditionally since those files are now dead).
*/
export async function migrateLegacyConfig(): Promise<void> {
async function migrateLegacyConfig(): Promise<void> {
try {
await ensureConfigDir();
const dir = getConfigDir();

View File

@@ -1,57 +0,0 @@
import { FeedVisibility } from "../types/feed"
import type { Feed } from "../types/feed"
import type { Episode } from "../types/episode"
import type { Podcast } from "../types/podcast"
import { cacheValue, getCachedValue } from "./cache"
import { fetchEpisodes } from "@/api/client"
const feedKey = (feedUrl: string) => `feed:${feedUrl}`
const episodesKey = (feedUrl: string) => `episodes:${feedUrl}`
const searchKey = (query: string) => `search:${query.toLowerCase()}`
export const fetchFeedWithCache = async (feedUrl: string): Promise<Feed | null> => {
const cached = getCachedValue<Feed>(feedKey(feedUrl))
if (cached) return cached
try {
const episodes = await fetchEpisodes(feedUrl)
const feed: Feed = {
id: feedUrl,
podcast: {
id: feedUrl,
title: feedUrl,
description: "",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
},
episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: "rss",
lastUpdated: new Date(),
isPinned: false,
}
cacheValue(feedKey(feedUrl), feed)
return feed
} catch {
return null
}
}
export const fetchEpisodesWithCache = async (feedUrl: string): Promise<Episode[]> => {
const cached = getCachedValue<Episode[]>(episodesKey(feedUrl))
if (cached) return cached
const episodes = await fetchEpisodes(feedUrl)
cacheValue(episodesKey(feedUrl), episodes)
return episodes
}
export const searchWithCache = async (
query: string,
fetcher: () => Promise<Podcast[]>
): Promise<Podcast[]> => {
const cached = getCachedValue<Podcast[]>(searchKey(query))
if (cached) return cached
const results = await fetcher()
cacheValue(searchKey(query), results)
return results
}

View File

@@ -79,7 +79,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
]);
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
export function tabByDigit(action: KeybindActionName): TABS | null {
function tabByDigit(action: KeybindActionName): TABS | null {
if (action.startsWith("tab-goto-")) {
const n = Number(action.slice("tab-goto-".length));
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;

View File

@@ -87,7 +87,7 @@ function createEventBus(): EventBusInstance {
}
// Singleton event bus instance
export const EventBus = createEventBus();
const EventBus = createEventBus();
import type { KeybindActionName } from "@/context/KeybindContext";
import type { TABS } from "@/utils/navigation";
@@ -105,6 +105,8 @@ export type AppEvents = {
"player.play": { episodeId: string };
"player.pause": { episodeId: string };
"player.stop": {};
// Emitted when a NEW episode begins playback (not on resume).
"player.started": { episodeId: string };
"toast.show": {
message: string;
variant: "info" | "success" | "warning" | "error";

View File

@@ -8,7 +8,7 @@
/**
* Remove JSONC comments from a string
*/
export function stripComments(jsonString: string): string {
function stripComments(jsonString: string): string {
const comments = [
{ pattern: /\/\/.*$/gm, replacement: "" },
{ pattern: /\/\*[\s\S]*?\*\//g, replacement: "" },

View File

@@ -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"],
@@ -77,7 +78,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
try {
const targetPath = getConfigFilePath(KEYBINDS_FILE);
// Check if file already exists
const targetFile = Bun.file(targetPath);
if (await targetFile.exists()) return;

View File

@@ -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

View File

@@ -1,6 +1,7 @@
import { searchSourceByType } from "./source-searcher";
import { parseRSSFeed } from "../api/rss-parser";
import { SourceType } from "../types/source";
import type { PodcastSource, SearchResult } from "../types/source";
import type { Episode } from "../types/episode";
type SearchCacheEntry = {
timestamp: number;
@@ -56,6 +57,47 @@ const dedupeResults = (results: SearchResult[]): SearchResult[] => {
return Array.from(map.values());
};
const FEED_URL_RE = /^https?:\/\/.+/i;
/**
* If the query is a direct RSS feed URL (useful for private feeds that aren't
* in public directories), fetch and parse it into a single search result.
* Returns an empty array when the query is not a URL so normal search proceeds.
*/
export const searchByFeedUrl = async (
query: string,
): Promise<SearchResult[]> => {
const trimmed = query.trim();
if (!FEED_URL_RE.test(trimmed)) return [];
try {
const response = await fetch(trimmed, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return [];
const xml = await response.text();
const podcast = parseRSSFeed(xml, trimmed);
return [
{
sourceId: "direct-rss",
sourceName: "RSS Feed",
sourceType: SourceType.RSS,
// parseRSSFeed marks feeds subscribed; a search result should start
// unsubscribed so the store can flag it correctly if already added.
podcast: { ...podcast, isSubscribed: false },
score: 1,
},
];
} catch {
return [];
}
};
export const searchPodcasts = async (
query: string,
sourceIds: string[],
@@ -114,61 +156,4 @@ export const searchPodcasts = async (
return sorted;
};
type ItunesEpisodeResult = {
trackId?: number;
trackName?: string;
description?: string;
shortDescription?: string;
releaseDate?: string;
trackTimeMillis?: number;
episodeUrl?: string;
previewUrl?: string;
trackViewUrl?: string;
};
type ItunesEpisodeResponse = {
resultCount: number;
results: ItunesEpisodeResult[];
};
export const searchEpisodes = async (
query: string,
feedId: string,
): Promise<Episode[]> => {
const trimmed = query.trim();
if (!trimmed) return [];
const url = new URL("https://itunes.apple.com/search");
url.searchParams.set("term", trimmed);
url.searchParams.set("media", "podcast");
url.searchParams.set("entity", "podcastEpisode");
url.searchParams.set("country", "US");
url.searchParams.set("lang", "en_us");
const response = await fetch(url.toString());
if (!response.ok) return [];
const data = (await response.json()) as ItunesEpisodeResponse;
return data.results
.map((item) => {
if (!item.trackName) return null;
const id = item.trackId
? `episode-${item.trackId}`
: `episode-${item.trackName}`;
const audioUrl =
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
return {
id,
podcastId: feedId,
title: item.trackName,
description: item.description || item.shortDescription || "",
audioUrl,
duration: item.trackTimeMillis
? Math.round(item.trackTimeMillis / 1000)
: 0,
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
};
})
.filter((item): item is Episode => Boolean(item));
};

View File

@@ -85,7 +85,7 @@ const makeResults = (query: string, source: PodcastSource, seedOffset = 0): Sear
})
}
export const searchRSSSource = async (
const searchRSSSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
@@ -148,7 +148,7 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
}
}
export const searchAPISource = async (
const searchAPISource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
@@ -173,7 +173,7 @@ export const searchAPISource = async (
}))
}
export const searchCustomSource = async (
const searchCustomSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {

View File

@@ -1,25 +0,0 @@
import type { SyncData } from "../types/sync-json"
import type { SyncDataXML } from "../types/sync-xml"
import { syncFormats } from "../constants/sync-formats"
const isObject = (value: unknown): value is { [key: string]: unknown } =>
typeof value === "object" && value !== null
const hasVersion = (value: unknown): value is { version: string } =>
isObject(value) && typeof value.version === "string"
export function validateJSONSync(data: unknown): SyncData {
if (!hasVersion(data) || data.version !== syncFormats.json.version) {
throw { message: "Unsupported sync format" }
}
return data as SyncData
}
export function validateXMLSync(data: unknown): SyncDataXML {
if (!hasVersion(data) || data.version !== syncFormats.xml.version) {
throw { message: "Unsupported sync format" }
}
return data as SyncDataXML
}

View File

@@ -1,60 +0,0 @@
import type { SyncData } from "../types/sync-json"
import type { SyncDataXML } from "../types/sync-xml"
import { validateJSONSync, validateXMLSync } from "./sync-validation"
import { syncFormats } from "../constants/sync-formats"
import { FeedVisibility } from "../types/feed"
export function exportToJSON(data: SyncData): string {
return `{\n "version": "${data.version}",\n "lastSyncedAt": "${data.lastSyncedAt}",\n "feeds": [],\n "sources": [],\n "settings": {\n "theme": "${data.settings.theme}",\n "playbackSpeed": ${data.settings.playbackSpeed},\n "downloadPath": "${data.settings.downloadPath}"\n },\n "preferences": {\n "showExplicit": ${data.preferences.showExplicit},\n "autoDownload": ${data.preferences.autoDownload}\n }\}`
}
export function importFromJSON(json: string): SyncData {
const data = json
return validateJSONSync(data as unknown)
}
export function exportToXML(data: SyncDataXML): string {
const feedItems = ""
const sourceItems = ""
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
`<podcastSync version="${syncFormats.xml.version}">\n` +
` <lastSyncedAt>${data.lastSyncedAt}</lastSyncedAt>\n` +
` <feeds>\n` +
feedItems +
` </feeds>\n` +
` <sources>\n` +
sourceItems +
` </sources>\n` +
` <settings>\n` +
` <theme>${data.settings.theme}</theme>\n` +
` <playbackSpeed>${data.settings.playbackSpeed}</playbackSpeed>\n` +
` <downloadPath>${data.settings.downloadPath}</downloadPath>\n` +
` </settings>\n` +
` <preferences>\n` +
` <showExplicit>${data.preferences.showExplicit}</showExplicit>\n` +
` <autoDownload>${data.preferences.autoDownload}</autoDownload>\n` +
` </preferences>\n` +
`</podcastSync>`
}
export function importFromXML(xml: string): SyncDataXML {
const version = syncFormats.xml.version
const data = {
version,
lastSyncedAt: "",
feeds: { feed: [] },
sources: { source: [] },
settings: {
theme: "system",
playbackSpeed: 1,
downloadPath: "",
},
preferences: {
showExplicit: false,
autoDownload: false,
},
} as SyncDataXML
return validateXMLSync(data)
}

View File

@@ -13,13 +13,21 @@ export function clearPaletteCache() {
cached = null;
}
export function detectSystemTheme(colors: TerminalColors) {
const bg = RGBA.fromHex(
colors.defaultBackground ?? colors.palette[0] ?? "#000000",
);
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b;
const mode = luminance > 0.5 ? "light" : "dark";
return { mode, background: bg };
/** 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(
@@ -27,14 +35,18 @@ export function generateSystemTheme(
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];
@@ -96,6 +108,7 @@ export function generateSystemTheme(
textSelectedTertiary: selectedTertiary,
selectedListItemText: bg,
background: transparent,
transparent: true,
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],

View File

@@ -1,36 +0,0 @@
import { RGBA } from "@opentui/core"
import type { ColorValue } from "../types/theme-schema"
const toCss = (value: ColorValue | RGBA) => {
if (value instanceof RGBA) {
const r = Math.round(value.r * 255)
const g = Math.round(value.g * 255)
const b = Math.round(value.b * 255)
return `rgba(${r}, ${g}, ${b}, ${value.a})`
}
if (typeof value === "number") return `var(--ansi-${value})`
if (typeof value === "string") return value
return value.dark
}
export function applyThemeToCSS(theme: Record<string, RGBA | ColorValue>) {
const root = document.documentElement
for (const [key, value] of Object.entries(theme)) {
if (key === "layerBackgrounds" && typeof value === "object") {
const layers = value as Record<string, RGBA | ColorValue>
for (const [layer, color] of Object.entries(layers)) {
root.style.setProperty(`--color-${layer}`, toCss(color))
}
} else {
root.style.setProperty(`--color-${key}`, toCss(value as ColorValue | RGBA))
}
}
}
export function setThemeAttribute(themeName: string) {
document.documentElement.setAttribute("data-theme", themeName)
}
export function resolveColorReference(value: ColorValue) {
return toCss(value)
}

View File

@@ -1,42 +1,4 @@
import path from "path"
import type { ThemeJson } from "../types/theme-schema"
import { THEME_JSON } from "../constants/themes"
export async function loadTheme(name: string) {
if (THEME_JSON[name]) return THEME_JSON[name]
const file = path.resolve(process.cwd(), "themes", `${name}.json`)
return loadThemeFromPath(file)
}
export async function loadThemeFromPath(file: string) {
const json = (await Bun.file(file).json()) as ThemeJson
validateTheme(json, file)
return json
}
export async function getAllThemes() {
return { ...THEME_JSON, ...(await getCustomThemes()) }
}
export async function getCustomThemes() {
const dirs = [
path.join(process.env.HOME ?? "", ".config/podtui/themes"),
path.resolve(process.cwd(), ".podtui/themes"),
path.resolve(process.cwd(), "themes"),
]
const result: Record<string, ThemeJson> = {}
for (const dir of dirs) {
const glob = new Bun.Glob("*.json")
for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) {
const name = path.basename(item, ".json")
const json = (await Bun.file(item).json()) as ThemeJson
validateTheme(json, item)
result[name] = json
}
}
return result
}
export function validateTheme(theme: ThemeJson, source?: string) {
if (!theme || typeof theme !== "object") {

View File

@@ -7,40 +7,12 @@
* - Tracking theme change state
*/
import { emit, on, off, type EventHandler } from "./event-bus"
/**
* Subscribe to theme reload events.
* These are triggered by SIGUSR2 signals.
*/
export function onThemeReload(handler: EventHandler<{}>): () => void {
return on("theme.reload", handler)
}
/**
* Subscribe to theme changed events.
* These are triggered when the theme selection changes.
*/
export function onThemeChanged(
handler: EventHandler<{ theme: string; mode: "dark" | "light" }>
): () => void {
return on("theme.changed", handler)
}
/**
* Subscribe to theme mode changed events.
* These are triggered when switching between dark/light mode.
*/
export function onThemeModeChanged(
handler: EventHandler<{ mode: "dark" | "light" }>
): () => void {
return on("theme.mode.changed", handler)
}
import { emit } from "./event-bus"
/**
* Emit a theme reload event.
*/
export function emitThemeReload(): void {
function emitThemeReload(): void {
emit("theme.reload", {})
}
@@ -79,26 +51,3 @@ export function setupThemeSignalHandler(onReload: () => void): () => void {
process.off("SIGUSR2", handler)
}
}
/**
* Create a debounced theme change handler to prevent rapid consecutive updates.
*
* @param handler - The handler to debounce
* @param delay - Delay in milliseconds (default: 100ms)
*/
export function createDebouncedThemeHandler<T>(
handler: (event: T) => void,
delay: number = 100
): (event: T) => void {
let timeout: NodeJS.Timeout | null = null
return (event: T) => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
handler(event)
timeout = null
}, delay)
}
}

View File

@@ -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,
}
}

View File

@@ -3,67 +3,13 @@
* Handles dynamic theme switching by updating CSS custom properties
*/
import { RGBA, type TerminalColors } from "@opentui/core";
import type { ThemeColors } from "../types/settings";
import type { ColorValue, ThemeJson } from "../types/theme-schema";
import type { TerminalColors } from "@opentui/core";
import type { ThemeJson } from "../types/theme-schema";
import { THEME_JSON } from "../constants/themes";
import { getCustomThemes } from "./custom-themes";
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
import { generateSystemTheme } from "./system-theme";
const toCss = (value: ColorValue | RGBA) => {
if (value instanceof RGBA) {
const r = Math.round(value.r * 255);
const g = Math.round(value.g * 255);
const b = Math.round(value.b * 255);
return `rgba(${r}, ${g}, ${b}, ${value.a})`;
}
if (typeof value === "number") return `var(--ansi-${value})`;
if (typeof value === "string") return value;
return value.dark;
};
export function applyTheme(theme: ThemeColors | Record<string, RGBA>) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.style.setProperty(
"--color-background",
toCss(theme.background as ColorValue),
);
root.style.setProperty("--color-surface", toCss(theme.surface as ColorValue));
root.style.setProperty("--color-primary", toCss(theme.primary as ColorValue));
root.style.setProperty(
"--color-secondary",
toCss(theme.secondary as ColorValue),
);
root.style.setProperty("--color-accent", toCss(theme.accent as ColorValue));
root.style.setProperty("--color-text", toCss(theme.text as ColorValue));
root.style.setProperty("--color-muted", toCss(theme.muted as ColorValue));
root.style.setProperty("--color-warning", toCss(theme.warning as ColorValue));
root.style.setProperty("--color-error", toCss(theme.error as ColorValue));
root.style.setProperty("--color-success", toCss(theme.success as ColorValue));
const layers = theme.layerBackgrounds as
| Record<string, ColorValue>
| undefined;
if (layers) {
root.style.setProperty("--color-layer0", toCss(layers.layer0));
root.style.setProperty("--color-layer1", toCss(layers.layer1));
root.style.setProperty("--color-layer2", toCss(layers.layer2));
root.style.setProperty("--color-layer3", toCss(layers.layer3));
}
}
/**
* Get theme mode from system preference
*/
export function getSystemThemeMode(): "dark" | "light" {
if (typeof window === "undefined") return "dark";
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return prefersDark ? "dark" : "light";
}
/**
* Apply CSS variable data-theme attribute
*/
@@ -77,15 +23,6 @@ export async function loadThemes() {
return await getCustomThemes();
}
export async function loadTheme(name: string) {
const themes = await loadThemes();
return themes[name];
}
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
return resolveThemeJson(theme, mode);
}
export function resolveTerminalTheme(
themes: Record<string, ThemeJson>,
name: string,

View File

@@ -11,8 +11,8 @@
*
* Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the
* player process, injected into the (private) `proc` slot of an MpvBackend —
* mpv is the only real backend, and it uses the kill-on-dispose
* exercising one is enough to guard the family.
* mpv is the only real backend; its kill-on-dispose path exercises the
* whole family, so one test suffices to guard it.
*/
import { test, expect } from "bun:test";
import { MpvBackend } from "../src/utils/audio-player";

43
tests/auto-jump.test.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* auto-jump.test.ts — "auto jump to player on podcast start" feature.
*
* The Shell subscribes to the `player.started` event (emitted by useAudio when
* a NEW episode begins, not on resume) and, when `autoJumpToPlayer` is enabled,
* performs `nav.setActiveTab(TABS.PLAYER)` + `nav.enterTabContent()` to land in
* the Player content pane. These tests pin that jump contract against the real
* nav store (the exact two calls the Shell handler makes) and pin the default
* value of the new preference.
*/
import { test, expect } from "bun:test";
import { createRoot } from "solid-js";
import {
createNavigation,
type NavigationState,
} from "../src/context/navigation-store";
import { TABS } from "../src/utils/navigation";
import { loadAppStateFromFile } from "../src/utils/app-persistence";
function withNav(fn: (nav: NavigationState) => void) {
createRoot((dispose) => {
const nav = createNavigation();
fn(nav);
dispose();
});
}
test("auto-jump: PLAYER + enterTabContent lands in the Player content pane", () => {
withNav((nav) => {
// Exact call sequence the Shell handler runs on `player.started`.
nav.setActiveTab(TABS.PLAYER);
nav.enterTabContent();
expect(nav.activeTab()).toBe(TABS.PLAYER);
// Content entered: tab list is no longer the CURRENT pane.
expect(nav.atRootTab()).toBe(false);
});
});
test("auto-jump: preference defaults to true on a config without the field", async () => {
// Existing configs predate the field; load must merge in the default true.
const state = await loadAppStateFromFile();
expect(state.preferences.autoJumpToPlayer).toBe(true);
});

View File

@@ -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(

View File

@@ -47,7 +47,7 @@ test("createNavigation starts on the tab root (atRootTab true)", () => {
// ── depth-tab focus: reads/writes the top frame's focus ───────────────────────
test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () => {
withNav((nav) => {
// FeeD is a depth-tab; its root frame is a the top frame on the stack.
// FEED is a depth-tab; its root frame is the top frame on the stack.
nav.setActiveTab(TABS.FEED);
expect(nav.isDepthTab()).toBe(true);
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(0);
@@ -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();

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

@@ -2,23 +2,23 @@
* yazi-pages-depth.test.ts — task 03 page contract tests.
*
* Every depth-stack tab (Feed / MyShows / Discover / Search / Player /
* Settings) renders through `<YaziPaneRow>` with the parent pane reading the
* previous-depth frame's list (blank placeholder at depth 0). Their `open()`
* action calls `nav.pushDepth(frame)` to drill and the Shell calls
* Settings) renders through `<PaneRow>` with the parent pane reading the
* previous-depth frame's list (muted tab-list placeholder at depth 0). Their
* `open()` action calls `nav.pushDepth(frame)` to drill and the Shell calls
* `nav.popDepth()` on `h`. This file exercises the nav-store contract those
* pages depend on for every depth-tab, asserting the parent-slot data model:
*
* • depth 0 → stack has exactly the root frame (parent pane is blank)
* • drill(l)→ push a child frame; stack length 2, parent = previous list
* • drill(l)→ push again; stack length 3 (Settings sections→items→editor)
* • depth 0 → stack has exactly the root frame (parent pane is the muted tab list)
* • drill(l) → push a child frame; stack length 2, parent = previous list
* • drill(l) → push again; stack length 3 (Settings sections→items→editor)
* • pop(h) → stack shrinks; parent returns to the previous list
* • pop(h) → back at root; parent is blank again
* • pop(h) → back at root; parent is the muted tab list again
*
* The visual "blank → list → list → blank" transition is the union of this
* data model (which list each depth renders) with `<YaziPaneRow>`'s null
* placeholder (covered by yazi-pane-row.test.tsx). Tested at the store level
* because the page `open()` closures are not exported and the nav store is
* the shared contract all four pages route through.
* The visual "muted → list → list → muted" transition is the union of this
* data model (which list each depth renders) with `<PaneRow>`'s parent slot
* (covered by yazi-pane-row.test.tsx). Tested at the store level because the
* page `open()` closures are not exported and the nav store is the shared
* contract all six depth-tabs route through.
*/
import { test, expect } from "bun:test";
@@ -37,7 +37,7 @@ function withNav(fn: (nav: ReturnType<typeof createNavigation>) => void) {
});
}
/** The depth-tabs that render via <YaziPaneRow> (task 03 conversion). */
/** The depth-tabs that render via <PaneRow> (task 03 conversion). */
const CONVERTED_TABS = [
TABS.FEED,
TABS.MYSHOWS,
@@ -55,7 +55,7 @@ for (const tab of CONVERTED_TABS) {
nav.setActiveTab(tab);
expect(nav.isDepthTab()).toBe(true);
// depth 0: exactly the root frame → parent pane renders blank.
// depth 0: exactly the root frame → parent pane is the muted tab list.
expect(nav.currentDepth()).toBe(0);
expect(nav.depthStack()).toHaveLength(1);
@@ -92,7 +92,7 @@ for (const tab of CONVERTED_TABS) {
expect(nav.depthStack()).toHaveLength(2);
expect(nav.topFrame()).toEqual(child);
// pop (h): back to depth 0 — parent pane is blank again.
// pop (h): back to depth 0 — parent pane is the muted tab list again.
expect(nav.popDepth()).toBe(true);
expect(nav.currentDepth()).toBe(0);
expect(nav.depthStack()).toHaveLength(1);
@@ -105,7 +105,7 @@ for (const tab of CONVERTED_TABS) {
expect(nav.currentDepth()).toBe(0);
expect(nav.popDepth()).toBe(false);
expect(nav.currentDepth()).toBe(0);
// the root frame is preserved (parent stays blank, not undefined).
// the root frame is preserved (parent stays the muted tab list, not undefined).
expect(nav.depthStack()).toHaveLength(1);
expect(nav.topFrame()).toBeDefined();
});

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