use std::collections::HashMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader, Cursor};
use std::path::Path;
use once_cell::sync::Lazy;
use crate::color::SimpleColor as Color;
use crate::style::Style;
const DRACULA_THEME_DATA: &str = include_str!("themes/dracula.theme");
const GRUVBOX_DARK_THEME_DATA: &str = include_str!("themes/gruvbox-dark.theme");
const NORD_THEME_DATA: &str = include_str!("themes/nord.theme");
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ThemeError {
PopBaseTheme,
IoError(String),
InvalidFormat(String),
}
impl std::fmt::Display for ThemeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThemeError::PopBaseTheme => write!(f, "Unable to pop base theme"),
ThemeError::IoError(msg) => write!(f, "IO error: {}", msg),
ThemeError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
}
}
}
impl std::error::Error for ThemeError {}
impl From<io::Error> for ThemeError {
fn from(err: io::Error) -> Self {
ThemeError::IoError(err.to_string())
}
}
#[derive(Debug, Clone)]
pub struct Theme {
styles: HashMap<String, Style>,
inherit: bool,
}
impl Default for Theme {
fn default() -> Self {
Self::new()
}
}
impl Theme {
pub fn new() -> Self {
Theme {
styles: default_styles(),
inherit: true,
}
}
pub fn empty() -> Self {
Theme {
styles: HashMap::new(),
inherit: false,
}
}
pub fn with_styles(styles: HashMap<String, Style>, inherit: bool) -> Self {
let mut theme_styles = if inherit {
default_styles()
} else {
HashMap::new()
};
theme_styles.extend(styles);
Theme {
styles: theme_styles,
inherit,
}
}
pub fn read<P: AsRef<Path>>(path: P, inherit: bool) -> Result<Self, ThemeError> {
let file = File::open(path)?;
let reader = BufReader::new(file);
Self::from_reader(reader, inherit)
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ThemeError> {
Self::read(path, true)
}
pub fn from_reader<R: BufRead>(reader: R, inherit: bool) -> Result<Self, ThemeError> {
let mut styles = HashMap::new();
let mut in_styles_section = false;
for line in reader.lines() {
let line = line?;
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
let section = &line[1..line.len() - 1].trim().to_lowercase();
in_styles_section = section == "styles";
continue;
}
if in_styles_section && let Some((name, style_str)) = line.split_once('=') {
let name = name.trim().to_string();
let style_str = style_str.trim();
if let Some(style) = Style::parse(style_str) {
styles.insert(name, style);
}
}
}
Ok(Self::with_styles(styles, inherit))
}
pub fn get_style(&self, name: &str) -> Option<Style> {
self.styles.get(name).copied()
}
pub fn add_style(&mut self, name: impl Into<String>, style: Style) {
self.styles.insert(name.into(), style);
}
pub fn remove_style(&mut self, name: &str) -> Option<Style> {
self.styles.remove(name)
}
pub fn has_style(&self, name: &str) -> bool {
self.styles.contains_key(name)
}
pub fn style_names(&self) -> impl Iterator<Item = &str> {
self.styles.keys().map(String::as_str)
}
pub fn len(&self) -> usize {
self.styles.len()
}
pub fn is_empty(&self) -> bool {
self.styles.is_empty()
}
pub fn inherits(&self) -> bool {
self.inherit
}
pub fn to_config(&self) -> String {
let mut lines = vec!["[styles]".to_string()];
let mut names: Vec<_> = self.styles.keys().collect();
names.sort();
for name in names {
if let Some(style) = self.styles.get(name) {
lines.push(format!("{} = {}", name, style.to_markup_string()));
}
}
lines.join("\n")
}
pub fn from_name(name: &str) -> Option<Self> {
match name.to_lowercase().as_str() {
"default" => Some(Self::new()),
"dracula" => {
let reader = Cursor::new(DRACULA_THEME_DATA);
Self::from_reader(reader, true).ok()
}
"gruvbox-dark" | "gruvbox" => {
let reader = Cursor::new(GRUVBOX_DARK_THEME_DATA);
Self::from_reader(reader, true).ok()
}
"nord" => {
let reader = Cursor::new(NORD_THEME_DATA);
Self::from_reader(reader, true).ok()
}
_ => None,
}
}
pub fn available_themes() -> Vec<&'static str> {
vec!["default", "dracula", "gruvbox-dark", "nord"]
}
}
#[derive(Debug, Clone)]
pub struct ThemeStack {
entries: Vec<HashMap<String, Style>>,
}
impl ThemeStack {
pub fn new(theme: Theme) -> Self {
ThemeStack {
entries: vec![theme.styles],
}
}
pub fn push(&mut self, theme: Theme, inherit: bool) {
let styles = if inherit && !self.entries.is_empty() {
let mut merged = self.entries.last().unwrap().clone();
merged.extend(theme.styles);
merged
} else {
theme.styles
};
self.entries.push(styles);
}
pub fn push_theme(&mut self, theme: Theme) {
self.push(theme, true);
}
pub fn pop(&mut self) -> Result<(), ThemeError> {
if self.entries.len() == 1 {
return Err(ThemeError::PopBaseTheme);
}
self.entries.pop();
Ok(())
}
pub fn pop_theme(&mut self) -> Result<(), ThemeError> {
self.pop()
}
pub fn get_style(&self, name: &str) -> Option<Style> {
self.entries
.last()
.and_then(|styles| styles.get(name).copied())
}
pub fn depth(&self) -> usize {
self.entries.len()
}
}
impl Default for ThemeStack {
fn default() -> Self {
Self::new(Theme::new())
}
}
pub fn default_styles() -> HashMap<String, Style> {
let mut styles = HashMap::new();
macro_rules! add {
($name:expr, $style:expr) => {
styles.insert($name.to_string(), $style);
};
}
add!("none", Style::new());
add!(
"reset",
Style {
color: Some(Color::Default),
bgcolor: Some(Color::Default),
bold: Some(false),
dim: Some(false),
italic: Some(false),
underline: Some(false),
blink: Some(false),
blink2: Some(false),
reverse: Some(false),
conceal: Some(false),
strike: Some(false),
underline2: Some(false),
frame: Some(false),
encircle: Some(false),
overline: Some(false),
}
);
add!("dim", Style::new().with_dim(true));
add!(
"bright",
Style {
dim: Some(false),
..Default::default()
}
);
add!("bold", Style::new().with_bold(true));
add!("strong", Style::new().with_bold(true));
add!(
"code",
Style {
reverse: Some(true),
bold: Some(true),
..Default::default()
}
);
add!("italic", Style::new().with_italic(true));
add!("emphasize", Style::new().with_italic(true));
add!("underline", Style::new().with_underline(true));
add!(
"blink",
Style {
blink: Some(true),
..Default::default()
}
);
add!(
"reverse",
Style {
reverse: Some(true),
..Default::default()
}
);
add!("strike", Style::new().with_strike(true));
add!("black", Style::color(Color::Standard(0)));
add!("red", Style::color(Color::Standard(1)));
add!("green", Style::color(Color::Standard(2)));
add!("yellow", Style::color(Color::Standard(3)));
add!("blue", Style::color(Color::Standard(4)));
add!("magenta", Style::color(Color::Standard(5)));
add!("cyan", Style::color(Color::Standard(6)));
add!("white", Style::color(Color::Standard(7)));
add!(
"inspect.attr",
Style::color(Color::Standard(3)).with_italic(true)
);
add!(
"inspect.attr.dunder",
Style::color(Color::Standard(3))
.with_italic(true)
.with_dim(true)
);
add!(
"inspect.callable",
Style::color(Color::Standard(1)).with_bold(true)
);
add!(
"inspect.async_def",
Style::color(Color::Standard(14)).with_italic(true)
);
add!(
"inspect.def",
Style::color(Color::Standard(14)).with_italic(true)
);
add!(
"inspect.class",
Style::color(Color::Standard(14)).with_italic(true)
);
add!(
"inspect.error",
Style::color(Color::Standard(1)).with_bold(true)
);
add!("inspect.equals", Style::new());
add!("inspect.help", Style::color(Color::Standard(6)));
add!("inspect.doc", Style::new().with_dim(true));
add!("inspect.value.border", Style::color(Color::Standard(2)));
add!(
"live.ellipsis",
Style::color(Color::Standard(1)).with_bold(true)
);
add!(
"layout.tree.row",
Style::color(Color::Standard(1)).with_dim(false)
);
add!(
"layout.tree.column",
Style::color(Color::Standard(4)).with_dim(false)
);
add!(
"logging.keyword",
Style::color(Color::Standard(3)).with_bold(true)
);
add!("logging.level.notset", Style::new().with_dim(true));
add!("logging.level.debug", Style::color(Color::Standard(2)));
add!("logging.level.info", Style::color(Color::Standard(4)));
add!("logging.level.warning", Style::color(Color::Standard(3)));
add!(
"logging.level.error",
Style::color(Color::Standard(1)).with_bold(true)
);
add!(
"logging.level.critical",
Style {
color: Some(Color::Standard(1)),
bold: Some(true),
reverse: Some(true),
..Default::default()
}
);
add!("log.level", Style::new());
add!("log.time", Style::color(Color::Standard(6)).with_dim(true));
add!("log.message", Style::new());
add!("log.path", Style::new().with_dim(true));
add!("repr.ellipsis", Style::color(Color::Standard(3)));
add!(
"repr.indent",
Style::color(Color::Standard(2)).with_dim(true)
);
add!(
"repr.error",
Style::color(Color::Standard(1)).with_bold(true)
);
add!(
"repr.str",
Style::color(Color::Standard(2))
.with_italic(false)
.with_bold(false)
);
add!("repr.brace", Style::new().with_bold(true));
add!("repr.comma", Style::new().with_bold(true));
add!(
"repr.ipv4",
Style::color(Color::Standard(10)).with_bold(true)
);
add!(
"repr.ipv6",
Style::color(Color::Standard(10)).with_bold(true)
);
add!(
"repr.eui48",
Style::color(Color::Standard(10)).with_bold(true)
);
add!(
"repr.eui64",
Style::color(Color::Standard(10)).with_bold(true)
);
add!("repr.tag_start", Style::new().with_bold(true));
add!(
"repr.tag_name",
Style::color(Color::Standard(13)).with_bold(true)
);
add!("repr.tag_contents", Style::color(Color::Default));
add!("repr.tag_end", Style::new().with_bold(true));
add!(
"repr.attrib_name",
Style::color(Color::Standard(3)).with_italic(false)
);
add!("repr.attrib_equal", Style::new().with_bold(true));
add!(
"repr.attrib_value",
Style::color(Color::Standard(5)).with_italic(false)
);
add!(
"repr.number",
Style::color(Color::Standard(6))
.with_bold(true)
.with_italic(false)
);
add!(
"repr.number_complex",
Style::color(Color::Standard(6))
.with_bold(true)
.with_italic(false)
);
add!(
"repr.bool_true",
Style::color(Color::Standard(10)).with_italic(true)
);
add!(
"repr.bool_false",
Style::color(Color::Standard(9)).with_italic(true)
);
add!(
"repr.none",
Style::color(Color::Standard(5)).with_italic(true)
);
add!(
"repr.url",
Style::color(Color::Standard(12))
.with_underline(true)
.with_italic(false)
.with_bold(false)
);
add!(
"repr.uuid",
Style::color(Color::Standard(11)).with_bold(false)
);
add!(
"repr.call",
Style::color(Color::Standard(5)).with_bold(true)
);
add!("repr.path", Style::color(Color::Standard(5)));
add!("repr.filename", Style::color(Color::Standard(13)));
add!("rule.line", Style::color(Color::Standard(10)));
add!("rule.text", Style::new());
add!("json.brace", Style::new().with_bold(true));
add!(
"json.bool_true",
Style::color(Color::Standard(10)).with_italic(true)
);
add!(
"json.bool_false",
Style::color(Color::Standard(9)).with_italic(true)
);
add!(
"json.null",
Style::color(Color::Standard(5)).with_italic(true)
);
add!(
"json.number",
Style::color(Color::Standard(6))
.with_bold(true)
.with_italic(false)
);
add!(
"json.str",
Style::color(Color::Standard(2))
.with_italic(false)
.with_bold(false)
);
add!("json.key", Style::color(Color::Standard(4)).with_bold(true));
add!("prompt", Style::new());
add!(
"prompt.choices",
Style::color(Color::Standard(5)).with_bold(true)
);
add!(
"prompt.default",
Style::color(Color::Standard(6)).with_bold(true)
);
add!("prompt.invalid", Style::color(Color::Standard(1)));
add!("prompt.invalid.choice", Style::color(Color::Standard(1)));
add!("pretty", Style::new());
add!(
"scope.border",
Style::color(Color::Standard(4)).with_dim(true)
);
add!(
"scope.key",
Style::color(Color::Standard(6)).with_bold(true)
);
add!(
"scope.key.special",
Style::color(Color::Standard(3))
.with_italic(true)
.with_dim(true)
);
add!("scope.equals", Style::new());
add!("table.header", Style::new().with_bold(true));
add!("table.footer", Style::new().with_bold(true));
add!("table.cell", Style::new());
add!("table.title", Style::new().with_italic(true));
add!(
"table.caption",
Style::new().with_italic(true).with_dim(true)
);
add!("traceback.border", Style::color(Color::Standard(1)));
add!(
"traceback.border.syntax_error",
Style::color(Color::Standard(9))
);
add!(
"traceback.title",
Style::color(Color::Standard(1)).with_bold(true)
);
add!("traceback.text", Style::color(Color::Standard(1)));
add!(
"traceback.exc_type",
Style::color(Color::Standard(9)).with_bold(true)
);
add!("traceback.exc_value", Style::new());
add!(
"traceback.error",
Style::color(Color::Standard(1)).with_bold(true)
);
add!(
"traceback.error_range",
Style::color(Color::Standard(1))
.with_bold(true)
.with_underline(true)
);
add!("traceback.path", Style::color(Color::Standard(8)));
add!("traceback.filename", Style::color(Color::Standard(13)));
add!("traceback.lineno", Style::color(Color::Standard(13)));
add!(
"traceback.offset",
Style::color(Color::Standard(9)).with_bold(true)
);
add!(
"traceback.note",
Style::color(Color::Standard(2)).with_bold(true)
);
add!("traceback.group.border", Style::color(Color::Standard(5)));
add!("bar.back", Style::color(Color::EightBit(59))); add!(
"bar.complete",
Style::color(Color::Rgb {
r: 249,
g: 38,
b: 114
})
);
add!(
"bar.finished",
Style::color(Color::Rgb {
r: 114,
g: 156,
b: 31
})
);
add!(
"bar.pulse",
Style::color(Color::Rgb {
r: 249,
g: 38,
b: 114
})
);
add!("progress.description", Style::new());
add!("progress.filesize", Style::color(Color::Standard(2)));
add!("progress.filesize.total", Style::color(Color::Standard(2)));
add!("progress.download", Style::color(Color::Standard(2)));
add!("progress.elapsed", Style::color(Color::Standard(3)));
add!("progress.percentage", Style::color(Color::Standard(5)));
add!("progress.remaining", Style::color(Color::Standard(6)));
add!("progress.data.speed", Style::color(Color::Standard(1)));
add!("progress.spinner", Style::color(Color::Standard(2)));
add!("status.spinner", Style::color(Color::Standard(2)));
add!("tree", Style::new());
add!("tree.line", Style::new());
add!("markdown.paragraph", Style::new());
add!("markdown.text", Style::new());
add!("markdown.em", Style::new().with_italic(true));
add!("markdown.emph", Style::new().with_italic(true));
add!("markdown.strong", Style::new().with_bold(true));
add!(
"markdown.code",
Style::color(Color::Standard(6))
.with_bold(true)
.with_bgcolor(Color::Standard(0))
);
add!(
"markdown.code_block",
Style::color(Color::Standard(6)).with_bgcolor(Color::Standard(0))
);
add!("markdown.block_quote", Style::color(Color::Standard(5)));
add!("markdown.list", Style::color(Color::Standard(6)));
add!("markdown.item", Style::new());
add!(
"markdown.item.bullet",
Style::color(Color::Standard(3)).with_bold(true)
);
add!(
"markdown.item.number",
Style::color(Color::Standard(3)).with_bold(true)
);
add!("markdown.hr", Style::color(Color::Standard(3)));
add!("markdown.h1.border", Style::new());
add!("markdown.h1", Style::new().with_bold(true));
add!(
"markdown.h2",
Style::new().with_bold(true).with_underline(true)
);
add!("markdown.h3", Style::new().with_bold(true));
add!("markdown.h4", Style::new().with_bold(true).with_dim(true));
add!("markdown.h5", Style::new().with_underline(true));
add!("markdown.h6", Style::new().with_italic(true));
add!("markdown.h7", Style::new().with_italic(true).with_dim(true));
add!("markdown.link", Style::color(Color::Standard(12)));
add!(
"markdown.link_url",
Style::color(Color::Standard(4)).with_underline(true)
);
add!("markdown.s", Style::new().with_strike(true));
add!("markdown.table.border", Style::color(Color::Standard(6)));
add!(
"markdown.table.header",
Style::color(Color::Standard(6)).with_bold(false)
);
add!(
"blink2",
Style {
blink2: Some(true),
..Default::default()
}
);
add!("iso8601.date", Style::color(Color::Standard(4)));
add!("iso8601.time", Style::color(Color::Standard(5)));
add!("iso8601.timezone", Style::color(Color::Standard(3)));
styles
}
static DEFAULT_STYLES_MAP: Lazy<HashMap<String, Style>> = Lazy::new(default_styles);
pub fn get_default_style(name: &str) -> Option<Style> {
DEFAULT_STYLES_MAP.get(name).copied()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_theme_new() {
let theme = Theme::new();
assert!(theme.has_style("repr.number"));
assert!(theme.has_style("markdown.h1"));
assert!(theme.len() > 50);
}
#[test]
fn test_theme_empty() {
let theme = Theme::empty();
assert!(theme.is_empty());
assert!(!theme.has_style("repr.number"));
}
#[test]
fn test_theme_get_style() {
let theme = Theme::new();
let style = theme.get_style("repr.number");
assert!(style.is_some());
let style = style.unwrap();
assert_eq!(style.bold, Some(true));
assert_eq!(style.color, Some(Color::Standard(6)));
}
#[test]
fn test_theme_add_style() {
let mut theme = Theme::empty();
theme.add_style("custom", Style::new().with_bold(true));
assert!(theme.has_style("custom"));
assert_eq!(theme.get_style("custom").unwrap().bold, Some(true));
}
#[test]
fn test_theme_remove_style() {
let mut theme = Theme::new();
assert!(theme.has_style("repr.number"));
theme.remove_style("repr.number");
assert!(!theme.has_style("repr.number"));
}
#[test]
fn test_theme_with_styles() {
let mut custom = HashMap::new();
custom.insert("my.style".to_string(), Style::new().with_italic(true));
let theme = Theme::with_styles(custom, true);
assert!(theme.has_style("repr.number"));
assert!(theme.has_style("my.style"));
}
#[test]
fn test_theme_with_styles_no_inherit() {
let mut custom = HashMap::new();
custom.insert("my.style".to_string(), Style::new().with_italic(true));
let theme = Theme::with_styles(custom, false);
assert!(!theme.has_style("repr.number"));
assert!(theme.has_style("my.style"));
}
#[test]
fn test_theme_to_config() {
let mut theme = Theme::empty();
theme.add_style("test.bold", Style::new().with_bold(true));
theme.add_style("test.color", Style::color(Color::Standard(1)));
let config = theme.to_config();
assert!(config.starts_with("[styles]"));
assert!(config.contains("test.bold = bold"));
assert!(config.contains("test.color = red"));
}
#[test]
fn test_theme_from_reader() {
let config = r#"
[styles]
custom.style = bold red
another = italic cyan
"#;
let reader = std::io::Cursor::new(config);
let theme = Theme::from_reader(reader, false).unwrap();
assert!(theme.has_style("custom.style"));
let style = theme.get_style("custom.style").unwrap();
assert_eq!(style.bold, Some(true));
assert_eq!(style.color, Some(Color::Standard(1)));
let another = theme.get_style("another").unwrap();
assert_eq!(another.italic, Some(true));
assert_eq!(another.color, Some(Color::Standard(6)));
}
#[test]
fn test_theme_from_reader_with_comments() {
let config = r#"
# Comment line
[styles]
; Another comment
test = bold
"#;
let reader = std::io::Cursor::new(config);
let theme = Theme::from_reader(reader, false).unwrap();
assert!(theme.has_style("test"));
}
#[test]
fn test_theme_stack_new() {
let stack = ThemeStack::new(Theme::new());
assert_eq!(stack.depth(), 1);
assert!(stack.get_style("repr.number").is_some());
}
#[test]
fn test_theme_stack_push_pop() {
let mut stack = ThemeStack::new(Theme::new());
let mut custom = Theme::empty();
custom.add_style("repr.number", Style::new().with_italic(true));
stack.push(custom, true);
assert_eq!(stack.depth(), 2);
let style = stack.get_style("repr.number").unwrap();
assert_eq!(style.italic, Some(true));
stack.pop().unwrap();
assert_eq!(stack.depth(), 1);
let style = stack.get_style("repr.number").unwrap();
assert_eq!(style.bold, Some(true));
assert_eq!(style.italic, Some(false));
}
#[test]
fn test_theme_stack_push_inherit() {
let mut stack = ThemeStack::new(Theme::new());
let mut custom = Theme::empty();
custom.add_style("custom.style", Style::new().with_bold(true));
stack.push(custom, true);
assert!(stack.get_style("repr.number").is_some());
assert!(stack.get_style("custom.style").is_some());
}
#[test]
fn test_theme_stack_push_no_inherit() {
let mut stack = ThemeStack::new(Theme::new());
let mut custom = Theme::empty();
custom.add_style("custom.style", Style::new().with_bold(true));
stack.push(custom, false);
assert!(stack.get_style("repr.number").is_none());
assert!(stack.get_style("custom.style").is_some());
}
#[test]
fn test_theme_stack_pop_base_error() {
let mut stack = ThemeStack::new(Theme::new());
let result = stack.pop();
assert!(matches!(result, Err(ThemeError::PopBaseTheme)));
}
#[test]
fn test_default_styles_count() {
let styles = default_styles();
assert!(
styles.len() >= 100,
"Expected at least 100 default styles, got {}",
styles.len()
);
}
#[test]
fn test_default_styles_has_expected() {
let styles = default_styles();
let expected = [
"none",
"reset",
"bold",
"italic",
"repr.number",
"repr.str",
"repr.bool_true",
"markdown.h1",
"markdown.code",
"log.level",
"log.time",
"json.brace",
"json.key",
"table.header",
"table.cell",
"traceback.error",
"traceback.title",
"progress.spinner",
"progress.percentage",
];
for name in expected {
assert!(styles.contains_key(name), "Missing default style: {}", name);
}
}
#[test]
fn test_style_to_string_roundtrip() {
let style = Style::new().with_bold(true).with_color(Color::Standard(1));
let s = style.to_markup_string();
assert!(s.contains("bold"));
assert!(s.contains("red"));
let parsed = Style::parse(&s).unwrap();
assert_eq!(parsed.bold, Some(true));
assert_eq!(parsed.color, Some(Color::Standard(1)));
}
#[test]
fn test_theme_from_name() {
let default = Theme::from_name("default");
assert!(default.is_some());
assert!(default.unwrap().has_style("repr.number"));
let dracula = Theme::from_name("dracula");
assert!(dracula.is_some());
let dracula = dracula.unwrap();
assert!(dracula.has_style("repr.number"));
let style = dracula.get_style("repr.number").unwrap();
assert!(style.color.is_some());
let gruvbox = Theme::from_name("gruvbox-dark");
assert!(gruvbox.is_some());
let nord = Theme::from_name("nord");
assert!(nord.is_some());
assert!(Theme::from_name("nonexistent").is_none());
}
#[test]
fn test_theme_available_themes() {
let themes = Theme::available_themes();
assert!(themes.contains(&"default"));
assert!(themes.contains(&"dracula"));
assert!(themes.contains(&"gruvbox-dark"));
assert!(themes.contains(&"nord"));
}
}