use crate::theme::{Color, parse_color_value};
use anyhow::{Context, Result, bail};
use std::collections::HashMap;
const PRETTY_LIST_ICONS: &[&str] = &[
"\u{f444}", "\u{f445}", "\u{f4c3}", "\u{f51d}", ];
#[derive(Debug, Clone, PartialEq)]
pub struct ListMarkerOverride {
pub icon: Option<String>,
pub color: Option<Color>,
}
#[derive(Debug, Default, Clone)]
pub struct ListMarkerConfig {
pub pretty: bool,
pub overrides: HashMap<usize, ListMarkerOverride>,
}
impl ListMarkerConfig {
pub fn resolve(&self, level: usize) -> Option<(String, Option<Color>)> {
let override_entry = self.overrides.get(&level);
let override_icon = override_entry.and_then(|e| e.icon.clone());
let override_color = override_entry.and_then(|e| e.color.clone());
if self.pretty {
let icon = override_icon.unwrap_or_else(|| {
let idx = level.saturating_sub(1).min(PRETTY_LIST_ICONS.len() - 1);
PRETTY_LIST_ICONS[idx].to_string()
});
return Some((icon, override_color));
}
override_icon.map(|icon| (icon, override_color))
}
pub fn parse_custom_list(input: &str) -> Result<HashMap<usize, ListMarkerOverride>> {
let mut out = HashMap::new();
let mut has_entries = false;
for raw_entry in input.split(';') {
let entry = raw_entry.trim();
if entry.is_empty() {
continue;
}
has_entries = true;
let (level_raw, rest) = entry
.split_once(':')
.with_context(|| format!("Custom list entry '{entry}' must contain ':'"))?;
let level: usize = level_raw.trim().parse().with_context(|| {
format!("Custom list level '{level_raw}' must be a positive integer")
})?;
if level == 0 {
bail!("Custom list level must be 1 or greater (got 0).");
}
let rest = rest.trim();
if rest.is_empty() {
bail!("Custom list level {level} must define an icon or color.");
}
let (first, remainder) = match rest.split_once(':') {
Some(parts) => parts,
None => (rest, ""),
};
let first_trim = first.trim();
let (icon, color) = if let Ok(parsed) = parse_color_value(first_trim) {
if !remainder.trim().is_empty() {
bail!(
"Custom list level {level} color-only entry must not contain extra tokens."
);
}
(None, Some(parsed))
} else {
let color = if remainder.trim().is_empty() {
None
} else {
let trimmed = remainder.trim();
Some(parse_color_value(trimmed).with_context(|| {
format!("Custom list level {level} has invalid color '{trimmed}'")
})?)
};
(Some(first_trim.to_string()), color)
};
if out
.insert(level, ListMarkerOverride { icon, color })
.is_some()
{
bail!("Custom list level {level} is defined more than once.");
}
}
if !has_entries {
bail!("Custom list string is empty.");
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Color;
#[test]
fn pretty_list_uses_builtin_icons() {
let cfg = ListMarkerConfig {
pretty: true,
..Default::default()
};
let (icon1, _) = cfg.resolve(1).unwrap();
let (icon2, _) = cfg.resolve(2).unwrap();
let (icon3, _) = cfg.resolve(3).unwrap();
let (icon4, _) = cfg.resolve(4).unwrap();
let (icon10, _) = cfg.resolve(10).unwrap();
assert_eq!(icon1, "\u{f444}");
assert_eq!(icon2, "\u{f445}");
assert_eq!(icon3, "\u{f4c3}");
assert_eq!(icon4, "\u{f51d}");
assert_eq!(icon10, "\u{f51d}");
}
#[test]
fn custom_list_overrides_per_level() {
let cfg = ListMarkerConfig {
pretty: true,
overrides: ListMarkerConfig::parse_custom_list("5:&").unwrap(),
};
let (icon, color) = cfg.resolve(5).unwrap();
assert_eq!(icon, "&");
assert_eq!(color, None);
}
#[test]
fn custom_list_parses_color() {
let overrides = ListMarkerConfig::parse_custom_list("5:&:#ff0000").unwrap();
let entry = overrides.get(&5).unwrap();
assert_eq!(entry.icon, Some("&".to_string()));
assert_eq!(
entry.color,
Some(Color::Rgb {
r: 0xff,
g: 0,
b: 0
})
);
}
#[test]
fn custom_list_rejects_duplicate_levels() {
assert!(ListMarkerConfig::parse_custom_list("1:a;1:b").is_err());
}
#[test]
fn custom_list_rejects_zero_level() {
assert!(ListMarkerConfig::parse_custom_list("0:a").is_err());
}
#[test]
fn custom_list_rejects_empty_value() {
assert!(ListMarkerConfig::parse_custom_list("1:").is_err());
}
#[test]
fn inactive_config_returns_none() {
let cfg = ListMarkerConfig::default();
assert!(cfg.resolve(1).is_none());
}
#[test]
fn custom_list_color_only_parses() {
let overrides = ListMarkerConfig::parse_custom_list("1:red;2:#00ff00").unwrap();
assert_eq!(overrides.get(&1).unwrap().icon, None);
assert_eq!(overrides.get(&1).unwrap().color, Some(Color::Red));
assert_eq!(overrides.get(&2).unwrap().icon, None);
assert_eq!(
overrides.get(&2).unwrap().color,
Some(Color::Rgb {
r: 0,
g: 0xff,
b: 0
})
);
}
#[test]
fn color_only_falls_back_to_pretty_icon() {
let cfg = ListMarkerConfig {
pretty: true,
overrides: ListMarkerConfig::parse_custom_list("1:red").unwrap(),
};
let (icon, color) = cfg.resolve(1).unwrap();
assert_eq!(icon, "\u{f444}");
assert_eq!(color, Some(Color::Red));
}
#[test]
fn color_only_rejects_extra_tokens() {
assert!(ListMarkerConfig::parse_custom_list("1:red:extra").is_err());
}
}