From ffffc18924e4ca675a459b924bc4912464d46dcd Mon Sep 17 00:00:00 2001 From: Roy Kollen Svendsen Date: Mon, 22 Jun 2026 15:51:18 +0200 Subject: [PATCH 1/6] add icon theme sets (nerd/unicode/ascii) with width invariants --- src/ui/icons.rs | 296 ++++++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 52 +-------- 2 files changed, 297 insertions(+), 51 deletions(-) create mode 100644 src/ui/icons.rs diff --git a/src/ui/icons.rs b/src/ui/icons.rs new file mode 100644 index 0000000..f63850a --- /dev/null +++ b/src/ui/icons.rs @@ -0,0 +1,296 @@ +//! UI glyphs as a swappable theme. +//! +//! `IconSet` holds one glyph per UI affordance; three const instances cover the +//! cases: `NERD` (Nerd Font Material Design glyphs, needs a patched font), +//! `UNICODE` (plain single-cell symbols that render anywhere) and `ASCII` +//! (pure ASCII for the most limited terminals). The active set is chosen once at +//! startup (see `icons()`/`init()`); rendering reads it through `icons()`. +//! +//! Every glyph in every set must be exactly one display cell wide and a single +//! Unicode scalar — the tests below enforce it. The `NERD` set targets Nerd +//! Fonts v3 (`nf-md-*`, U+F0001+); the recommended fallback font is +//! "Symbols Nerd Font Mono" (the Mono variant keeps glyphs single-cell). + +// Legacy flat constants — still referenced by call sites until the migration +// commit replaces them with `icons().field`. Kept here so all PUA literals live +// in one module. +pub const INBOX: &str = "\u{F02FB}"; +pub const OUTBOX: &str = "\u{F048A}"; +pub const THREADS: &str = "\u{F0369}"; +pub const CHANNELS: &str = "\u{F0423}"; +pub const GROUPS: &str = "\u{F0849}"; +pub const PUBLIC: &str = "\u{F0FC6}"; +pub const ENCRYPTED: &str = "\u{F033E}"; +pub const CREATOR: &str = "\u{F01A5}"; +pub const DRAFT: &str = "\u{F03EB}"; +pub const CHECK: &str = "\u{F012C}"; +pub const ERROR: &str = "\u{F0028}"; +pub const LOCK_CLOCK: &str = "\u{F097F}"; +pub const HISTORY: &str = "\u{F02DA}"; +pub const SYNC: &str = "\u{F04E6}"; +pub const BLOCK: &str = "\u{F01A7}"; +pub const ACCOUNT: &str = "\u{F0B55}"; +pub const WALLET: &str = "\u{F0BDD}"; +pub const KEY: &str = "\u{F030B}"; +pub const HELP: &str = "\u{F0625}"; +pub const EXIT: &str = "\u{F0206}"; +pub const MAGNIFY: &str = "\u{F0349}"; +pub const MENU: &str = "\u{F035C}"; +pub const KEYBOARD: &str = "\u{F097B}"; +pub const COG: &str = "\u{F0493}"; +pub const REFRESH: &str = "\u{F0450}"; +pub const SWAP: &str = "\u{F04E1}"; +pub const COPY: &str = "\u{F018F}"; +pub const LOCK_OPEN: &str = "\u{F0340}"; +pub const CHEVRON_LEFT: &str = "\u{F0141}"; +pub const CHEVRON_RIGHT: &str = "\u{F0142}"; + +/// Nerd Font Material Design glyphs (v3 nf-md-*). Needs a patched font. +// `allow` is dropped in the next commit once `icons()`/`resolve` consume the sets. +#[allow(dead_code)] +pub const NERD: IconSet = IconSet { + inbox: "\u{F02FB}", + outbox: "\u{F048A}", + threads: "\u{F0369}", + channels: "\u{F0423}", + groups: "\u{F0849}", + public: "\u{F0FC6}", + encrypted: "\u{F033E}", + creator: "\u{F01A5}", + draft: "\u{F03EB}", + check: "\u{F012C}", + error: "\u{F0028}", + lock_clock: "\u{F097F}", + history: "\u{F02DA}", + sync: "\u{F04E6}", + block: "\u{F01A7}", + account: "\u{F0B55}", + wallet: "\u{F0BDD}", + key: "\u{F030B}", + help: "\u{F0625}", + exit: "\u{F0206}", + magnify: "\u{F0349}", + menu: "\u{F035C}", + keyboard: "\u{F097B}", + cog: "\u{F0493}", + refresh: "\u{F0450}", + swap: "\u{F04E1}", + copy: "\u{F018F}", + lock_open: "\u{F0340}", + chevron_left: "\u{F0141}", + chevron_right: "\u{F0142}", + arrow_up: "\u{F005D}", + arrow_down: "\u{F0045}", + arrow_left: "\u{F004D}", + arrow_right: "\u{F0054}", +}; + +/// Plain single-cell Unicode symbols (East-Asian-Width N) that render on any +/// terminal without a Nerd Font. The default theme. +#[allow(dead_code)] +pub const UNICODE: IconSet = IconSet { + inbox: "\u{21A7}", // downwards arrow from bar + outbox: "\u{21A5}", // upwards arrow from bar + threads: "\u{2263}", // strictly equivalent to (stacked bars) + channels: "#", + groups: "\u{2042}", // asterism + public: "\u{2641}", // earth + encrypted: "\u{2327}", // x in a rectangle box + creator: "\u{2734}", // eight pointed star + draft: "\u{270E}", // lower right pencil + check: "\u{2713}", // check mark + error: "\u{203C}", // double exclamation + lock_clock: "\u{29D7}", // black hourglass + history: "\u{21BA}", // anticlockwise circle arrow + sync: "\u{21BB}", // clockwise circle arrow + block: "\u{25AA}", // black small square + account: "\u{263A}", // white smiling face + wallet: "\u{25AD}", // white rectangle + key: "\u{2325}", // option key + help: "?", + exit: "\u{2715}", // multiplication x + magnify: "\u{2315}", // telephone recorder (lens) + menu: "\u{2263}", // stacked bars + keyboard: "\u{2328}", // keyboard + cog: "\u{2699}", // gear + refresh: "\u{21BB}", // clockwise circle arrow + swap: "\u{21C4}", // rightwards over leftwards arrow + copy: "\u{29C9}", // two joined squares + lock_open: "\u{25CC}", // dotted circle + chevron_left: "\u{2039}", // single left angle quote + chevron_right: "\u{203A}", // single right angle quote + arrow_up: "\u{25B4}", // small up triangle + arrow_down: "\u{25BE}", // small down triangle + arrow_left: "\u{25C2}", // small left triangle + arrow_right: "\u{25B8}", // small right triangle +}; + +/// Pure ASCII for the most limited terminals (and non-TTY output). +#[allow(dead_code)] +pub const ASCII: IconSet = IconSet { + inbox: "i", + outbox: "o", + threads: "t", + channels: "#", + groups: "g", + public: "p", + encrypted: "e", + creator: "@", + draft: "d", + check: "+", + error: "!", + lock_clock: "T", + history: "h", + sync: "s", + block: "#", + account: "a", + wallet: "w", + key: "k", + help: "?", + exit: "q", + magnify: "/", + menu: "=", + keyboard: "K", + cog: "*", + refresh: "r", + swap: "~", + copy: "c", + lock_open: "u", + chevron_left: "<", + chevron_right: ">", + arrow_up: "^", + arrow_down: "v", + arrow_left: "<", + arrow_right: ">", +}; + +/// One glyph per UI affordance. Fields are `&'static str` so a set is a plain +/// const and call sites keep `&'static str` types. +#[allow(dead_code)] +pub struct IconSet { + pub inbox: &'static str, + pub outbox: &'static str, + pub threads: &'static str, + pub channels: &'static str, + pub groups: &'static str, + pub public: &'static str, + pub encrypted: &'static str, + pub creator: &'static str, + pub draft: &'static str, + pub check: &'static str, + pub error: &'static str, + pub lock_clock: &'static str, + pub history: &'static str, + pub sync: &'static str, + pub block: &'static str, + pub account: &'static str, + pub wallet: &'static str, + pub key: &'static str, + pub help: &'static str, + pub exit: &'static str, + pub magnify: &'static str, + pub menu: &'static str, + pub keyboard: &'static str, + pub cog: &'static str, + pub refresh: &'static str, + pub swap: &'static str, + pub copy: &'static str, + pub lock_open: &'static str, + pub chevron_left: &'static str, + pub chevron_right: &'static str, + pub arrow_up: &'static str, + pub arrow_down: &'static str, + pub arrow_left: &'static str, + pub arrow_right: &'static str, +} + +#[cfg(test)] +impl IconSet { + /// All themes, for tests that must check every set. + pub const ALL: [&'static IconSet; 3] = [&NERD, &UNICODE, &ASCII]; + + /// (field name, glyph) for every field, so tests can iterate exhaustively. + pub fn all_glyphs(&self) -> [(&'static str, &'static str); 34] { + [ + ("inbox", self.inbox), + ("outbox", self.outbox), + ("threads", self.threads), + ("channels", self.channels), + ("groups", self.groups), + ("public", self.public), + ("encrypted", self.encrypted), + ("creator", self.creator), + ("draft", self.draft), + ("check", self.check), + ("error", self.error), + ("lock_clock", self.lock_clock), + ("history", self.history), + ("sync", self.sync), + ("block", self.block), + ("account", self.account), + ("wallet", self.wallet), + ("key", self.key), + ("help", self.help), + ("exit", self.exit), + ("magnify", self.magnify), + ("menu", self.menu), + ("keyboard", self.keyboard), + ("cog", self.cog), + ("refresh", self.refresh), + ("swap", self.swap), + ("copy", self.copy), + ("lock_open", self.lock_open), + ("chevron_left", self.chevron_left), + ("chevron_right", self.chevron_right), + ("arrow_up", self.arrow_up), + ("arrow_down", self.arrow_down), + ("arrow_left", self.arrow_left), + ("arrow_right", self.arrow_right), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use unicode_width::UnicodeWidthStr; + + #[test] + fn every_glyph_is_single_cell() { + for set in IconSet::ALL { + for (name, glyph) in set.all_glyphs() { + let w = UnicodeWidthStr::width(glyph); + assert_eq!( + w, 1, + "glyph {name} = {glyph:?} has display width {w}, expected 1" + ); + } + } + } + + #[test] + fn every_glyph_is_single_scalar() { + // One scalar means no VS16/ZWJ that could flip width to 2. + for set in IconSet::ALL { + for (name, glyph) in set.all_glyphs() { + assert_eq!( + glyph.chars().count(), + 1, + "glyph {name} = {glyph:?} must be a single scalar" + ); + } + } + } + + #[test] + fn nerd_is_byte_identical() { + // The nerd set must match the historical codepoints exactly, so opting + // into `nerd` looks the same as old taolk. + assert_eq!(NERD.inbox, "\u{F02FB}"); + assert_eq!(NERD.channels, "\u{F0423}"); + assert_eq!(NERD.key, "\u{F030B}"); + assert_eq!(NERD.wallet, "\u{F0BDD}"); + assert_eq!(NERD.arrow_up, "\u{F005D}"); + assert_eq!(NERD.arrow_right, "\u{F0054}"); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 36ea86d..19375a9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -11,57 +11,7 @@ pub mod symbols; pub mod timeline; pub mod welcome; -pub mod icons { - // Conversation types - pub const INBOX: &str = "\u{F02FB}"; - pub const OUTBOX: &str = "\u{F048A}"; - pub const THREADS: &str = "\u{F0369}"; - pub const CHANNELS: &str = "\u{F0423}"; - pub const GROUPS: &str = "\u{F0849}"; - - // Message attributes - pub const PUBLIC: &str = "\u{F0FC6}"; - pub const ENCRYPTED: &str = "\u{F033E}"; - pub const CREATOR: &str = "\u{F01A5}"; - pub const DRAFT: &str = "\u{F03EB}"; - - // Status indicators - pub const CHECK: &str = "\u{F012C}"; - pub const ERROR: &str = "\u{F0028}"; - pub const LOCK_CLOCK: &str = "\u{F097F}"; - pub const HISTORY: &str = "\u{F02DA}"; - pub const SYNC: &str = "\u{F04E6}"; - - // Chain primitives - pub const BLOCK: &str = "\u{F01A7}"; - - // Identity & secrets - pub const ACCOUNT: &str = "\u{F0B55}"; - pub const WALLET: &str = "\u{F0BDD}"; - pub const KEY: &str = "\u{F030B}"; - - // Commands & affordances - pub const HELP: &str = "\u{F0625}"; - pub const EXIT: &str = "\u{F0206}"; - pub const MAGNIFY: &str = "\u{F0349}"; - pub const MENU: &str = "\u{F035C}"; - pub const KEYBOARD: &str = "\u{F097B}"; - pub const COG: &str = "\u{F0493}"; - pub const REFRESH: &str = "\u{F0450}"; - pub const SWAP: &str = "\u{F04E1}"; - pub const COPY: &str = "\u{F018F}"; - pub const LOCK_OPEN: &str = "\u{F0340}"; - - // Navigation - // ARROW_UP/DOWN/LEFT/RIGHT are inlined at call sites because Rust const rules - // forbid combo string composition from const refs. Codepoints: - // nf-md-arrow_up \u{F005D} - // nf-md-arrow_down \u{F0045} - // nf-md-arrow_left \u{F004D} - // nf-md-arrow_right \u{F0054} - pub const CHEVRON_LEFT: &str = "\u{F0141}"; - pub const CHEVRON_RIGHT: &str = "\u{F0142}"; -} +pub mod icons; use crate::app::App; use ratatui::Frame; From 8e56ff831c6efd339eecf5eda30f1c01c7922bec Mon Sep 17 00:00:00 2001 From: Roy Kollen Svendsen Date: Mon, 22 Jun 2026 15:59:26 +0200 Subject: [PATCH 2/6] add runtime icon accessor and theme resolution --- src/ui/icons.rs | 197 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 192 insertions(+), 5 deletions(-) diff --git a/src/ui/icons.rs b/src/ui/icons.rs index f63850a..6c4306e 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -11,6 +11,10 @@ //! Fonts v3 (`nf-md-*`, U+F0001+); the recommended fallback font is //! "Symbols Nerd Font Mono" (the Mono variant keeps glyphs single-cell). +// The runtime API below is consumed by the migration and startup commits; until +// then these items are intentionally unused. Removed once startup wires it in. +#![allow(dead_code)] + // Legacy flat constants — still referenced by call sites until the migration // commit replaces them with `icons().field`. Kept here so all PUA literals live // in one module. @@ -46,8 +50,6 @@ pub const CHEVRON_LEFT: &str = "\u{F0141}"; pub const CHEVRON_RIGHT: &str = "\u{F0142}"; /// Nerd Font Material Design glyphs (v3 nf-md-*). Needs a patched font. -// `allow` is dropped in the next commit once `icons()`/`resolve` consume the sets. -#[allow(dead_code)] pub const NERD: IconSet = IconSet { inbox: "\u{F02FB}", outbox: "\u{F048A}", @@ -87,7 +89,6 @@ pub const NERD: IconSet = IconSet { /// Plain single-cell Unicode symbols (East-Asian-Width N) that render on any /// terminal without a Nerd Font. The default theme. -#[allow(dead_code)] pub const UNICODE: IconSet = IconSet { inbox: "\u{21A7}", // downwards arrow from bar outbox: "\u{21A5}", // upwards arrow from bar @@ -126,7 +127,6 @@ pub const UNICODE: IconSet = IconSet { }; /// Pure ASCII for the most limited terminals (and non-TTY output). -#[allow(dead_code)] pub const ASCII: IconSet = IconSet { inbox: "i", outbox: "o", @@ -166,7 +166,6 @@ pub const ASCII: IconSet = IconSet { /// One glyph per UI affordance. Fields are `&'static str` so a set is a plain /// const and call sites keep `&'static str` types. -#[allow(dead_code)] pub struct IconSet { pub inbox: &'static str, pub outbox: &'static str, @@ -204,6 +203,146 @@ pub struct IconSet { pub arrow_right: &'static str, } +use std::sync::OnceLock; + +/// The three selectable themes. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum IconStyle { + Nerd, + Unicode, + Ascii, +} + +impl IconStyle { + /// Parse a config/env value; `None` for anything unrecognised. + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "nerd" => Some(Self::Nerd), + "unicode" => Some(Self::Unicode), + "ascii" => Some(Self::Ascii), + _ => None, + } + } + + /// The theme this style selects. + pub fn set(self) -> &'static IconSet { + match self { + Self::Nerd => &NERD, + Self::Unicode => &UNICODE, + Self::Ascii => &ASCII, + } + } +} + +static ACTIVE: OnceLock<&'static IconSet> = OnceLock::new(); + +/// The active theme. Defaults to the safe `UNICODE` set until `init` runs. +pub fn icons() -> &'static IconSet { + ACTIVE.get().copied().unwrap_or(&UNICODE) +} + +/// Set the active theme once. The first call wins; later calls (e.g. a wallet +/// switch re-entering the session) are no-ops. +pub fn init(set: &'static IconSet) { + let _ = ACTIVE.set(set); +} + +/// Pure theme resolution. Precedence: `TAOLK_ICONS` env, then `NERD_FONT=1`, +/// then the `ui.icons` config value, then a non-TTY downgrade to ASCII, then the +/// default `UNICODE`. An explicit env/config choice beats the non-TTY rule. +pub fn resolve_with( + cfg_value: &str, + taolk_env: Option<&str>, + nerd_env: Option<&str>, + is_tty: bool, +) -> &'static IconSet { + if let Some(style) = taolk_env.and_then(IconStyle::parse) { + return style.set(); + } + if nerd_env == Some("1") { + return &NERD; + } + if let Some(style) = IconStyle::parse(cfg_value) { + return style.set(); + } + if !is_tty { + return &ASCII; + } + &UNICODE +} + +/// Resolve the active theme from the real environment and TTY state. +pub fn resolve(cfg_value: &str) -> &'static IconSet { + use std::io::IsTerminal; + let taolk = std::env::var("TAOLK_ICONS").ok(); + let nerd = std::env::var("NERD_FONT").ok(); + resolve_with( + cfg_value, + taolk.as_deref(), + nerd.as_deref(), + std::io::stdout().is_terminal(), + ) +} + +/// An icon referenced from a `const` table, where a runtime `&str` can't go. +/// Resolved to a glyph through the active theme at render time. +#[derive(Clone, Copy)] +pub enum Icon { + Threads, + Outbox, + Groups, + Magnify, + Channels, + Inbox, + Menu, + Help, + Block, + Refresh, + Copy, + LockOpen, + Encrypted, + Swap, + Exit, + Keyboard, + Cog, + Draft, + Check, + Account, +} + +impl Icon { + /// The glyph for this icon in a given theme (pure). + pub fn pick(self, set: &IconSet) -> &'static str { + match self { + Icon::Threads => set.threads, + Icon::Outbox => set.outbox, + Icon::Groups => set.groups, + Icon::Magnify => set.magnify, + Icon::Channels => set.channels, + Icon::Inbox => set.inbox, + Icon::Menu => set.menu, + Icon::Help => set.help, + Icon::Block => set.block, + Icon::Refresh => set.refresh, + Icon::Copy => set.copy, + Icon::LockOpen => set.lock_open, + Icon::Encrypted => set.encrypted, + Icon::Swap => set.swap, + Icon::Exit => set.exit, + Icon::Keyboard => set.keyboard, + Icon::Cog => set.cog, + Icon::Draft => set.draft, + Icon::Check => set.check, + Icon::Account => set.account, + } + } + + /// The glyph for this icon in the active theme. + pub fn glyph(self) -> &'static str { + self.pick(icons()) + } +} + #[cfg(test)] impl IconSet { /// All themes, for tests that must check every set. @@ -293,4 +432,52 @@ mod tests { assert_eq!(NERD.arrow_up, "\u{F005D}"); assert_eq!(NERD.arrow_right, "\u{F0054}"); } + + #[test] + fn iconstyle_parse_is_case_insensitive() { + assert_eq!(IconStyle::parse("nerd"), Some(IconStyle::Nerd)); + assert_eq!(IconStyle::parse("UNICODE"), Some(IconStyle::Unicode)); + assert_eq!(IconStyle::parse(" Ascii "), Some(IconStyle::Ascii)); + assert_eq!(IconStyle::parse("bogus"), None); + } + + // `inbox` differs across all three themes, so it identifies which set we got. + // (Pointer identity is unreliable here: `&NERD` on a `const` can be a fresh + // promoted address each use.) + #[test] + fn iconstyle_set_maps_to_the_right_theme() { + assert_eq!(IconStyle::Nerd.set().inbox, NERD.inbox); + assert_eq!(IconStyle::Unicode.set().inbox, UNICODE.inbox); + assert_eq!(IconStyle::Ascii.set().inbox, ASCII.inbox); + } + + #[test] + fn resolve_with_follows_precedence() { + // TAOLK_ICONS wins over everything. + assert_eq!( + resolve_with("nerd", Some("ascii"), Some("1"), true).inbox, + ASCII.inbox + ); + // NERD_FONT=1 beats config. + assert_eq!( + resolve_with("unicode", None, Some("1"), true).inbox, + NERD.inbox + ); + // config value when no env. + assert_eq!(resolve_with("nerd", None, None, true).inbox, NERD.inbox); + // bad config falls through to the default. + assert_eq!(resolve_with("bogus", None, None, true).inbox, UNICODE.inbox); + // non-TTY downgrades the default to ascii. + assert_eq!(resolve_with("", None, None, false).inbox, ASCII.inbox); + // but an explicit choice beats the non-TTY rule. + assert_eq!(resolve_with("nerd", None, None, false).inbox, NERD.inbox); + } + + #[test] + fn icon_pick_maps_to_the_set_field() { + assert_eq!(Icon::Threads.pick(&ASCII), ASCII.threads); + assert_eq!(Icon::Channels.pick(&UNICODE), UNICODE.channels); + assert_eq!(Icon::Check.pick(&NERD), NERD.check); + assert_eq!(Icon::Exit.pick(&ASCII), ASCII.exit); + } } From 7d4dab8c935d3c3621d4544a45112a449d0de429 Mon Sep 17 00:00:00 2001 From: Roy Kollen Svendsen Date: Mon, 22 Jun 2026 16:10:35 +0200 Subject: [PATCH 3/6] migrate icon call sites to the runtime theme --- src/cmd/registry.rs | 38 +++++++++---------- src/main.rs | 22 ++++++----- src/ui/chat_list.rs | 24 +++++++----- src/ui/hintbar.rs | 39 +++++++------------- src/ui/icons.rs | 78 +++++++++++++++++++++------------------ src/ui/input.rs | 6 +-- src/ui/mod.rs | 2 +- src/ui/overlay/help.rs | 56 +++++++++++++++++----------- src/ui/overlay/jump.rs | 12 +++--- src/ui/overlay/palette.rs | 2 +- src/ui/statusline.rs | 14 ++++--- src/ui/timeline.rs | 46 ++++++++++++++--------- src/ui/welcome.rs | 12 +++--- 13 files changed, 191 insertions(+), 160 deletions(-) diff --git a/src/cmd/registry.rs b/src/cmd/registry.rs index 595b0aa..0b88b32 100644 --- a/src/cmd/registry.rs +++ b/src/cmd/registry.rs @@ -1,12 +1,12 @@ use crate::app::{App, Focus, Overlay, View}; -use crate::ui::icons; +use crate::ui::icons::Icon; use taolk::types::BlockRef; pub type CmdResult = Result<(), String>; pub struct Command { pub name: &'static str, - pub glyph: &'static str, + pub glyph: Icon, pub summary: &'static str, pub run: fn(&mut App, &[&str]) -> CmdResult, } @@ -15,100 +15,100 @@ pub const COMMANDS: &[Command] = &[ // Compose Command { name: "thread", - glyph: icons::THREADS, + glyph: Icon::Threads, summary: "Start a new thread with a contact", run: run_thread, }, Command { name: "message", - glyph: icons::OUTBOX, + glyph: Icon::Outbox, summary: "Send a one-off message (not a thread)", run: run_message, }, Command { name: "group", - glyph: icons::GROUPS, + glyph: Icon::Groups, summary: "Create a new group conversation", run: run_group, }, // Navigate Command { name: "search", - glyph: icons::MAGNIFY, + glyph: Icon::Magnify, summary: "Search messages in the current view", run: run_search, }, Command { name: "channels", - glyph: icons::CHANNELS, + glyph: Icon::Channels, summary: "Browse the channel directory", run: run_channels, }, Command { name: "inbox", - glyph: icons::INBOX, + glyph: Icon::Inbox, summary: "Jump to the inbox view", run: run_inbox, }, Command { name: "outbox", - glyph: icons::OUTBOX, + glyph: Icon::Outbox, summary: "Jump to the sent view", run: run_outbox, }, // View Command { name: "sidebar", - glyph: icons::MENU, + glyph: Icon::Menu, summary: "Toggle the sidebar", run: run_sidebar, }, Command { name: "help", - glyph: icons::HELP, + glyph: Icon::Help, summary: "Show the help overlay", run: run_help, }, // System Command { name: "get", - glyph: icons::BLOCK, + glyph: Icon::Block, summary: "Get remark(s) at block:index positions", run: run_fetch, }, Command { name: "refresh", - glyph: icons::REFRESH, + glyph: Icon::Refresh, summary: "Reload and fill message gaps", run: run_refresh, }, Command { name: "copy", - glyph: icons::COPY, + glyph: Icon::Copy, summary: "Copy a sender's SS58 address", run: run_copy, }, Command { name: "unlock", - glyph: icons::LOCK_OPEN, + glyph: Icon::LockOpen, summary: "Unlock all locked outbound messages", run: run_unlock, }, Command { name: "lock", - glyph: icons::ENCRYPTED, + glyph: Icon::Encrypted, summary: "Lock the session", run: run_lock, }, Command { name: "wallet", - glyph: icons::SWAP, + glyph: Icon::Swap, summary: "Switch to a different wallet", run: run_wallet, }, Command { name: "quit", - glyph: icons::EXIT, + glyph: Icon::Exit, summary: "Exit taolk", run: run_quit, }, @@ -253,7 +253,7 @@ mod tests { for c in COMMANDS { assert!(!c.name.is_empty()); assert!(!c.summary.is_empty()); - assert!(!c.glyph.is_empty()); + assert!(!c.glyph.glyph().is_empty()); } } } diff --git a/src/main.rs b/src/main.rs index 7e6991c..d6a4f71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -320,7 +320,7 @@ fn run_lock_screen( let mut spans: Vec> = Vec::new(); if win_start > 0 { - spans.push(Span::styled(ui::icons::CHEVRON_LEFT, dim_style)); + spans.push(Span::styled(ui::icons::icons().chevron_left, dim_style)); spans.push(Span::raw(" ")); } else { spans.push(Span::raw(" ")); @@ -337,7 +337,7 @@ fn run_lock_screen( } if win_end < wallets.len() { spans.push(Span::raw(" ")); - spans.push(Span::styled(ui::icons::CHEVRON_RIGHT, dim_style)); + spans.push(Span::styled(ui::icons::icons().chevron_right, dim_style)); } else { spans.push(Span::raw(" ")); } @@ -345,7 +345,7 @@ fn run_lock_screen( lines.push(centered_spans(spans, w)); } else { lines.push(centered_line( - &format!("{} Wallet: {}", ui::icons::WALLET, current_wallet), + &format!("{} Wallet: {}", ui::icons::icons().wallet, current_wallet), w, active_style, )); @@ -353,7 +353,7 @@ fn run_lock_screen( lines.push(Line::raw("")); - let prompt = format!("{} Password: ", ui::icons::KEY); + let prompt = format!("{} Password: ", ui::icons::icons().key); let prompt_cols = prompt.chars().count(); let prompt_style = if inserting { prompt_active_style @@ -376,13 +376,17 @@ fn run_lock_screen( } let hints = if inserting { - "Enter unlock \u{00B7} Esc back" + "Enter unlock \u{00B7} Esc back".to_string() } else if show_carousel { - "\u{F004D}/\u{F0054} select \u{00B7} i unlock \u{00B7} q quit" + format!( + "{}/{} select \u{00B7} i unlock \u{00B7} q quit", + ui::icons::icons().arrow_left, + ui::icons::icons().arrow_right + ) } else { - "i unlock \u{00B7} q quit" + "i unlock \u{00B7} q quit".to_string() }; - lines.push(centered_line(hints, w, dim_style)); + lines.push(centered_line(&hints, w, dim_style)); frame.render_widget(Paragraph::new(lines), area); @@ -724,7 +728,7 @@ fn prompt_password_modal( let inner = block.inner(rect); frame.render_widget(block, rect); - let prompt_text = format!("{} Password: ", ui::icons::KEY); + let prompt_text = format!("{} Password: ", ui::icons::icons().key); let prompt_cols = u16::try_from(prompt_text.chars().count()).unwrap_or(u16::MAX); let mut lines: Vec = Vec::new(); lines.push(Line::raw("")); diff --git a/src/ui/chat_list.rs b/src/ui/chat_list.rs index f6f8e8a..76cc193 100644 --- a/src/ui/chat_list.rs +++ b/src/ui/chat_list.rs @@ -26,7 +26,10 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { let inbox_style = item_style(inbox_selected); items.push(ListItem::new(Line::from(vec![ Span::styled(indicator(inbox_selected), inbox_style), - Span::styled(format!("{} Inbox", super::icons::INBOX), inbox_style), + Span::styled( + format!("{} Inbox", super::icons::icons().inbox), + inbox_style, + ), Span::styled(format!(" ({})", app.session.inbox.len()), dim_style), ]))); @@ -34,7 +37,10 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { let outbox_style = item_style(outbox_selected); items.push(ListItem::new(Line::from(vec![ Span::styled(indicator(outbox_selected), outbox_style), - Span::styled(format!("{} Sent", super::icons::OUTBOX), outbox_style), + Span::styled( + format!("{} Sent", super::icons::icons().outbox), + outbox_style, + ), Span::styled(format!(" ({})", app.session.outbox.len()), dim_style), ]))); @@ -42,7 +48,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { items.push(ListItem::new(Line::raw(""))); items.push(ListItem::new(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled(format!("{} ", super::icons::THREADS), dim_style), + Span::styled(format!("{} ", super::icons::icons().threads), dim_style), Span::styled("Threads", dim_style), ]))); @@ -113,7 +119,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { } if !thread.draft.is_empty() { spans.push(Span::styled( - format!(" {}", super::icons::DRAFT), + format!(" {}", super::icons::icons().draft), accent_style, )); } @@ -161,7 +167,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { } if !thread.draft.is_empty() { spans.push(Span::styled( - format!(" {}", super::icons::DRAFT), + format!(" {}", super::icons::icons().draft), accent_style, )); } @@ -177,7 +183,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { let dir_style = item_style(dir_selected); items.push(ListItem::new(Line::from(vec![ Span::styled(indicator(dir_selected), dir_style), - Span::styled(format!("{} ", super::icons::CHANNELS), dim_style), + Span::styled(format!("{} ", super::icons::icons().channels), dim_style), Span::styled("Channels", dir_style), Span::styled( format!(" ({})", app.session.known_channels.len()), @@ -230,7 +236,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { } if !channel.draft.is_empty() { spans.push(Span::styled( - format!(" {}", super::icons::DRAFT), + format!(" {}", super::icons::icons().draft), accent_style, )); } @@ -242,7 +248,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { items.push(ListItem::new(Line::raw(""))); items.push(ListItem::new(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled(format!("{} ", super::icons::GROUPS), dim_style), + Span::styled(format!("{} ", super::icons::icons().groups), dim_style), Span::styled("Groups", dim_style), ]))); @@ -300,7 +306,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { } if !group.draft.is_empty() { spans.push(Span::styled( - format!(" {}", super::icons::DRAFT), + format!(" {}", super::icons::icons().draft), accent_style, )); } diff --git a/src/ui/hintbar.rs b/src/ui/hintbar.rs index 94a2f01..4bc2556 100644 --- a/src/ui/hintbar.rs +++ b/src/ui/hintbar.rs @@ -1,4 +1,5 @@ use crate::app::{App, Focus, Overlay, View}; +use crate::ui::icons; use crate::ui::palette; use ratatui::style::Style; use ratatui::text::{Line, Span}; @@ -11,6 +12,12 @@ pub fn hints(app: &App) -> Line<'static> { let mut spans: Vec> = Vec::with_capacity(pairs.len() * 2 + 1); spans.push(Span::raw(" ")); for (k, d) in pairs { + // `{updn}` is a placeholder for the up/down nav arrows in the active theme. + let k = if *k == "{updn}" { + format!("{}{}", icons::icons().arrow_up, icons::icons().arrow_down) + } else { + (*k).to_string() + }; spans.push(Span::styled(format!("{k} "), key)); spans.push(Span::styled(format!("{d} "), desc)); } @@ -21,46 +28,26 @@ fn pairs_for(app: &App) -> &'static [(&'static str, &'static str)] { match app.overlay { Some(Overlay::Help) => &[("j/k", "scroll"), ("any", "close")], Some(Overlay::Confirm) => &[("Enter", "confirm"), ("Esc", "back")], - Some(Overlay::Compose) => &[ - ("\u{F005D}\u{F0045}", "nav"), - ("Enter", "select"), - ("Esc", "cancel"), - ], + Some(Overlay::Compose) => &[("{updn}", "nav"), ("Enter", "select"), ("Esc", "cancel")], Some(Overlay::Message) => { if app.msg_recipient.is_some() { &[("p", "public"), ("e", "encrypted"), ("Esc", "cancel")] } else { - &[ - ("\u{F005D}\u{F0045}", "nav"), - ("Enter", "select"), - ("Esc", "cancel"), - ] + &[("{updn}", "nav"), ("Enter", "select"), ("Esc", "cancel")] } } Some(Overlay::CreateChannel) => &[("Enter", "next"), ("Esc", "cancel")], Some(Overlay::CreateChannelDesc) => &[("Enter", "create"), ("Esc", "back")], Some(Overlay::CreateGroupMembers) => &[ - ("\u{F005D}\u{F0045}", "nav"), + ("{updn}", "nav"), ("Enter", "toggle"), ("Tab", "done"), ("Esc", "cancel"), ], Some(Overlay::Search) => &[("Enter", "apply"), ("Esc", "clear")], - Some(Overlay::SenderPicker) => &[ - ("\u{F005D}\u{F0045}", "nav"), - ("Enter", "copy"), - ("Esc", "cancel"), - ], - Some(Overlay::CommandPalette) => &[ - ("\u{F005D}\u{F0045}", "nav"), - ("Enter", "run"), - ("Esc", "cancel"), - ], - Some(Overlay::FuzzyJump) => &[ - ("\u{F005D}\u{F0045}", "nav"), - ("Enter", "jump"), - ("Esc", "cancel"), - ], + Some(Overlay::SenderPicker) => &[("{updn}", "nav"), ("Enter", "copy"), ("Esc", "cancel")], + Some(Overlay::CommandPalette) => &[("{updn}", "nav"), ("Enter", "run"), ("Esc", "cancel")], + Some(Overlay::FuzzyJump) => &[("{updn}", "nav"), ("Enter", "jump"), ("Esc", "cancel")], None => match app.focus { Focus::Composer => &[ ("Enter", "send"), diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 6c4306e..919ff2c 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -11,44 +11,10 @@ //! Fonts v3 (`nf-md-*`, U+F0001+); the recommended fallback font is //! "Symbols Nerd Font Mono" (the Mono variant keeps glyphs single-cell). -// The runtime API below is consumed by the migration and startup commits; until -// then these items are intentionally unused. Removed once startup wires it in. +// `resolve`/`init` are consumed by the startup commit; until then they are +// intentionally unused. Removed once startup wires them in. #![allow(dead_code)] -// Legacy flat constants — still referenced by call sites until the migration -// commit replaces them with `icons().field`. Kept here so all PUA literals live -// in one module. -pub const INBOX: &str = "\u{F02FB}"; -pub const OUTBOX: &str = "\u{F048A}"; -pub const THREADS: &str = "\u{F0369}"; -pub const CHANNELS: &str = "\u{F0423}"; -pub const GROUPS: &str = "\u{F0849}"; -pub const PUBLIC: &str = "\u{F0FC6}"; -pub const ENCRYPTED: &str = "\u{F033E}"; -pub const CREATOR: &str = "\u{F01A5}"; -pub const DRAFT: &str = "\u{F03EB}"; -pub const CHECK: &str = "\u{F012C}"; -pub const ERROR: &str = "\u{F0028}"; -pub const LOCK_CLOCK: &str = "\u{F097F}"; -pub const HISTORY: &str = "\u{F02DA}"; -pub const SYNC: &str = "\u{F04E6}"; -pub const BLOCK: &str = "\u{F01A7}"; -pub const ACCOUNT: &str = "\u{F0B55}"; -pub const WALLET: &str = "\u{F0BDD}"; -pub const KEY: &str = "\u{F030B}"; -pub const HELP: &str = "\u{F0625}"; -pub const EXIT: &str = "\u{F0206}"; -pub const MAGNIFY: &str = "\u{F0349}"; -pub const MENU: &str = "\u{F035C}"; -pub const KEYBOARD: &str = "\u{F097B}"; -pub const COG: &str = "\u{F0493}"; -pub const REFRESH: &str = "\u{F0450}"; -pub const SWAP: &str = "\u{F04E1}"; -pub const COPY: &str = "\u{F018F}"; -pub const LOCK_OPEN: &str = "\u{F0340}"; -pub const CHEVRON_LEFT: &str = "\u{F0141}"; -pub const CHEVRON_RIGHT: &str = "\u{F0142}"; - /// Nerd Font Material Design glyphs (v3 nf-md-*). Needs a patched font. pub const NERD: IconSet = IconSet { inbox: "\u{F02FB}", @@ -480,4 +446,44 @@ mod tests { assert_eq!(Icon::Check.pick(&NERD), NERD.check); assert_eq!(Icon::Exit.pick(&ASCII), ASCII.exit); } + + // All Nerd Font PUA glyphs (U+F0000..U+FFFFD, written as \u{F0..}/\u{F1..} or + // raw) must live only in this module. This keeps the codebase font-agnostic: + // a contributor can't scatter a hard nerd glyph into a widget. + #[test] + fn no_nerd_pua_outside_registry() { + use std::path::Path; + + fn scan(dir: &Path, offenders: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + scan(&path, offenders); + } else if path.extension().is_some_and(|e| e == "rs") + && !path.ends_with("ui/icons.rs") + { + let text = std::fs::read_to_string(&path).unwrap(); + for (i, line) in text.lines().enumerate() { + let escaped = ["\\u{F0", "\\u{f0", "\\u{F1", "\\u{f1"] + .iter() + .any(|p| line.contains(p)); + let raw = line + .chars() + .any(|c| ('\u{F0000}'..='\u{FFFFD}').contains(&c)); + if escaped || raw { + offenders.push(format!("{}:{}", path.display(), i + 1)); + } + } + } + } + } + + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut offenders = Vec::new(); + scan(&src, &mut offenders); + assert!( + offenders.is_empty(), + "Nerd PUA glyphs must live only in ui/icons.rs; found: {offenders:?}" + ); + } } diff --git a/src/ui/input.rs b/src/ui/input.rs index 3ceefe9..09f927a 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -169,7 +169,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { render_single_input( frame, app, - &format!("{} ", icons::MAGNIFY), + &format!("{} ", icons::icons().magnify), "Search messages...", None, area, @@ -211,12 +211,12 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { Span::raw(" "), Span::styled("[p] ", Style::default().fg(palette::ACCENT)), Span::styled( - format!("{} public ", icons::PUBLIC), + format!("{} public ", icons::icons().public), Style::default().fg(ratatui::style::Color::Reset), ), Span::styled("[e] ", Style::default().fg(palette::ACCENT)), Span::styled( - format!("{} encrypted ", icons::ENCRYPTED), + format!("{} encrypted ", icons::icons().encrypted), Style::default().fg(ratatui::style::Color::Reset), ), Span::styled( diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 19375a9..16302c2 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -99,7 +99,7 @@ fn render_main_panel(frame: &mut Frame, app: &mut App, area: Rect) { fn render_too_small(frame: &mut Frame, area: Rect) { let msg = format!( "{} taolk requires at least {MIN_WIDTH}x{MIN_HEIGHT} — current {}x{}", - icons::ERROR, + icons::icons().error, area.width, area.height, ); diff --git a/src/ui/overlay/help.rs b/src/ui/overlay/help.rs index ce32d6f..d694aae 100644 --- a/src/ui/overlay/help.rs +++ b/src/ui/overlay/help.rs @@ -6,28 +6,36 @@ use ratatui::widgets::Paragraph; use unicode_width::UnicodeWidthStr; use crate::app::App; -use crate::ui::icons; +use crate::ui::icons::{self, Icon}; use crate::ui::palette; struct Card { title: &'static str, - glyph: &'static str, + glyph: Icon, entries: &'static [(&'static str, &'static str)], } +/// Expand arrow placeholders in a key hint into the active theme's glyphs. +fn expand(s: &str) -> String { + s.replace("{up}", icons::icons().arrow_up) + .replace("{dn}", icons::icons().arrow_down) + .replace("{lt}", icons::icons().arrow_left) + .replace("{rt}", icons::icons().arrow_right) +} + const CARDS: &[Card] = &[ Card { title: "Sidebar", - glyph: icons::MENU, + glyph: Icon::Menu, entries: &[ - ("\u{F005D} / \u{F0045}", "Previous / next conversation"), + ("{up} / {dn}", "Previous / next conversation"), ("Tab / S-Tab", "Previous / next conversation"), ("Space", "Toggle sidebar"), ], }, Card { title: "Page content", - glyph: icons::KEYBOARD, + glyph: Icon::Keyboard, entries: &[ ("j / k", "Down / up one line"), ("C-d / C-u", "Half-page down / up"), @@ -38,7 +46,7 @@ const CARDS: &[Card] = &[ }, Card { title: "Actions", - glyph: icons::COG, + glyph: Icon::Cog, entries: &[ ("i", "Compose or reply in current"), ("n", "New thread"), @@ -58,7 +66,7 @@ const CARDS: &[Card] = &[ }, Card { title: "Channel directory", - glyph: icons::CHANNELS, + glyph: Icon::Channels, entries: &[ ("j / k", "Move channel cursor"), ("digits / :", "Type a channel ref"), @@ -69,45 +77,45 @@ const CARDS: &[Card] = &[ }, Card { title: "Insert", - glyph: icons::DRAFT, + glyph: Icon::Draft, entries: &[ ("Enter", "Send (preview fee)"), ("S-Enter", "Insert newline"), ("Esc", "Save draft and exit"), - ("C-\u{F004D} / C-\u{F0054}", "Jump by word"), + ("C-{lt} / C-{rt}", "Jump by word"), ("Backspace", "Delete left"), ], }, Card { title: "Confirm", - glyph: icons::CHECK, + glyph: Icon::Check, entries: &[("Enter", "Submit transaction"), ("Esc", "Back to edit")], }, Card { title: "Compose / Message", - glyph: icons::OUTBOX, + glyph: Icon::Outbox, entries: &[ ("type", "Filter or paste SS58"), - ("\u{F005D} / \u{F0045}", "Pick contact"), + ("{up} / {dn}", "Pick contact"), ("Enter", "Confirm and compose"), ("Esc", "Cancel"), ], }, Card { title: "Sender picker", - glyph: icons::ACCOUNT, + glyph: Icon::Account, entries: &[ - ("\u{F005D} / \u{F0045}", "Pick sender"), + ("{up} / {dn}", "Pick sender"), ("Enter", "Copy SS58 to clipboard"), ("Esc", "Cancel"), ], }, Card { title: "Group members", - glyph: icons::GROUPS, + glyph: Icon::Groups, entries: &[ ("type", "Filter or paste SS58"), - ("\u{F005D} / \u{F0045}", "Pick contact"), + ("{up} / {dn}", "Pick contact"), ("Enter", "Add or remove"), ("Tab", "Done, create group"), ("Esc", "Cancel"), @@ -220,10 +228,13 @@ fn compute_card_width() -> usize { let mut max_entry = 0usize; let mut max_header = 0usize; for card in CARDS { - let header = format!("{} {}", card.glyph, card.title); + let header = format!("{} {}", card.glyph.glyph(), card.title); max_header = max_header.max(UnicodeWidthStr::width(header.as_str())); for (key, desc) in card.entries { - let w = UnicodeWidthStr::width(*key) + UnicodeWidthStr::width(*desc) + CARD_ENTRY_GAP; + let key = expand(key); + let w = UnicodeWidthStr::width(key.as_str()) + + UnicodeWidthStr::width(*desc) + + CARD_ENTRY_GAP; max_entry = max_entry.max(w); } } @@ -241,20 +252,21 @@ fn render_card(card: &Card, width: usize) -> Vec> { let key_style = Style::default().fg(palette::ACCENT); let mut lines = Vec::with_capacity(card.entries.len() + 3); - let header = format!("{} {}", card.glyph, card.title); + let header = format!("{} {}", card.glyph.glyph(), card.title); let header_w = UnicodeWidthStr::width(header.as_str()); let left = (width.saturating_sub(header_w)) / 2; let right = width.saturating_sub(header_w).saturating_sub(left); lines.push(Line::from(vec![ Span::raw(" ".repeat(left)), - Span::styled(format!("{} ", card.glyph), glyph_style), + Span::styled(format!("{} ", card.glyph.glyph()), glyph_style), Span::styled(card.title.to_string(), title_style), Span::raw(" ".repeat(right)), ])); lines.push(blank_line(width)); for (key, desc) in card.entries { - let key_w = UnicodeWidthStr::width(*key); + let key = expand(key); + let key_w = UnicodeWidthStr::width(key.as_str()); let desc_w = UnicodeWidthStr::width(*desc); let used = CARD_H_PAD * 2 + key_w + desc_w; let gap = width.saturating_sub(used).max(1); @@ -262,7 +274,7 @@ fn render_card(card: &Card, width: usize) -> Vec> { Span::raw(" ".repeat(CARD_H_PAD)), Span::styled((*desc).to_string(), desc_style), Span::raw(" ".repeat(gap)), - Span::styled((*key).to_string(), key_style), + Span::styled(key, key_style), Span::raw(" ".repeat(CARD_H_PAD)), ])); } diff --git a/src/ui/overlay/jump.rs b/src/ui/overlay/jump.rs index 8a4350a..c9d98f2 100644 --- a/src/ui/overlay/jump.rs +++ b/src/ui/overlay/jump.rs @@ -43,23 +43,23 @@ impl JumpState { let mut targets: Vec = Vec::new(); targets.push(JumpTarget { label: "Inbox".into(), - glyph: icons::INBOX, + glyph: icons::icons().inbox, view: View::Inbox, }); targets.push(JumpTarget { label: "Sent".into(), - glyph: icons::OUTBOX, + glyph: icons::icons().outbox, view: View::Outbox, }); targets.push(JumpTarget { label: "Channels".into(), - glyph: icons::CHANNELS, + glyph: icons::icons().channels, view: View::ChannelDir, }); for (i, t) in app.session.threads.iter().enumerate() { targets.push(JumpTarget { label: t.peer_ss58.clone(), - glyph: icons::THREADS, + glyph: icons::icons().threads, view: View::Thread(i), }); } @@ -69,7 +69,7 @@ impl JumpState { } targets.push(JumpTarget { label: format!("#{}", c.name), - glyph: icons::CHANNELS, + glyph: icons::icons().channels, view: View::Channel(i), }); } @@ -77,7 +77,7 @@ impl JumpState { let members = g.members.len(); targets.push(JumpTarget { label: format!("group ({members})"), - glyph: icons::GROUPS, + glyph: icons::icons().groups, view: View::Group(i), }); } diff --git a/src/ui/overlay/palette.rs b/src/ui/overlay/palette.rs index b364fd3..adcafab 100644 --- a/src/ui/overlay/palette.rs +++ b/src/ui/overlay/palette.rs @@ -160,7 +160,7 @@ pub fn render(frame: &mut Frame, app: &App) { if selected { selected_style } else { name_style }, ), Span::styled( - format!("{} ", cmd.glyph), + format!("{} ", cmd.glyph.glyph()), if selected { selected_style } else { name_style }, ), Span::styled( diff --git a/src/ui/statusline.rs b/src/ui/statusline.rs index 5b3cef0..4a9ba2b 100644 --- a/src/ui/statusline.rs +++ b/src/ui/statusline.rs @@ -14,7 +14,7 @@ fn reconnect_pill(state: ConnState) -> Option> { match state { ConnState::Connected => None, ConnState::Reconnecting { in_secs } => Some(Span::styled( - format!(" {} reconnecting in {in_secs}s ", icons::SYNC), + format!(" {} reconnecting in {in_secs}s ", icons::icons().sync), Style::default() .fg(palette::ERROR) .add_modifier(Modifier::REVERSED | Modifier::BOLD), @@ -32,12 +32,12 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { )) } else if is_error { Line::from(Span::styled( - format!(" {} {status} ", icons::ERROR), + format!(" {} {status} ", icons::icons().error), Style::default().fg(palette::ERROR), )) } else { Line::from(Span::styled( - format!(" {} {status} ", icons::CHECK), + format!(" {} {status} ", icons::icons().check), palette::strong(), )) } @@ -74,7 +74,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { let block_str = format!( " {} {} ", - icons::BLOCK, + icons::icons().block, format_number(u128::from(app.session.block_number)) ); let block_fresh = app.frame.wrapping_sub(app.block_changed_at) < highlight_frames; @@ -92,7 +92,11 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { let locked_str = if app.locked_outbound.is_empty() { String::new() } else { - format!(" {} {} (U) ", icons::LOCK_CLOCK, app.locked_outbound.len()) + format!( + " {} {} (U) ", + icons::icons().lock_clock, + app.locked_outbound.len() + ) }; let right_width = u16::try_from(locked_str.chars().count()).unwrap_or(0) diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs index 93f9dc9..5a81cee 100644 --- a/src/ui/timeline.rs +++ b/src/ui/timeline.rs @@ -234,7 +234,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { frame, app, &app.session.inbox, - &format!("{} Inbox", super::icons::INBOX), + &format!("{} Inbox", super::icons::icons().inbox), "From", None, area, @@ -243,7 +243,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { frame, app, &app.session.outbox, - &format!("{} Sent", super::icons::OUTBOX), + &format!("{} Sent", super::icons::icons().outbox), "To", pending_text(app, View::Outbox), area, @@ -287,12 +287,14 @@ fn render_standalone( .to_string(); let (type_icon, type_label, badge_bg) = match samp::ContentType::from_byte(msg.content_type) { - Ok(samp::ContentType::Public) => (super::icons::PUBLIC, "public", Color::Cyan), + Ok(samp::ContentType::Public) => { + (super::icons::icons().public, "public", Color::Cyan) + } Ok(samp::ContentType::Encrypted) => { - (super::icons::ENCRYPTED, "encrypted", Color::Magenta) + (super::icons::icons().encrypted, "encrypted", Color::Magenta) } _ => ( - super::icons::BLOCK, + super::icons::icons().block, &*format!("0x{:02x}", msg.content_type), Color::DarkGray, ), @@ -348,8 +350,10 @@ fn render_standalone( if let Some(text) = pending { let spinner = app.spinner_16(); let type_badge: Option<(&str, &str)> = match app.pending_msg_type { - Some(samp::ContentType::Public) => Some((super::icons::PUBLIC, "public")), - Some(samp::ContentType::Encrypted) => Some((super::icons::ENCRYPTED, "encrypted")), + Some(samp::ContentType::Public) => Some((super::icons::icons().public, "public")), + Some(samp::ContentType::Encrypted) => { + Some((super::icons::icons().encrypted, "encrypted")) + } _ => None, }; let recipient_label = app @@ -398,7 +402,7 @@ fn id_block_str(id_str: &str) -> String { if id_str.is_empty() { String::new() } else { - format!("{} {}", super::icons::BLOCK, id_str) + format!("{} {}", super::icons::icons().block, id_str) } } @@ -597,7 +601,7 @@ fn group_member_title(group: &crate::conversation::Group, app: &App, title_max: taolk::util::ss58_short(pk) }; let label = if *pk == group.creator_pubkey { - format!("{label}{}", super::icons::CREATOR) + format!("{label}{}", super::icons::icons().creator) } else { label }; @@ -626,7 +630,7 @@ fn render_channel_dir(frame: &mut Frame, app: &App, area: Rect) { let count = app.session.known_channels.len(); let mut lines: Vec = vec![ header_line( - super::icons::CHANNELS, + super::icons::icons().channels, "Channels", &format!("{count} channels"), usize::from(area.width), @@ -645,10 +649,14 @@ fn render_channel_dir(frame: &mut Frame, app: &App, area: Rect) { let subscribed = app.session.is_subscribed(&info.channel_ref); let indicator = if selected { "> " } else { " " }; - let check = if subscribed { " \u{F012C}" } else { "" }; + let check = if subscribed { + format!(" {}", super::icons::icons().check) + } else { + String::new() + }; let id_str = format!( " {} {}:{}", - super::icons::BLOCK, + super::icons::icons().block, info.channel_ref.block().get(), info.channel_ref.index().get() ); @@ -703,7 +711,7 @@ fn render_contact_picker(frame: &mut Frame, app: &App, area: Rect) { let mut lines: Vec = vec![ header_line( - super::icons::ACCOUNT, + super::icons::icons().account, "Contacts", &format!("{total} contacts"), w, @@ -770,7 +778,7 @@ fn render_sender_picker(frame: &mut Frame, app: &App, area: Rect) { let mut lines: Vec = vec![ header_line( - super::icons::ACCOUNT, + super::icons::icons().account, "Copy SS58", &format!("{total} senders in view"), w, @@ -829,7 +837,7 @@ fn render_group_member_picker(frame: &mut Frame, app: &App, area: Rect) { let mut lines: Vec = vec![ header_line( - super::icons::GROUPS, + super::icons::icons().groups, "Select Members", &format!("{others_selected} selected, {total} contacts"), w, @@ -855,7 +863,11 @@ fn render_group_member_picker(frame: &mut Frame, app: &App, area: Rect) { let addr_max = w.saturating_sub(6); let indicator = if cursor { "> " } else { " " }; - let check = if is_member { "\u{F012C} " } else { " " }; + let check = if is_member { + format!("{} ", super::icons::icons().check) + } else { + " ".to_string() + }; let addr_color = if cursor { Color::Reset } else if is_member { @@ -941,7 +953,7 @@ fn render_messages( }; let gap_str = format!( " {} Earlier messages may be missing \u{00B7} press r to load", - super::icons::HISTORY + super::icons::icons().history ); let gap_text = truncate(&gap_str, width); lines.push(Line::from(vec![Span::styled( diff --git a/src/ui/welcome.rs b/src/ui/welcome.rs index 187734c..b22e719 100644 --- a/src/ui/welcome.rs +++ b/src/ui/welcome.rs @@ -13,12 +13,12 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { let text = Style::default(); let shortcuts: &[(&str, &str, &str)] = &[ - ("n", icons::THREADS, "new thread"), - ("m", icons::OUTBOX, "standalone message"), - ("c", icons::CHANNELS, "channels"), - ("g", icons::GROUPS, "create group"), - ("?", icons::HELP, "help"), - ("q", icons::EXIT, "quit"), + ("n", icons::icons().threads, "new thread"), + ("m", icons::icons().outbox, "standalone message"), + ("c", icons::icons().channels, "channels"), + ("g", icons::icons().groups, "create group"), + ("?", icons::icons().help, "help"), + ("q", icons::icons().exit, "quit"), ]; let content_h = u16::try_from(shortcuts.len()).unwrap_or(u16::MAX); From cb694285d5e3f52e2618f6eb838ece1c2766c643 Mon Sep 17 00:00:00 2001 From: Roy Kollen Svendsen Date: Mon, 22 Jun 2026 16:18:38 +0200 Subject: [PATCH 4/6] add ui.icons config key --- src/config.rs | 20 ++++++++++++++++++++ tests/config.rs | 15 ++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index e3dd7a8..ff18951 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,6 +38,7 @@ pub struct Ui { pub mouse: bool, pub timestamp_format: String, pub date_format: String, + pub icons: String, } #[derive(Clone, Serialize, Deserialize)] @@ -75,6 +76,7 @@ impl Default for Ui { mouse: true, timestamp_format: "%H:%M".into(), date_format: "%Y-%m-%d %H:%M".into(), + icons: "unicode".into(), } } } @@ -178,6 +180,13 @@ pub const KEYS: &[KeyDef] = &[ description: "Full date format (chrono strftime)", default_display: "%Y-%m-%d %H:%M", }, + KeyDef { + key: "ui.icons", + section: "ui", + field: "icons", + description: "Icon style (nerd, unicode, ascii)", + default_display: "unicode", + }, KeyDef { key: "notifications.enabled", section: "notifications", @@ -250,6 +259,7 @@ pub fn get_value(config: &Config, key: &str) -> String { "ui.mouse" => config.ui.mouse.to_string(), "ui.timestamp_format" => config.ui.timestamp_format.clone(), "ui.date_format" => config.ui.date_format.clone(), + "ui.icons" => config.ui.icons.clone(), "notifications.enabled" => config.notifications.enabled.to_string(), "notifications.volume" => config.notifications.volume.to_string(), "notifications.dm" => config.notifications.dm.to_string(), @@ -287,6 +297,16 @@ pub fn set_key(key: &str, raw: &[String]) -> Result { "wallet.default" | "network.node" | "ui.timestamp_format" | "ui.date_format" => { toml::Value::String(raw.join(" ")) } + "ui.icons" => { + let v = raw.first().map(|s| s.as_str()).unwrap_or(""); + if !matches!(v, "nerd" | "unicode" | "ascii") { + return Err(ConfigError::InvalidValue { + expected: "nerd, unicode, or ascii".into(), + got: v.into(), + }); + } + toml::Value::String(v.to_string()) + } "network.mirrors" => { let items: Vec = raw .iter() diff --git a/tests/config.rs b/tests/config.rs index 193926c..1b19292 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -83,7 +83,20 @@ fn suggest_key_no_match() { #[test] fn key_count() { - assert_eq!(config::KEYS.len(), 14); + assert_eq!(config::KEYS.len(), 15); +} + +#[test] +fn ui_icons_defaults_to_unicode() { + let cfg = config::Config::default(); + assert_eq!(cfg.ui.icons, "unicode"); + assert_eq!(config::get_value(&cfg, "ui.icons"), "unicode"); +} + +#[test] +fn set_key_icons_invalid_value() { + let result = config::set_key("ui.icons", &["bogus".into()]); + assert!(result.is_err()); } // --- set_key validation errors (no filesystem write needed) --- From 717f9f022d500b0266946329badcb2e2e7121d4f Mon Sep 17 00:00:00 2001 From: Roy Kollen Svendsen Date: Mon, 22 Jun 2026 16:22:46 +0200 Subject: [PATCH 5/6] select icon theme at startup --- src/main.rs | 19 +++++++++++++++++++ src/ui/icons.rs | 4 ---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index d6a4f71..2a1b9c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -123,6 +123,7 @@ enum Commands { fn main() -> Result<(), Box> { let cli = Cli::parse(); let cfg = config::load(); + ui::icons::init(ui::icons::resolve(&cfg.ui.icons)); match cli.command { Some(Commands::Wallet { action }) => cmd::wallet::run(action), @@ -2685,6 +2686,24 @@ mod tests { assert!(parse_channel_ref("100:99999").is_err()); } + #[test] + fn e2e_default_theme_is_unicode_no_pua() { + // Tests never call icons::init, so the active theme is the safe default + // (unicode). The screen must render real glyphs and never a nerd PUA glyph. + let mut h = TuiHarness::new(); + let screen = h.screen(); + assert!( + screen.contains(ui::icons::UNICODE.inbox), + "default render should use the unicode inbox glyph" + ); + assert!( + !screen + .chars() + .any(|c| (0xF0000..=0xFFFFD).contains(&(c as u32))), + "no nerd PUA glyph should appear under the default theme" + ); + } + #[test] fn e2e_help_overlay_renders_and_closes() { let mut h = TuiHarness::new(); diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 919ff2c..7fab788 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -11,10 +11,6 @@ //! Fonts v3 (`nf-md-*`, U+F0001+); the recommended fallback font is //! "Symbols Nerd Font Mono" (the Mono variant keeps glyphs single-cell). -// `resolve`/`init` are consumed by the startup commit; until then they are -// intentionally unused. Removed once startup wires them in. -#![allow(dead_code)] - /// Nerd Font Material Design glyphs (v3 nf-md-*). Needs a patched font. pub const NERD: IconSet = IconSet { inbox: "\u{F02FB}", From d0dba3627537c68c85d93723435f50fc35681553 Mon Sep 17 00:00:00 2001 From: Roy Kollen Svendsen Date: Mon, 22 Jun 2026 16:25:16 +0200 Subject: [PATCH 6/6] document icon themes --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 314bc2e..6a04e12 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,20 @@ async fn main() -> taolk::error::Result<()> { | `ui.mouse` | `true` | Mouse support | | `ui.timestamp_format` | `%H:%M` | Message time format | | `ui.date_format` | `%Y-%m-%d %H:%M` | Full date format | +| `ui.icons` | `unicode` | Icon style: `nerd`, `unicode`, or `ascii` | + +### Icons + +taolk draws its UI icons in one of three styles, set with `ui.icons` or the `TAOLK_ICONS` +environment variable: + +- `unicode` (default) — plain single-cell symbols that render on any terminal, no special font. +- `nerd` — [Nerd Font](https://www.nerdfonts.com/) glyphs (nicer, but needs a patched font such as + a Nerd Font or `Symbols Nerd Font Mono`). Also enabled by `NERD_FONT=1`. +- `ascii` — pure ASCII, for the most limited terminals. + +Precedence: `TAOLK_ICONS` > `NERD_FONT=1` > `ui.icons` > default. When output isn't a terminal +(piped or redirected), icons fall back to `ascii`. ## Mirrors