Skip to main content

standout_render/theme/
icon_def.rs

1//! Icon definitions and icon set collections.
2//!
3//! Icons are characters (not images) used in terminal output. Each icon
4//! has a classic (Unicode) variant and an optional Nerd Font variant.
5//!
6//! # Example
7//!
8//! ```rust
9//! use standout_render::{IconDefinition, IconSet, IconMode};
10//!
11//! let icons = IconSet::new()
12//!     .add("pending", IconDefinition::new("⚪"))
13//!     .add("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"))
14//!     .add("timer", IconDefinition::new("⏲").with_nerdfont("\u{f017}"));
15//!
16//! // Resolve for classic mode
17//! let resolved = icons.resolve(IconMode::Classic);
18//! assert_eq!(resolved.get("pending").unwrap(), "⚪");
19//! assert_eq!(resolved.get("done").unwrap(), "⚫");
20//!
21//! // Resolve for Nerd Font mode
22//! let resolved = icons.resolve(IconMode::NerdFont);
23//! assert_eq!(resolved.get("pending").unwrap(), "⚪"); // No nerdfont variant, uses classic
24//! assert_eq!(resolved.get("done").unwrap(), "\u{f00c}");
25//! ```
26
27use std::collections::HashMap;
28
29use super::icon_mode::IconMode;
30
31/// A single icon definition with classic and optional Nerd Font variants.
32///
33/// The classic variant is always required and works in all terminals.
34/// The Nerd Font variant is optional and used when the terminal has a
35/// Nerd Font installed.
36///
37/// Icons can be N characters long, though they are typically a single character.
38///
39/// # Example
40///
41/// ```rust
42/// use standout_render::{IconDefinition, IconMode};
43///
44/// // Classic-only icon
45/// let icon = IconDefinition::new("⚪");
46/// assert_eq!(icon.resolve(IconMode::Classic), "⚪");
47/// assert_eq!(icon.resolve(IconMode::NerdFont), "⚪"); // Falls back to classic
48///
49/// // Icon with Nerd Font variant
50/// let icon = IconDefinition::new("⚫").with_nerdfont("\u{f00c}");
51/// assert_eq!(icon.resolve(IconMode::Classic), "⚫");
52/// assert_eq!(icon.resolve(IconMode::NerdFont), "\u{f00c}");
53/// ```
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct IconDefinition {
56    /// Classic variant (always required). Works in all terminals.
57    pub classic: String,
58    /// Nerd Font variant (optional). Used when Nerd Font is available.
59    pub nerdfont: Option<String>,
60}
61
62impl IconDefinition {
63    /// Creates a new icon definition with a classic variant.
64    pub fn new(classic: impl Into<String>) -> Self {
65        Self {
66            classic: classic.into(),
67            nerdfont: None,
68        }
69    }
70
71    /// Adds a Nerd Font variant to this icon definition.
72    pub fn with_nerdfont(mut self, nerdfont: impl Into<String>) -> Self {
73        self.nerdfont = Some(nerdfont.into());
74        self
75    }
76
77    /// Resolves the icon string for the given mode.
78    ///
79    /// In `NerdFont` mode, returns the Nerd Font variant if available,
80    /// otherwise falls back to the classic variant.
81    ///
82    /// In `Classic` or `Auto` mode, always returns the classic variant.
83    pub fn resolve(&self, mode: IconMode) -> &str {
84        match mode {
85            IconMode::NerdFont => self.nerdfont.as_deref().unwrap_or(&self.classic),
86            IconMode::Classic | IconMode::Auto => &self.classic,
87        }
88    }
89}
90
91/// A collection of named icon definitions.
92///
93/// `IconSet` stores icon definitions and resolves them for a given
94/// [`IconMode`] into a flat map of name → string.
95///
96/// # Example
97///
98/// ```rust
99/// use standout_render::{IconSet, IconDefinition, IconMode};
100///
101/// let icons = IconSet::new()
102///     .add("check", IconDefinition::new("[ok]").with_nerdfont("\u{f00c}"))
103///     .add("cross", IconDefinition::new("[!!]").with_nerdfont("\u{f00d}"));
104///
105/// let resolved = icons.resolve(IconMode::Classic);
106/// assert_eq!(resolved.get("check").unwrap(), "[ok]");
107/// assert_eq!(resolved.get("cross").unwrap(), "[!!]");
108/// ```
109#[derive(Debug, Clone, Default)]
110pub struct IconSet {
111    icons: HashMap<String, IconDefinition>,
112}
113
114impl IconSet {
115    /// Creates an empty icon set.
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Adds an icon definition, returning `self` for chaining.
121    pub fn add(mut self, name: impl Into<String>, def: IconDefinition) -> Self {
122        self.icons.insert(name.into(), def);
123        self
124    }
125
126    /// Inserts an icon definition by mutable reference.
127    pub fn insert(&mut self, name: impl Into<String>, def: IconDefinition) {
128        self.icons.insert(name.into(), def);
129    }
130
131    /// Resolves all icons for the given mode into a flat name → string map.
132    pub fn resolve(&self, mode: IconMode) -> HashMap<String, String> {
133        self.icons
134            .iter()
135            .map(|(name, def)| (name.clone(), def.resolve(mode).to_string()))
136            .collect()
137    }
138
139    /// Returns true if no icons are defined.
140    pub fn is_empty(&self) -> bool {
141        self.icons.is_empty()
142    }
143
144    /// Returns the number of defined icons.
145    pub fn len(&self) -> usize {
146        self.icons.len()
147    }
148
149    /// Merges another icon set into this one.
150    ///
151    /// Icons from `other` take precedence over icons in `self`.
152    pub fn merge(mut self, other: IconSet) -> Self {
153        self.icons.extend(other.icons);
154        self
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    // =========================================================================
163    // IconDefinition tests
164    // =========================================================================
165
166    #[test]
167    fn test_icon_definition_classic_only() {
168        let icon = IconDefinition::new("⚪");
169        assert_eq!(icon.classic, "⚪");
170        assert_eq!(icon.nerdfont, None);
171    }
172
173    #[test]
174    fn test_icon_definition_with_nerdfont() {
175        let icon = IconDefinition::new("⚫").with_nerdfont("\u{f00c}");
176        assert_eq!(icon.classic, "⚫");
177        assert_eq!(icon.nerdfont, Some("\u{f00c}".to_string()));
178    }
179
180    #[test]
181    fn test_icon_definition_resolve_classic_mode() {
182        let icon = IconDefinition::new("⚫").with_nerdfont("\u{f00c}");
183        assert_eq!(icon.resolve(IconMode::Classic), "⚫");
184    }
185
186    #[test]
187    fn test_icon_definition_resolve_nerdfont_mode() {
188        let icon = IconDefinition::new("⚫").with_nerdfont("\u{f00c}");
189        assert_eq!(icon.resolve(IconMode::NerdFont), "\u{f00c}");
190    }
191
192    #[test]
193    fn test_icon_definition_resolve_nerdfont_fallback() {
194        // No nerdfont variant, should fall back to classic
195        let icon = IconDefinition::new("⚪");
196        assert_eq!(icon.resolve(IconMode::NerdFont), "⚪");
197    }
198
199    #[test]
200    fn test_icon_definition_resolve_auto_mode() {
201        let icon = IconDefinition::new("⚫").with_nerdfont("\u{f00c}");
202        // Auto mode resolves to classic
203        assert_eq!(icon.resolve(IconMode::Auto), "⚫");
204    }
205
206    #[test]
207    fn test_icon_definition_multi_char() {
208        let icon = IconDefinition::new("[ok]").with_nerdfont("\u{f00c}");
209        assert_eq!(icon.resolve(IconMode::Classic), "[ok]");
210        assert_eq!(icon.resolve(IconMode::NerdFont), "\u{f00c}");
211    }
212
213    #[test]
214    fn test_icon_definition_empty_string() {
215        let icon = IconDefinition::new("");
216        assert_eq!(icon.resolve(IconMode::Classic), "");
217    }
218
219    #[test]
220    fn test_icon_definition_equality() {
221        let a = IconDefinition::new("⚪").with_nerdfont("nf");
222        let b = IconDefinition::new("⚪").with_nerdfont("nf");
223        assert_eq!(a, b);
224    }
225
226    // =========================================================================
227    // IconSet tests
228    // =========================================================================
229
230    #[test]
231    fn test_icon_set_new_is_empty() {
232        let set = IconSet::new();
233        assert!(set.is_empty());
234        assert_eq!(set.len(), 0);
235    }
236
237    #[test]
238    fn test_icon_set_add() {
239        let set = IconSet::new()
240            .add("pending", IconDefinition::new("⚪"))
241            .add("done", IconDefinition::new("⚫"));
242        assert_eq!(set.len(), 2);
243        assert!(!set.is_empty());
244    }
245
246    #[test]
247    fn test_icon_set_insert() {
248        let mut set = IconSet::new();
249        set.insert("pending", IconDefinition::new("⚪"));
250        assert_eq!(set.len(), 1);
251    }
252
253    #[test]
254    fn test_icon_set_resolve_classic() {
255        let set = IconSet::new()
256            .add("pending", IconDefinition::new("⚪"))
257            .add("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
258
259        let resolved = set.resolve(IconMode::Classic);
260        assert_eq!(resolved.get("pending").unwrap(), "⚪");
261        assert_eq!(resolved.get("done").unwrap(), "⚫");
262    }
263
264    #[test]
265    fn test_icon_set_resolve_nerdfont() {
266        let set = IconSet::new()
267            .add("pending", IconDefinition::new("⚪"))
268            .add("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
269
270        let resolved = set.resolve(IconMode::NerdFont);
271        assert_eq!(resolved.get("pending").unwrap(), "⚪"); // No nerdfont, falls back
272        assert_eq!(resolved.get("done").unwrap(), "\u{f00c}");
273    }
274
275    #[test]
276    fn test_icon_set_merge() {
277        let base = IconSet::new()
278            .add("keep", IconDefinition::new("K"))
279            .add("override", IconDefinition::new("OLD"));
280
281        let extension = IconSet::new()
282            .add("override", IconDefinition::new("NEW"))
283            .add("added", IconDefinition::new("A"));
284
285        let merged = base.merge(extension);
286
287        assert_eq!(merged.len(), 3);
288        let resolved = merged.resolve(IconMode::Classic);
289        assert_eq!(resolved.get("keep").unwrap(), "K");
290        assert_eq!(resolved.get("override").unwrap(), "NEW");
291        assert_eq!(resolved.get("added").unwrap(), "A");
292    }
293
294    #[test]
295    fn test_icon_set_resolve_empty() {
296        let set = IconSet::new();
297        let resolved = set.resolve(IconMode::Classic);
298        assert!(resolved.is_empty());
299    }
300}