use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GlyphSet {
#[default]
Unicode,
Ascii,
Nerd,
}
impl GlyphSet {
pub const ALL: [GlyphSet; 3] = [GlyphSet::Unicode, GlyphSet::Ascii, GlyphSet::Nerd];
pub const fn label(self) -> &'static str {
match self {
GlyphSet::Unicode => "Unicode",
GlyphSet::Ascii => "ASCII",
GlyphSet::Nerd => "Nerd Font",
}
}
pub fn sample(self) -> &'static str {
match self {
GlyphSet::Unicode => "✔ ✖ ⚠ ● ▸ ▾ ╭─╮ │ └─┘ ⠋ ⣾ ↑ ↓ ⚙ 🔧",
GlyphSet::Ascii => "[ok] [x] [!] [*] > + - +-+ | +-+ | \\ -",
GlyphSet::Nerd => {
"\u{f00c} \u{f00d} \u{f071} \u{f111} \u{f0da} \u{f0d7} \u{e0b0} \u{f126} \u{f013} \u{f077} \u{f078} \u{f0ad}"
}
}
}
pub const fn symbols(self) -> Symbols {
match self {
GlyphSet::Unicode => Symbols::unicode(),
GlyphSet::Ascii => Symbols::ascii(),
GlyphSet::Nerd => Symbols::nerd(),
}
}
}
impl fmt::Display for GlyphSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
impl FromStr for GlyphSet {
type Err = UnknownGlyphSet;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"unicode" | "uni" | "default" => Ok(GlyphSet::Unicode),
"ascii" => Ok(GlyphSet::Ascii),
"nerd" | "nerdfont" | "nerd-font" | "nerd_font" => Ok(GlyphSet::Nerd),
other => Err(UnknownGlyphSet(other.to_string())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownGlyphSet(pub String);
impl fmt::Display for UnknownGlyphSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"unknown glyph set '{}': expected one of unicode, ascii, nerd",
self.0
)
}
}
impl std::error::Error for UnknownGlyphSet {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Symbols {
pub status_success: &'static str,
pub status_error: &'static str,
pub status_warning: &'static str,
pub status_info: &'static str,
pub status_pending: &'static str,
pub status_running: &'static str,
pub status_aborted: &'static str,
pub status_done: &'static str,
pub dot_on: &'static str,
pub dot_off: &'static str,
pub cursor: &'static str,
pub nav_selected: &'static str,
pub nav_expand: &'static str,
pub nav_collapse: &'static str,
pub nav_back: &'static str,
pub arrow_right: &'static str,
pub arrow_left: &'static str,
pub arrow_up: &'static str,
pub arrow_down: &'static str,
pub tree_branch: &'static str,
pub tree_last: &'static str,
pub tree_vertical: &'static str,
pub tree_horizontal: &'static str,
pub round_tl: &'static str,
pub round_tr: &'static str,
pub round_bl: &'static str,
pub round_br: &'static str,
pub round_h: &'static str,
pub round_v: &'static str,
pub sharp_tl: &'static str,
pub sharp_tr: &'static str,
pub sharp_bl: &'static str,
pub sharp_br: &'static str,
pub sharp_h: &'static str,
pub sharp_v: &'static str,
pub sharp_cross: &'static str,
pub sharp_tee_right: &'static str,
pub sharp_tee_left: &'static str,
pub sep_block: &'static str,
pub sep_dot: &'static str,
pub sep_pipe: &'static str,
pub rule: &'static str,
pub bullet: &'static str,
pub dash: &'static str,
pub checkbox_on: &'static str,
pub checkbox_off: &'static str,
pub radio_on: &'static str,
pub radio_off: &'static str,
pub icon_folder: &'static str,
pub icon_file: &'static str,
pub icon_search: &'static str,
pub icon_git: &'static str,
pub icon_branch: &'static str,
pub icon_model: &'static str,
pub icon_cost: &'static str,
pub icon_time: &'static str,
pub icon_tokens: &'static str,
pub icon_context: &'static str,
pub icon_prompt: &'static str,
pub icon_warning: &'static str,
pub icon_todo: &'static str,
pub icon_thinking: &'static str,
pub icon_thinking_off: &'static str,
pub icon_thinking_minimal: &'static str,
pub icon_thinking_low: &'static str,
pub icon_thinking_medium: &'static str,
pub icon_thinking_high: &'static str,
pub icon_thinking_xhigh: &'static str,
pub icon_lang_rust: &'static str,
pub icon_lang_python: &'static str,
pub icon_lang_javascript: &'static str,
pub icon_lang_typescript: &'static str,
pub icon_lang_go: &'static str,
pub icon_lang_java: &'static str,
pub icon_lang_cpp: &'static str,
pub icon_lang_csharp: &'static str,
pub icon_lang_swift: &'static str,
pub icon_lang_kotlin: &'static str,
pub icon_lang_shell: &'static str,
pub icon_lang_html: &'static str,
pub icon_lang_css: &'static str,
pub icon_lang_json: &'static str,
pub icon_lang_sql: &'static str,
pub icon_lang_docker: &'static str,
pub icon_lang_default: &'static str,
pub tool_bash: &'static str,
pub tool_edit: &'static str,
pub tool_write: &'static str,
pub tool_read: &'static str,
pub tool_search: &'static str,
pub tool_task: &'static str,
pub tool_web: &'static str,
pub tool_lsp: &'static str,
pub tool_debug: &'static str,
pub tool_mcp: &'static str,
pub tool_ask: &'static str,
pub tool_generic: &'static str,
pub spinner_status: &'static [&'static str],
pub spinner_activity: &'static [&'static str],
}
impl Symbols {
pub const fn unicode() -> Self {
Self {
status_success: "✔",
status_error: "✘",
status_warning: "⚠",
status_info: "ⓘ",
status_pending: "⏳",
status_running: "⟳",
status_aborted: "⏹",
status_done: "•",
dot_on: "●",
dot_off: "○",
cursor: "❯ ",
nav_selected: "➤",
nav_expand: "▸",
nav_collapse: "▾",
nav_back: "⟵",
arrow_right: "→",
arrow_left: "←",
arrow_up: "↑",
arrow_down: "↓",
tree_branch: "├─",
tree_last: "└─",
tree_vertical: "│",
tree_horizontal: "─",
round_tl: "╭",
round_tr: "╮",
round_bl: "╰",
round_br: "╯",
round_h: "─",
round_v: "│",
sharp_tl: "┌",
sharp_tr: "┐",
sharp_bl: "└",
sharp_br: "┘",
sharp_h: "─",
sharp_v: "│",
sharp_cross: "┼",
sharp_tee_right: "├",
sharp_tee_left: "┤",
sep_block: "▌",
sep_dot: " · ",
sep_pipe: " │ ",
rule: "─",
bullet: "•",
dash: "—",
checkbox_on: "☑",
checkbox_off: "☐",
radio_on: "◉",
radio_off: "○",
icon_folder: "📁",
icon_file: "📄",
icon_search: "🔍",
icon_git: "⎇",
icon_branch: "⑂",
icon_model: "⬢",
icon_cost: "💲",
icon_time: "⏱",
icon_tokens: "🪙",
icon_context: "◫",
icon_prompt: "❯",
icon_warning: "⚠",
icon_todo: "📋",
icon_thinking: "⚙",
icon_thinking_off: "○ off",
icon_thinking_minimal: "◔ min",
icon_thinking_low: "◑ low",
icon_thinking_medium: "◒ med",
icon_thinking_high: "◕ high",
icon_thinking_xhigh: "◉ xhi",
tool_bash: "❯",
tool_edit: "✎",
tool_write: "✎",
tool_read: "📖",
tool_search: "⌕",
tool_task: "⇶",
tool_web: "🌐",
tool_lsp: "💡",
tool_debug: "🐞",
tool_mcp: "🔌",
tool_ask: "?",
tool_generic: "🔧",
icon_lang_rust: "rs",
icon_lang_python: "py",
icon_lang_javascript: "js",
icon_lang_typescript: "ts",
icon_lang_go: "go",
icon_lang_java: "java",
icon_lang_cpp: "c++",
icon_lang_csharp: "cs",
icon_lang_swift: "sw",
icon_lang_kotlin: "kt",
icon_lang_shell: "sh",
icon_lang_html: "html",
icon_lang_css: "css",
icon_lang_json: "json",
icon_lang_sql: "sql",
icon_lang_docker: "docker",
icon_lang_default: "?",
spinner_status: SPINNER_STATUS_UNICODE,
spinner_activity: SPINNER_ACTIVITY_UNICODE,
}
}
pub const fn ascii() -> Self {
Self {
status_success: "[ok]",
status_error: "[!!]",
status_warning: "[!]",
status_info: "[i]",
status_pending: "[*]",
status_running: "[~]",
status_aborted: "[-]",
status_done: "*",
dot_on: "[*]",
dot_off: "[ ]",
cursor: "> ",
nav_selected: "->",
nav_expand: "+",
nav_collapse: "-",
nav_back: "<-",
arrow_right: "->",
arrow_left: "<-",
arrow_up: "^",
arrow_down: "v",
tree_branch: "|--",
tree_last: "`--",
tree_vertical: "|",
tree_horizontal: "-",
round_tl: "+",
round_tr: "+",
round_bl: "+",
round_br: "+",
round_h: "-",
round_v: "|",
sharp_tl: "+",
sharp_tr: "+",
sharp_bl: "+",
sharp_br: "+",
sharp_h: "-",
sharp_v: "|",
sharp_cross: "+",
sharp_tee_right: "+",
sharp_tee_left: "+",
sep_block: "#",
sep_dot: " - ",
sep_pipe: " | ",
rule: "-",
bullet: "*",
dash: "-",
checkbox_on: "[x]",
checkbox_off: "[ ]",
radio_on: "(*)",
radio_off: "( )",
icon_folder: "[D]",
icon_file: "[F]",
icon_search: "[/]",
icon_git: "git:",
icon_branch: "@",
icon_model: "[M]",
icon_cost: "$",
icon_time: "t:",
icon_tokens: "tok:",
icon_context: "ctx:",
icon_prompt: ">",
icon_warning: "[!]",
icon_todo: "[T]",
icon_thinking: "[~]",
icon_thinking_off: "[off]",
icon_thinking_minimal: "[min]",
icon_thinking_low: "[low]",
icon_thinking_medium: "[med]",
icon_thinking_high: "[high]",
icon_thinking_xhigh: "[xhi]",
tool_bash: "$",
tool_edit: "~",
tool_write: "+f",
tool_read: "cat",
tool_search: "/",
tool_task: ">>>",
tool_web: "web",
tool_lsp: "lsp",
tool_debug: "dbg",
tool_mcp: "<>",
tool_ask: "[?]",
tool_generic: "[*]",
icon_lang_rust: "[rs]",
icon_lang_python: "[py]",
icon_lang_javascript: "[js]",
icon_lang_typescript: "[ts]",
icon_lang_go: "[go]",
icon_lang_java: "[java]",
icon_lang_cpp: "[c++]",
icon_lang_csharp: "[cs]",
icon_lang_swift: "[sw]",
icon_lang_kotlin: "[kt]",
icon_lang_shell: "[sh]",
icon_lang_html: "[html]",
icon_lang_css: "[css]",
icon_lang_json: "[json]",
icon_lang_sql: "[sql]",
icon_lang_docker: "[docker]",
icon_lang_default: "[?]",
spinner_status: SPINNER_STATUS_ASCII,
spinner_activity: SPINNER_ACTIVITY_ASCII,
}
}
pub const fn nerd() -> Self {
Self {
status_success: "\u{f00c}", status_error: "\u{f00d}", status_warning: "\u{f071}", status_info: "\u{f129}", status_pending: "\u{f254}", status_running: "\u{f110}", status_aborted: "\u{f04d}", status_done: "\u{f111}", dot_on: "\u{f111}", dot_off: "\u{f10c}", cursor: "\u{f054} ", nav_selected: "\u{f178}", nav_expand: "\u{f0da}", nav_collapse: "\u{f0d7}", nav_back: "\u{f060}", arrow_right: "\u{f054}", arrow_left: "\u{f053}", arrow_up: "\u{f077}", arrow_down: "\u{f078}", tree_branch: "├─",
tree_last: "└─",
tree_vertical: "│",
tree_horizontal: "─",
round_tl: "╭",
round_tr: "╮",
round_bl: "╰",
round_br: "╯",
round_h: "─",
round_v: "│",
sharp_tl: "┌",
sharp_tr: "┐",
sharp_bl: "└",
sharp_br: "┘",
sharp_h: "─",
sharp_v: "│",
sharp_cross: "┼",
sharp_tee_right: "├",
sharp_tee_left: "┤",
sep_block: "\u{e0b0}", sep_dot: " \u{f111} ",
sep_pipe: " \u{e0b3} ",
rule: "─",
bullet: "\u{f111}", dash: "–",
checkbox_on: "\u{f14a}", checkbox_off: "\u{f096}", radio_on: "\u{f192}", radio_off: "\u{f10c}", icon_folder: "\u{f115}", icon_file: "\u{f15b}", icon_search: "\u{f002}", icon_git: "\u{f1d3}", icon_branch: "\u{f126}", icon_model: "\u{ec19}", icon_cost: "\u{f155}", icon_time: "\u{f017}", icon_tokens: "\u{e26b}", icon_context: "\u{e70f}", icon_prompt: "\u{f054}", icon_warning: "\u{f071}", icon_todo: "\u{f03a}", icon_thinking: "\u{f013}", icon_thinking_off: "\u{f05e} off", icon_thinking_minimal: "\u{f0e7} min", icon_thinking_low: "\u{f10c} low", icon_thinking_medium: "\u{f192} med", icon_thinking_high: "\u{f111} high", icon_thinking_xhigh: "\u{f06d} xhi", tool_bash: "\u{ebca}", tool_edit: "\u{ea73}", tool_write: "\u{ea7f}", tool_read: "\u{f02d}", tool_search: "\u{f002}", tool_task: "\u{f4a0}", tool_web: "\u{eaae}", tool_lsp: "\u{ea61}", tool_debug: "\u{ead8}", tool_mcp: "\u{eb2d}", tool_ask: "\u{f059}", tool_generic: "\u{f0ad}", icon_lang_rust: "\u{e7a8} rs",
icon_lang_python: "\u{e73c} py",
icon_lang_javascript: "\u{e781} js",
icon_lang_typescript: "\u{e6a6} ts",
icon_lang_go: "\u{e724} go",
icon_lang_java: "\u{e738} java",
icon_lang_cpp: "\u{e61d} c++",
icon_lang_csharp: "\u{e64a} cs",
icon_lang_swift: "\u{e755} sw",
icon_lang_kotlin: "\u{e70c} kt",
icon_lang_shell: "\u{e795} sh",
icon_lang_html: "\u{e736} html",
icon_lang_css: "\u{e749} css",
icon_lang_json: "\u{e787} json",
icon_lang_sql: "\u{e706} sql",
icon_lang_docker: "\u{e7b0} docker",
icon_lang_default: "\u{f15c} ?",
spinner_status: SPINNER_STATUS_NERD,
spinner_activity: SPINNER_ACTIVITY_UNICODE,
}
}
pub fn thinking_level_icon(self, level: &str) -> &'static str {
match level {
"off" => self.icon_thinking_off,
"minimal" => self.icon_thinking_minimal,
"low" => self.icon_thinking_low,
"medium" => self.icon_thinking_medium,
"high" => self.icon_thinking_high,
"xhigh" => self.icon_thinking_xhigh,
_ => self.icon_thinking_off,
}
}
pub fn lang_icon(self, lang: &str) -> &'static str {
match lang {
"rust" | "rs" => self.icon_lang_rust,
"python" | "py" => self.icon_lang_python,
"javascript" | "js" => self.icon_lang_javascript,
"typescript" | "ts" | "tsx" | "jsx" => self.icon_lang_typescript,
"go" => self.icon_lang_go,
"java" => self.icon_lang_java,
"c" | "cpp" | "c++" | "cxx" => self.icon_lang_cpp,
"csharp" | "cs" | "c#" => self.icon_lang_csharp,
"swift" => self.icon_lang_swift,
"kotlin" | "kt" => self.icon_lang_kotlin,
"bash" | "sh" | "shell" | "zsh" => self.icon_lang_shell,
"html" => self.icon_lang_html,
"css" => self.icon_lang_css,
"json" => self.icon_lang_json,
"sql" => self.icon_lang_sql,
"docker" | "dockerfile" => self.icon_lang_docker,
"yaml" | "yml" | "toml" | "xml" | "ini" | "conf" => self.icon_lang_json,
"ruby" | "rb" | "php" | "lua" | "markdown" | "md" | "text" | "txt" => {
self.icon_lang_default
}
_ => self.icon_lang_default,
}
}
}
impl Default for Symbols {
fn default() -> Self {
GlyphSet::Unicode.symbols()
}
}
const SPINNER_STATUS_UNICODE: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
const SPINNER_ACTIVITY_UNICODE: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const SPINNER_STATUS_ASCII: &[&str] = &["|", "/", "-", "\\"];
const SPINNER_ACTIVITY_ASCII: &[&str] = &["|", "/", "-", "\\"];
const SPINNER_STATUS_NERD: &[&str] = &[
"\u{f1146}",
"\u{f114b}",
"\u{f114c}",
"\u{f114d}",
"\u{f114e}",
"\u{f114f}",
"\u{f1150}",
"\u{f1151}",
"\u{f1152}",
"\u{f1153}",
"\u{f1154}",
"\u{f1155}",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_unicode() {
assert_eq!(GlyphSet::default(), GlyphSet::Unicode);
assert_eq!(Symbols::default(), Symbols::unicode());
}
#[test]
fn parse_glyph_set() {
assert_eq!("unicode".parse::<GlyphSet>().unwrap(), GlyphSet::Unicode);
assert_eq!("ASCII".parse::<GlyphSet>().unwrap(), GlyphSet::Ascii);
assert_eq!("nerdfont".parse::<GlyphSet>().unwrap(), GlyphSet::Nerd);
assert_eq!("default".parse::<GlyphSet>().unwrap(), GlyphSet::Unicode);
assert!("emoji".parse::<GlyphSet>().is_err());
}
#[test]
fn serde_roundtrip() {
#[derive(serde::Serialize, serde::Deserialize)]
struct Wrap {
#[allow(dead_code)]
glyph_set: GlyphSet,
}
let toml_str = "glyph_set = \"nerd\"\n";
let parsed: Wrap = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.glyph_set, GlyphSet::Nerd);
let back = toml::to_string(&Wrap {
glyph_set: GlyphSet::Unicode,
})
.unwrap();
assert!(back.contains("unicode"), "got: {back}");
assert!(toml::from_str::<Wrap>("glyph_set = \"emoji\"\n").is_err());
}
#[test]
fn all_presets_populate_every_field_distinctly() {
for preset in GlyphSet::ALL {
let s = preset.symbols();
assert!(!s.status_success.is_empty());
assert!(!s.cursor.is_empty());
assert!(!s.rule.is_empty());
assert!(!s.spinner_status.is_empty());
assert!(!s.spinner_activity.is_empty());
assert!(!s.arrow_up.is_empty());
assert!(!s.arrow_down.is_empty());
assert!(!s.icon_thinking.is_empty());
assert!(!s.tool_generic.is_empty());
assert!(!s.icon_thinking_off.is_empty());
assert!(!s.icon_thinking_minimal.is_empty());
assert!(!s.icon_thinking_low.is_empty());
assert!(!s.icon_thinking_medium.is_empty());
assert!(!s.icon_thinking_high.is_empty());
assert!(!s.icon_thinking_xhigh.is_empty());
assert!(!s.icon_lang_rust.is_empty());
assert!(!s.icon_lang_python.is_empty());
assert!(!s.icon_lang_shell.is_empty());
assert!(!s.icon_lang_default.is_empty());
}
assert_ne!(Symbols::unicode(), Symbols::ascii());
assert_ne!(Symbols::unicode(), Symbols::nerd());
assert_ne!(Symbols::ascii(), Symbols::nerd());
}
#[test]
fn ascii_preset_is_7bit_clean() {
let s = Symbols::ascii();
for field in [
s.status_success,
s.status_error,
s.cursor,
s.tree_branch,
s.rule,
s.sep_block,
s.bullet,
s.tool_bash,
s.arrow_up,
s.arrow_down,
s.icon_thinking,
s.tool_generic,
s.icon_thinking_off,
s.icon_thinking_minimal,
s.icon_thinking_low,
s.icon_thinking_medium,
s.icon_thinking_high,
s.icon_thinking_xhigh,
s.icon_lang_rust,
s.icon_lang_python,
s.icon_lang_shell,
s.icon_lang_default,
] {
assert!(
field.is_ascii(),
"ASCII preset field '{field}' must be 7-bit clean"
);
}
}
#[test]
fn unicode_cursor_is_two_cells_wide_prefix() {
assert!(Symbols::unicode().cursor.ends_with(' '));
assert!(Symbols::ascii().cursor.ends_with(' '));
}
#[test]
fn unknown_glyph_set_error_is_helpful() {
let err = "wat".parse::<GlyphSet>().unwrap_err();
let msg = err.to_string();
assert!(msg.contains("unicode"), "msg was: {msg}");
assert!(msg.contains("ascii"));
assert!(msg.contains("nerd"));
}
#[test]
fn samples_render_for_each_preset() {
for preset in GlyphSet::ALL {
assert!(!preset.sample().is_empty());
}
}
}