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:
@@ -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
|
||||
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
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useTheme } from "@/context/ThemeContext";
|
||||
import { useNavigation } from "@/context/NavigationContext";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
import { TABS } from "@/utils/navigation";
|
||||
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
|
||||
const TAB_LABEL: Record<TABS, string> = {
|
||||
[TABS.FEED]: "Feed",
|
||||
@@ -30,12 +31,24 @@ const TAB_LABEL: Record<TABS, string> = {
|
||||
[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). */
|
||||
const TAB_ORDER = Object.values(TABS).filter(
|
||||
(v): v is TABS => typeof v === "number",
|
||||
) as TABS[];
|
||||
|
||||
export function TabListPane(props: { muted?: boolean }) {
|
||||
// Static: detection never changes mid-session.
|
||||
const nerd = supportsNerdFonts();
|
||||
const { theme } = useTheme();
|
||||
const nav = useNavigation();
|
||||
|
||||
@@ -92,6 +105,11 @@ export function TabListPane(props: { muted?: boolean }) {
|
||||
>
|
||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||
<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={labelFg()} paddingLeft={1}>
|
||||
{TAB_LABEL[tab]}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
@@ -36,6 +37,8 @@ import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
export const DiscoverPaneCount = 1;
|
||||
|
||||
function DiscoverPage() {
|
||||
// Static: detection never changes mid-session.
|
||||
const nerd = supportsNerdFonts();
|
||||
const discoverStore = useDiscoverStore();
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
@@ -186,6 +189,11 @@ function DiscoverPage() {
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||||
</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{cat.icon}
|
||||
</text>
|
||||
)}
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{cat.name}
|
||||
</text>
|
||||
@@ -223,6 +231,11 @@ function DiscoverPage() {
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{cat.icon}
|
||||
</text>
|
||||
)}
|
||||
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
@@ -45,6 +46,8 @@ export const FeedPaneCount = 1;
|
||||
type EpItem = { episode: Episode; feed: Feed };
|
||||
|
||||
function FeedPage() {
|
||||
// Static: detection never changes mid-session.
|
||||
const nerd = supportsNerdFonts();
|
||||
const feedStore = useFeedStore();
|
||||
const downloadStore = useDownloadStore();
|
||||
const audioNav = useAudioNavStore();
|
||||
@@ -312,6 +315,11 @@ function FeedPage() {
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
{focusedOnMore() ? "❯" : " "}
|
||||
</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
{NF_ICONS.more}
|
||||
</text>
|
||||
)}
|
||||
<Show
|
||||
when={!feedStore.isLoadingMore()}
|
||||
fallback={<LoadingIndicator label="Fetching…" />}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type PaneId,
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { SettingItem, SettingsSectionDef } from "./types";
|
||||
import { usePreferencesItems } from "./PreferencesPanel";
|
||||
@@ -45,29 +46,38 @@ const SECTIONS: SettingsSectionDef[] = [
|
||||
id: 0,
|
||||
label: "Sync",
|
||||
description: "Import/export subscriptions and sync status.",
|
||||
icon: NF_ICONS.sync,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
label: "Sources",
|
||||
description: "Podcast search/RSS sources — add, enable, remove.",
|
||||
icon: NF_ICONS.sources,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: "Preferences",
|
||||
description: "Theme, font, playback speed, explicit/auto-download.",
|
||||
icon: NF_ICONS.preferences,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: "Visualizer",
|
||||
description: "Audio visualizer: bars, sensitivity, cutoffs.",
|
||||
icon: NF_ICONS.visualizer,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: "Downloads",
|
||||
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. */
|
||||
function sectionItems(sectionId: number): SettingItem[] {
|
||||
switch (sectionId) {
|
||||
@@ -286,6 +296,7 @@ export function SettingsPage() {
|
||||
{(section, index) => (
|
||||
<Row
|
||||
label={section.label}
|
||||
icon={section.icon}
|
||||
focused={index() === focusedSectionIdx()}
|
||||
active={false}
|
||||
/>
|
||||
@@ -315,6 +326,7 @@ export function SettingsPage() {
|
||||
{(section, index) => (
|
||||
<Row
|
||||
label={section.label}
|
||||
icon={section.icon}
|
||||
focused={index() === focusedSectionIdx()}
|
||||
active={isActive()}
|
||||
onMouseDown={() => {
|
||||
@@ -407,6 +419,7 @@ function Row(props: {
|
||||
focused: boolean;
|
||||
active: boolean;
|
||||
hint?: string;
|
||||
icon?: string;
|
||||
onMouseDown?: () => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
@@ -434,6 +447,7 @@ function Row(props: {
|
||||
onMouseDown={props.onMouseDown}
|
||||
>
|
||||
<text fg={fg()}>{props.focused ? "❯" : " "}</text>
|
||||
{props.icon && nerd && <text fg={fg()}>{props.icon}</text>}
|
||||
<text fg={fg()}>{props.label}</text>
|
||||
<Show when={props.value}>
|
||||
<box flexGrow={1} />
|
||||
|
||||
@@ -41,5 +41,7 @@ export interface SettingsSectionDef {
|
||||
id: number;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Nerd Font glyph for the section row (rendered only when supported). */
|
||||
icon: string;
|
||||
items?: () => SettingItem[];
|
||||
}
|
||||
|
||||
@@ -20,17 +20,17 @@ export interface DiscoverCategory {
|
||||
}
|
||||
|
||||
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
||||
{ id: "all", name: "All", icon: "*" },
|
||||
{ id: "technology", name: "Technology", icon: ">" },
|
||||
{ id: "science", name: "Science", icon: "~" },
|
||||
{ id: "comedy", name: "Comedy", icon: ")" },
|
||||
{ id: "news", name: "News", icon: "!" },
|
||||
{ id: "business", name: "Business", icon: "$" },
|
||||
{ id: "health", name: "Health", icon: "+" },
|
||||
{ id: "education", name: "Education", icon: "?" },
|
||||
{ id: "sports", name: "Sports", icon: "#" },
|
||||
{ id: "true-crime", name: "True Crime", icon: "%" },
|
||||
{ id: "arts", name: "Arts", icon: "@" },
|
||||
{ id: "all", name: "All", icon: "\uF0CA" },
|
||||
{ id: "technology", name: "Technology", icon: "\uF2DB" },
|
||||
{ id: "science", name: "Science", icon: "\uF0C3" },
|
||||
{ id: "comedy", name: "Comedy", icon: "\uF118" },
|
||||
{ id: "news", name: "News", icon: "\uF1EA" },
|
||||
{ id: "business", name: "Business", icon: "\uF0B1" },
|
||||
{ id: "health", name: "Health", icon: "\uF21E" },
|
||||
{ id: "education", name: "Education", icon: "\uF19D" },
|
||||
{ id: "sports", name: "Sports", icon: "\uF1E3" },
|
||||
{ id: "true-crime", name: "True Crime", icon: "\uF00E" },
|
||||
{ id: "arts", name: "Arts", icon: "\uF1FC" },
|
||||
];
|
||||
|
||||
// ── Remote featured-shows manifest ───────────────────────────────────────────
|
||||
|
||||
115
src/utils/nerd-fonts.ts
Normal file
115
src/utils/nerd-fonts.ts
Normal 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] ?? "" : "";
|
||||
}
|
||||
Reference in New Issue
Block a user