feat(ui): nerd font icons on hard-defined list rows, graceful degrade

Tabs, Discover categories (replacing unused placeholder glyphs), Settings
sections, and the Feed 'Fetch More' row get Nerd Font glyphs (Font Awesome PUA
codepoints). When the terminal font isn't Nerd Font capable the glyphs render
nothing at all — no tofu, no layout gaps — via supportsNerdFonts() (env
allowlist + PODTUI_NERD_FONTS=1/0 override). Documented in README (Configuration
-> Fonts).
This commit is contained in:
2026-08-10 16:50:21 -04:00
parent 5dce21c038
commit b280af484c
8 changed files with 184 additions and 12 deletions

View File

@@ -195,7 +195,9 @@ Download Scope` (all / none / whitelist, default all). With the whitelist
scope, a search field appears under the setting to pick shows (Space toggles scope, a search field appears under the setting to pick shows (Space toggles
a suggestion in/out), and `w` in My Shows adds/removes the focused show. a suggestion in/out), and `w` in My Shows adds/removes the focused show.
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`, `PODTUI_NERD_FONTS`.
**Fonts** — PodTui prepends Nerd Font glyphs to non-episode/show list rows (tabs, Discover categories, Settings sections, the Feed "Fetch More" row). Icons are hidden automatically when your terminal font is not Nerd Font capable (no tofu, no layout gaps); detection is heuristic (terminal type), so force it with `PODTUI_NERD_FONTS=1` or `=0` if it guesses wrong. A Nerd Font-patched font (e.g. JetBrainsMono Nerd Font) is recommended.
## Troubleshooting ## Troubleshooting

View File

@@ -20,6 +20,7 @@ import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext"; import { useNavigation } from "@/context/NavigationContext";
import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { TABS } from "@/utils/navigation"; import { TABS } from "@/utils/navigation";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
const TAB_LABEL: Record<TABS, string> = { const TAB_LABEL: Record<TABS, string> = {
[TABS.FEED]: "Feed", [TABS.FEED]: "Feed",
@@ -30,12 +31,24 @@ const TAB_LABEL: Record<TABS, string> = {
[TABS.SETTINGS]: "Settings", [TABS.SETTINGS]: "Settings",
}; };
/** Nerd Font glyph per tab (rendered only when the terminal supports them). */
const TAB_ICON: Record<TABS, string> = {
[TABS.FEED]: NF_ICONS.feed,
[TABS.MYSHOWS]: NF_ICONS.shows,
[TABS.DISCOVER]: NF_ICONS.discover,
[TABS.SEARCH]: NF_ICONS.search,
[TABS.PLAYER]: NF_ICONS.player,
[TABS.SETTINGS]: NF_ICONS.settings,
};
/** Numeric TABS values, in declaration order (1..TabsCount). */ /** Numeric TABS values, in declaration order (1..TabsCount). */
const TAB_ORDER = Object.values(TABS).filter( const TAB_ORDER = Object.values(TABS).filter(
(v): v is TABS => typeof v === "number", (v): v is TABS => typeof v === "number",
) as TABS[]; ) as TABS[];
export function TabListPane(props: { muted?: boolean }) { export function TabListPane(props: { muted?: boolean }) {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const { theme } = useTheme(); const { theme } = useTheme();
const nav = useNavigation(); const nav = useNavigation();
@@ -92,6 +105,11 @@ export function TabListPane(props: { muted?: boolean }) {
> >
{/* ── selection marker (j/k cursor) ─────────────────────────── */} {/* ── selection marker (j/k cursor) ─────────────────────────── */}
<text fg={focusFg(tab)}>{isCursor() ? "" : " "}</text> <text fg={focusFg(tab)}>{isCursor() ? "" : " "}</text>
{nerd && (
<text fg={focusFg(tab)} paddingRight={1}>
{TAB_ICON[tab]}
</text>
)}
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text> <text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
<text fg={labelFg()} paddingLeft={1}> <text fg={labelFg()} paddingLeft={1}>
{TAB_LABEL[tab]} {TAB_LABEL[tab]}

View File

@@ -27,6 +27,7 @@ import {
type DepthFrame, type DepthFrame,
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import { PaneRow } from "@/components/PaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
@@ -36,6 +37,8 @@ import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const DiscoverPaneCount = 1; export const DiscoverPaneCount = 1;
function DiscoverPage() { function DiscoverPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const discoverStore = useDiscoverStore(); const discoverStore = useDiscoverStore();
const { theme } = useTheme(); const { theme } = useTheme();
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
@@ -186,6 +189,11 @@ function DiscoverPage() {
<text fg={focusFg(index(), nav.depthFocus(0), false)}> <text fg={focusFg(index(), nav.depthFocus(0), false)}>
{index() === nav.depthFocus(0) ? "" : " "} {index() === nav.depthFocus(0) ? "" : " "}
</text> </text>
{nerd && (
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.icon}
</text>
)}
<text fg={focusFg(index(), nav.depthFocus(0), false)}> <text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.name} {cat.name}
</text> </text>
@@ -223,6 +231,11 @@ function DiscoverPage() {
<text fg={focusFg(index(), lf(), isActive())}> <text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "} {index() === lf() ? "" : " "}
</text> </text>
{nerd && (
<text fg={focusFg(index(), lf(), isActive())}>
{cat.icon}
</text>
)}
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text> <text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
</box> </box>
); );

View File

@@ -32,6 +32,7 @@ import {
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode"; import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed"; import type { Feed } from "@/types/feed";
@@ -45,6 +46,8 @@ export const FeedPaneCount = 1;
type EpItem = { episode: Episode; feed: Feed }; type EpItem = { episode: Episode; feed: Feed };
function FeedPage() { function FeedPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const downloadStore = useDownloadStore(); const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore(); const audioNav = useAudioNavStore();
@@ -312,6 +315,11 @@ function FeedPage() {
<text fg={focusFg(episodes().length, focusedRow(), isActive())}> <text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? "" : " "} {focusedOnMore() ? "" : " "}
</text> </text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show <Show
when={!feedStore.isLoadingMore()} when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />} fallback={<LoadingIndicator label="Fetching…" />}

View File

@@ -27,6 +27,7 @@ import {
type PaneId, type PaneId,
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import type { SettingItem, SettingsSectionDef } from "./types"; import type { SettingItem, SettingsSectionDef } from "./types";
import { usePreferencesItems } from "./PreferencesPanel"; import { usePreferencesItems } from "./PreferencesPanel";
@@ -45,29 +46,38 @@ const SECTIONS: SettingsSectionDef[] = [
id: 0, id: 0,
label: "Sync", label: "Sync",
description: "Import/export subscriptions and sync status.", description: "Import/export subscriptions and sync status.",
icon: NF_ICONS.sync,
}, },
{ {
id: 1, id: 1,
label: "Sources", label: "Sources",
description: "Podcast search/RSS sources — add, enable, remove.", description: "Podcast search/RSS sources — add, enable, remove.",
icon: NF_ICONS.sources,
}, },
{ {
id: 2, id: 2,
label: "Preferences", label: "Preferences",
description: "Theme, font, playback speed, explicit/auto-download.", description: "Theme, font, playback speed, explicit/auto-download.",
icon: NF_ICONS.preferences,
}, },
{ {
id: 3, id: 3,
label: "Visualizer", label: "Visualizer",
description: "Audio visualizer: bars, sensitivity, cutoffs.", description: "Audio visualizer: bars, sensitivity, cutoffs.",
icon: NF_ICONS.visualizer,
}, },
{ {
id: 4, id: 4,
label: "Downloads", label: "Downloads",
description: "Manage downloaded episodes — delete by show or individually.", description: "Manage downloaded episodes — delete by show or individually.",
icon: NF_ICONS.downloads,
}, },
]; ];
// Static: detection never changes mid-session. Module-level because the Row
// component below (a sibling module function) needs it too.
const nerd = supportsNerdFonts();
/** Resolve the items for a section id at render time. */ /** Resolve the items for a section id at render time. */
function sectionItems(sectionId: number): SettingItem[] { function sectionItems(sectionId: number): SettingItem[] {
switch (sectionId) { switch (sectionId) {
@@ -286,6 +296,7 @@ export function SettingsPage() {
{(section, index) => ( {(section, index) => (
<Row <Row
label={section.label} label={section.label}
icon={section.icon}
focused={index() === focusedSectionIdx()} focused={index() === focusedSectionIdx()}
active={false} active={false}
/> />
@@ -315,6 +326,7 @@ export function SettingsPage() {
{(section, index) => ( {(section, index) => (
<Row <Row
label={section.label} label={section.label}
icon={section.icon}
focused={index() === focusedSectionIdx()} focused={index() === focusedSectionIdx()}
active={isActive()} active={isActive()}
onMouseDown={() => { onMouseDown={() => {
@@ -407,6 +419,7 @@ function Row(props: {
focused: boolean; focused: boolean;
active: boolean; active: boolean;
hint?: string; hint?: string;
icon?: string;
onMouseDown?: () => void; onMouseDown?: () => void;
}) { }) {
const { theme } = useTheme(); const { theme } = useTheme();
@@ -434,6 +447,7 @@ function Row(props: {
onMouseDown={props.onMouseDown} onMouseDown={props.onMouseDown}
> >
<text fg={fg()}>{props.focused ? "" : " "}</text> <text fg={fg()}>{props.focused ? "" : " "}</text>
{props.icon && nerd && <text fg={fg()}>{props.icon}</text>}
<text fg={fg()}>{props.label}</text> <text fg={fg()}>{props.label}</text>
<Show when={props.value}> <Show when={props.value}>
<box flexGrow={1} /> <box flexGrow={1} />

View File

@@ -41,5 +41,7 @@ export interface SettingsSectionDef {
id: number; id: number;
label: string; label: string;
description: string; description: string;
/** Nerd Font glyph for the section row (rendered only when supported). */
icon: string;
items?: () => SettingItem[]; items?: () => SettingItem[];
} }

View File

@@ -20,17 +20,17 @@ export interface DiscoverCategory {
} }
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [ export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
{ id: "all", name: "All", icon: "*" }, { id: "all", name: "All", icon: "\uF0CA" },
{ id: "technology", name: "Technology", icon: ">" }, { id: "technology", name: "Technology", icon: "\uF2DB" },
{ id: "science", name: "Science", icon: "~" }, { id: "science", name: "Science", icon: "\uF0C3" },
{ id: "comedy", name: "Comedy", icon: ")" }, { id: "comedy", name: "Comedy", icon: "\uF118" },
{ id: "news", name: "News", icon: "!" }, { id: "news", name: "News", icon: "\uF1EA" },
{ id: "business", name: "Business", icon: "$" }, { id: "business", name: "Business", icon: "\uF0B1" },
{ id: "health", name: "Health", icon: "+" }, { id: "health", name: "Health", icon: "\uF21E" },
{ id: "education", name: "Education", icon: "?" }, { id: "education", name: "Education", icon: "\uF19D" },
{ id: "sports", name: "Sports", icon: "#" }, { id: "sports", name: "Sports", icon: "\uF1E3" },
{ id: "true-crime", name: "True Crime", icon: "%" }, { id: "true-crime", name: "True Crime", icon: "\uF00E" },
{ id: "arts", name: "Arts", icon: "@" }, { id: "arts", name: "Arts", icon: "\uF1FC" },
]; ];
// ── Remote featured-shows manifest ─────────────────────────────────────────── // ── Remote featured-shows manifest ───────────────────────────────────────────

115
src/utils/nerd-fonts.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* Nerd Font support detection + icon codepoints for PodTui.
*
* The app prepends Nerd Font glyphs to hard-defined list rows (tabs, Discover
* categories, Settings sections, the Feed "Fetch More" row). When the user's
* terminal font is NOT Nerd Font capable those glyphs must not render at all —
* no tofu boxes, no empty columns — so every call site gates the icon on
* `supportsNerdFonts()`.
*
* Detection is a heuristic (see `supportsNerdFonts`); the `PODTUI_NERD_FONTS`
* env override wins over everything so a wrong guess is always fixable.
* Under tmux the outer terminal decides — `TMUX` being set counts as
* capable (the multiplexer passes glyphs through), matching the same choice
* made for `screen`-prefixed TERM values. See README → Configuration → Fonts.
*
* This module is deliberately free of Solid/JSX imports so it stays
* unit-testable in isolation.
*/
// ── Detection ────────────────────────────────────────────────────────────────
// Memoized: the terminal does not change mid-session, so detect once.
let cached: boolean | null = null;
/**
* True when the terminal is (very likely) using a Nerd Font-patched font.
*
* Order:
* a. `PODTUI_NERD_FONTS` env override ("1"/"true" → true, "0"/"false" →
* false) — wins over everything.
* b. Allowlist: TERM_PROGRAM ∈ {iTerm.app, WezTerm, vscode, ghostty, rio,
* hyper, tabby, contour}, or TERM starts with {xterm-kitty, foot,
* alacritty, contour, screen} (tmux/screen passthrough — the outer
* terminal decides), or WT_SESSION set (Windows Terminal), or TMUX set.
* Case-insensitive.
* c. Everything else (Terminal.app default SF Mono, plain xterm, unknown)
* → false.
*/
export function supportsNerdFonts(): boolean {
if (cached !== null) return cached;
// a. Env override wins over everything.
const override = process.env.PODTUI_NERD_FONTS?.trim().toLowerCase();
if (override === "1" || override === "true") {
cached = true;
return cached;
}
if (override === "0" || override === "false") {
cached = false;
return cached;
}
// b. Allowlist.
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() ?? "";
const term = process.env.TERM?.toLowerCase() ?? "";
const TERM_PROGRAM_ALLOWLIST: Record<string, true> = {
"iterm.app": true,
wezterm: true,
vscode: true,
ghostty: true,
rio: true,
hyper: true,
tabby: true,
contour: true,
};
const TERM_PREFIX_ALLOWLIST = [
"xterm-kitty",
"foot",
"alacritty",
"contour",
"screen",
];
cached =
TERM_PROGRAM_ALLOWLIST[termProgram] === true ||
TERM_PREFIX_ALLOWLIST.some((prefix) => term.startsWith(prefix)) ||
!!process.env.WT_SESSION ||
!!process.env.TMUX;
// c. Everything else falls through to false.
return cached;
}
// ── Icon codepoints ──────────────────────────────────────────────────────────
// Font Awesome codepoints in the Nerd Font PUA range — stable across Nerd
// Font versions. Keyed by the semantic names the list rows use.
export const NF_ICONS: Record<string, string> = {
feed: "\uF09E",
shows: "\uF005",
discover: "\uF14E",
search: "\uF002",
player: "\uF144",
settings: "\uF013",
sync: "\uF021",
sources: "\uF143",
preferences: "\uF1DE",
visualizer: "\uF080",
downloads: "\uF019",
all: "\uF0CA",
technology: "\uF2DB",
science: "\uF0C3",
comedy: "\uF118",
news: "\uF1EA",
business: "\uF0B1",
health: "\uF21E",
education: "\uF19D",
sports: "\uF1E3",
"true-crime": "\uF00E",
arts: "\uF1FC",
more: "\uF141",
add: "\uF067",
};
/** Glyph for a named icon when Nerd Fonts are supported, else "". */
export function nfIcon(name: string): string {
return supportsNerdFonts() ? NF_ICONS[name] ?? "" : "";
}