diff --git a/README.md b/README.md index be39dc8..143690c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/components/TabPanel.tsx b/src/components/TabPanel.tsx index f0bce18..a8e26da 100644 --- a/src/components/TabPanel.tsx +++ b/src/components/TabPanel.tsx @@ -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.FEED]: "Feed", @@ -30,12 +31,24 @@ const TAB_LABEL: Record = { [TABS.SETTINGS]: "Settings", }; +/** Nerd Font glyph per tab (rendered only when the terminal supports them). */ +const TAB_ICON: Record = { + [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) ─────────────────────────── */} {isCursor() ? "❯" : " "} + {nerd && ( + + {TAB_ICON[tab]} + + )} {tab} {TAB_LABEL[tab]} diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index a34543c..a090b52 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -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() { {index() === nav.depthFocus(0) ? "❯" : " "} + {nerd && ( + + {cat.icon} + + )} {cat.name} @@ -223,6 +231,11 @@ function DiscoverPage() { {index() === lf() ? "❯" : " "} + {nerd && ( + + {cat.icon} + + )} {cat.name} ); diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index 7c8bd4e..a8aaf7b 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -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() { {focusedOnMore() ? "❯" : " "} + {nerd && ( + + {NF_ICONS.more} + + )} } diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 741638b..b76b1db 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -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) => ( @@ -315,6 +326,7 @@ export function SettingsPage() { {(section, index) => ( { @@ -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} > {props.focused ? "❯" : " "} + {props.icon && nerd && {props.icon}} {props.label} diff --git a/src/pages/Settings/types.ts b/src/pages/Settings/types.ts index 3e22c2d..f7363e8 100644 --- a/src/pages/Settings/types.ts +++ b/src/pages/Settings/types.ts @@ -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[]; } diff --git a/src/stores/discover.ts b/src/stores/discover.ts index ba4565e..47c18dc 100644 --- a/src/stores/discover.ts +++ b/src/stores/discover.ts @@ -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 ─────────────────────────────────────────── diff --git a/src/utils/nerd-fonts.ts b/src/utils/nerd-fonts.ts new file mode 100644 index 0000000..17e83e8 --- /dev/null +++ b/src/utils/nerd-fonts.ts @@ -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 = { + "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 = { + 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] ?? "" : ""; +}