diff --git a/Cargo.lock b/Cargo.lock index 7aa99bcb8ff..64920627aec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,6 +125,15 @@ version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arboard" version = "3.4.0" @@ -269,6 +278,7 @@ dependencies = [ "base64 0.22.1", "clap", "config", + "crossterm", "crypto_secretbox", "directories", "eyre", @@ -280,9 +290,11 @@ dependencies = [ "indicatif", "interim", "itertools 0.12.1", + "lazy_static", "log", "memchr", "minspan", + "palette", "pretty_assertions", "rand", "regex", @@ -299,6 +311,9 @@ dependencies = [ "shellexpand", "sql-builder", "sqlx", + "strum", + "strum_macros", + "testing_logger", "thiserror", "time", "tiny-bip39", @@ -638,6 +653,12 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.16.1" @@ -1341,6 +1362,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.1.0" @@ -2639,6 +2666,31 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "palette_derive", + "phf", + "serde", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.70", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -2719,6 +2771,48 @@ dependencies = [ "indexmap 2.2.6", ] +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.70", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.5" @@ -3700,6 +3794,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "sketches-ddsketch" version = "0.2.2" @@ -4129,6 +4229,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "testing_logger" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d92b727cb45d33ae956f7f46b966b25f1bc712092aeef9dba5ac798fc89f720" +dependencies = [ + "log", +] + [[package]] name = "thiserror" version = "1.0.61" diff --git a/crates/atuin-client/Cargo.toml b/crates/atuin-client/Cargo.toml index 79a2f0a647d..7e050f653a1 100644 --- a/crates/atuin-client/Cargo.toml +++ b/crates/atuin-client/Cargo.toml @@ -70,6 +70,14 @@ sha2 = { version = "0.10", optional = true } indicatif = "0.17.7" tiny-bip39 = "1" +# theme +crossterm = "0.27.0" +palette = { version = "0.7.5", features = ["serializing"] } +lazy_static = "1.4.0" +strum_macros = "0.26.3" +strum = { version = "0.26.2", features = ["strum_macros"] } + [dev-dependencies] tokio = { version = "1", features = ["full"] } pretty_assertions = { workspace = true } +testing_logger = "0.1.1" diff --git a/crates/atuin-client/config.toml b/crates/atuin-client/config.toml index bf3cff6877b..4ddd93f5df8 100644 --- a/crates/atuin-client/config.toml +++ b/crates/atuin-client/config.toml @@ -230,3 +230,19 @@ records = true ## The port that should be used for TCP on non unix systems # tcp_port = 8889 + +# [theme] +## Color theme to use for rendering in the terminal. +## There are some built-in themes, including the base theme which has the default colors, +## "autumn" and "marine". You can add your own themes to the "./themes" subdirectory of your +## Atuin config (or ATUIN_THEME_DIR, if provided) as TOML files whose keys should be one or +## more of AlertInfo, AlertWarn, AlertError, Annotation, Base, Guidance, Important, and +## the string values as lowercase entries from this list: +## https://ogeon.github.io/docs/palette/master/palette/named/index.html +## If you provide a custom theme file, it should be called "NAME.toml" and the theme below +## should be the stem, i.e. `theme = "NAME"` for your chosen NAME. +# name = "autumn" + +## Whether the theme manager should output normal or extra information to help fix themes. +## Boolean, true or false. If unset, left up to the theme manager. +# debug = true diff --git a/crates/atuin-client/src/lib.rs b/crates/atuin-client/src/lib.rs index a6842038a4e..d0f6ee7313d 100644 --- a/crates/atuin-client/src/lib.rs +++ b/crates/atuin-client/src/lib.rs @@ -20,5 +20,6 @@ pub mod record; pub mod register; pub mod secrets; pub mod settings; +pub mod theme; mod utils; diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index 05da636b17e..b64418cdc25 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -338,6 +338,18 @@ pub struct Preview { pub strategy: PreviewStrategy, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Theme { + /// Name of desired theme ("" for base) + pub name: String, + + /// Whether any available additional theme debug should be shown + pub debug: Option, + + /// How many levels of parenthood will be traversed if needed + pub max_depth: Option, +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Daemon { /// Use the daemon to sync @@ -366,6 +378,16 @@ impl Default for Preview { } } +impl Default for Theme { + fn default() -> Self { + Self { + name: "".to_string(), + debug: None::, + max_depth: Some(10), + } + } +} + impl Default for Daemon { fn default() -> Self { Self { @@ -458,6 +480,9 @@ pub struct Settings { #[serde(default)] pub daemon: Daemon, + + #[serde(default)] + pub theme: Theme, } impl Settings { @@ -727,6 +752,8 @@ impl Settings { .set_default("daemon.socket_path", socket_path.to_str())? .set_default("daemon.systemd_socket", false)? .set_default("daemon.tcp_port", 8889)? + .set_default("theme.name", "")? + .set_default("theme.debug", None::)? .set_default( "prefers_reduced_motion", std::env::var("NO_MOTION") diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs new file mode 100644 index 00000000000..f0e51cd79a6 --- /dev/null +++ b/crates/atuin-client/src/theme.rs @@ -0,0 +1,687 @@ +use config::{Config, File as ConfigFile, FileFormat}; +use lazy_static::lazy_static; +use log; +use palette::named; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::error; +use std::io::{Error, ErrorKind}; +use std::path::PathBuf; +use strum_macros; + +static DEFAULT_MAX_DEPTH: u8 = 10; + +// Collection of settable "meanings" that can have colors set. +// NOTE: You can add a new meaning here without breaking backwards compatibility but please: +// - update the atuin/docs repository, which has a list of available meanings +// - add a fallback in the MEANING_FALLBACKS below, so that themes which do not have it +// get a sensible fallback (see Title as an example) +#[derive( + Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display, +)] +#[strum(serialize_all = "camel_case")] +pub enum Meaning { + AlertInfo, + AlertWarn, + AlertError, + Annotation, + Base, + Guidance, + Important, + Title, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ThemeConfig { + // Definition of the theme + pub theme: ThemeDefinitionConfigBlock, + + // Colors + pub colors: HashMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ThemeDefinitionConfigBlock { + /// Name of theme ("" for base) + pub name: String, + + /// Whether any theme should be treated as a parent _if available_ + pub parent: Option, +} + +use crossterm::style::{Color, ContentStyle}; + +// For now, a theme is specifically a mapping of meanings to colors, but it may be desirable to +// expand that in the future to general styles. +pub struct Theme { + pub name: String, + pub parent: Option, + pub colors: HashMap, +} + +// Themes have a number of convenience functions for the most commonly used meanings. +// The general purpose `as_style` routine gives back a style, but for ease-of-use and to keep +// theme-related boilerplate minimal, the convenience functions give a color. +impl Theme { + // This is the base "default" color, for general text + pub fn get_base(&self) -> Color { + self.colors[&Meaning::Base] + } + + pub fn get_info(&self) -> Color { + self.get_alert(log::Level::Info) + } + + pub fn get_warning(&self) -> Color { + self.get_alert(log::Level::Warn) + } + + pub fn get_error(&self) -> Color { + self.get_alert(log::Level::Error) + } + + // The alert meanings may be chosen by the Level enum, rather than the methods above + // or the full Meaning enum, to simplify programmatic selection of a log-level. + pub fn get_alert(&self, severity: log::Level) -> Color { + self.colors[ALERT_TYPES.get(&severity).unwrap()] + } + + pub fn new(name: String, parent: Option, colors: HashMap) -> Theme { + Theme { + name, + parent, + colors, + } + } + + pub fn closest_meaning<'a>(&self, meaning: &'a Meaning) -> &'a Meaning { + if self.colors.contains_key(meaning) { + meaning + } else if MEANING_FALLBACKS.contains_key(meaning) { + self.closest_meaning(&MEANING_FALLBACKS[meaning]) + } else { + &Meaning::Base + } + } + + // General access - if you have a meaning, this will give you a (crossterm) style + pub fn as_style(&self, meaning: Meaning) -> ContentStyle { + ContentStyle { + foreground_color: Some(self.colors[self.closest_meaning(&meaning)]), + ..ContentStyle::default() + } + } + + // Turns a map of meanings to colornames into a theme + // If theme-debug is on, then we will print any colornames that we cannot load, + // but we do not have this on in general, as it could print unfiltered text to the terminal + // from a theme TOML file. However, it will always return a theme, falling back to + // defaults on error, so that a TOML file does not break loading + pub fn from_map( + name: String, + parent: Option<&Theme>, + colors: HashMap, + debug: bool, + ) -> Theme { + let colors: HashMap = colors + .iter() + .map(|(name, color)| { + ( + *name, + from_string(color).unwrap_or_else(|msg: String| { + if debug { + log::warn!("Could not load theme color: {} -> {}", msg, color); + } + Color::Grey + }), + ) + }) + .collect(); + make_theme(name, parent, &colors) + } +} + +// Use palette to get a color from a string name, if possible +fn from_string(name: &str) -> Result { + if name.is_empty() { + return Err("Empty string".into()); + } + if let Some(name) = name.strip_prefix('#') { + let hexcode = name; + let vec: Vec = hexcode + .chars() + .collect::>() + .chunks(2) + .map(|pair| u8::from_str_radix(pair.iter().collect::().as_str(), 16)) + .filter_map(|n| n.ok()) + .collect(); + if vec.len() != 3 { + return Err("Could not parse 3 hex values from string".into()); + } + Ok(Color::Rgb { + r: vec[0], + g: vec[1], + b: vec[2], + }) + } else { + let srgb = named::from_str(name).ok_or("No such color in palette")?; + Ok(Color::Rgb { + r: srgb.red, + g: srgb.green, + b: srgb.blue, + }) + } +} + +// For succinctness, if we are confident that the name will be known, +// this routine is available to keep the code readable +fn _from_known(name: &str) -> Color { + from_string(name).unwrap() +} + +// Boil down a meaning-color hashmap into a theme, by taking the defaults +// for any unknown colors +fn make_theme(name: String, parent: Option<&Theme>, overrides: &HashMap) -> Theme { + let colors = match parent { + Some(theme) => Box::new(theme.colors.clone()), + None => Box::new(HashMap::from([ + (Meaning::AlertError, Color::Red), + (Meaning::AlertWarn, Color::Yellow), + (Meaning::AlertInfo, Color::Green), + (Meaning::Annotation, Color::DarkGrey), + (Meaning::Guidance, Color::Blue), + (Meaning::Important, Color::White), + (Meaning::Base, Color::Grey), + ])), + } + .iter() + .map(|(name, color)| match overrides.get(name) { + Some(value) => (*name, *value), + None => (*name, *color), + }) + .collect(); + Theme::new(name, parent.map(|p| p.name.clone()), colors) +} + +// Built-in themes. Rather than having extra files added before any theming +// is available, this gives a couple of basic options, demonstrating the use +// of themes: autumn and marine +lazy_static! { + static ref ALERT_TYPES: HashMap = { + HashMap::from([ + (log::Level::Info, Meaning::AlertInfo), + (log::Level::Warn, Meaning::AlertWarn), + (log::Level::Error, Meaning::AlertError), + ]) + }; + static ref MEANING_FALLBACKS: HashMap = { + HashMap::from([ + (Meaning::Guidance, Meaning::AlertInfo), + (Meaning::Annotation, Meaning::AlertInfo), + (Meaning::Title, Meaning::Important), + ]) + }; + static ref BUILTIN_THEMES: HashMap<&'static str, Theme> = { + HashMap::from([ + ("", HashMap::new()), + ( + "autumn", + HashMap::from([ + (Meaning::AlertError, _from_known("saddlebrown")), + (Meaning::AlertWarn, _from_known("darkorange")), + (Meaning::AlertInfo, _from_known("gold")), + (Meaning::Annotation, Color::DarkGrey), + (Meaning::Guidance, _from_known("brown")), + ]), + ), + ( + "marine", + HashMap::from([ + (Meaning::AlertError, _from_known("yellowgreen")), + (Meaning::AlertWarn, _from_known("cyan")), + (Meaning::AlertInfo, _from_known("turquoise")), + (Meaning::Annotation, _from_known("steelblue")), + (Meaning::Base, _from_known("lightsteelblue")), + (Meaning::Guidance, _from_known("teal")), + ]), + ), + ]) + .iter() + .map(|(name, theme)| (*name, make_theme(name.to_string(), None, theme))) + .collect() + }; +} + +// To avoid themes being repeatedly loaded, we store them in a theme manager +pub struct ThemeManager { + loaded_themes: HashMap, + debug: bool, + override_theme_dir: Option, +} + +// Theme-loading logic +impl ThemeManager { + pub fn new(debug: Option, theme_dir: Option) -> Self { + Self { + loaded_themes: HashMap::new(), + debug: debug.unwrap_or(false), + override_theme_dir: match theme_dir { + Some(theme_dir) => Some(theme_dir), + None => std::env::var("ATUIN_THEME_DIR").ok(), + }, + } + } + + // Try to load a theme from a `{name}.toml` file in the theme directory. If an override is set + // for the theme dir (via ATUIN_THEME_DIR env) we should load the theme from there + pub fn load_theme_from_file( + &mut self, + name: &str, + max_depth: u8, + ) -> Result<&Theme, Box> { + let mut theme_file = if let Some(p) = &self.override_theme_dir { + if p.is_empty() { + return Err(Box::new(Error::new( + ErrorKind::NotFound, + "Empty theme directory override and could not find theme elsewhere", + ))); + } + PathBuf::from(p) + } else { + let config_dir = atuin_common::utils::config_dir(); + let mut theme_file = PathBuf::new(); + theme_file.push(config_dir); + theme_file.push("themes"); + theme_file + }; + + let theme_toml = format!["{}.toml", name]; + theme_file.push(theme_toml); + + let mut config_builder = Config::builder(); + + config_builder = config_builder.add_source(ConfigFile::new( + theme_file.to_str().unwrap(), + FileFormat::Toml, + )); + + let config = config_builder.build()?; + self.load_theme_from_config(name, config, max_depth) + } + + pub fn load_theme_from_config( + &mut self, + name: &str, + config: Config, + max_depth: u8, + ) -> Result<&Theme, Box> { + let debug = self.debug; + let theme_config: ThemeConfig = match config.try_deserialize() { + Ok(tc) => tc, + Err(e) => { + return Err(Box::new(Error::new( + ErrorKind::InvalidInput, + format!( + "Failed to deserialize theme: {}", + if debug { + e.to_string() + } else { + "set theme debug on for more info".to_string() + } + ), + ))) + } + }; + let colors: HashMap = theme_config.colors; + let parent: Option<&Theme> = match theme_config.theme.parent { + Some(parent_name) => { + if max_depth == 0 { + return Err(Box::new(Error::new( + ErrorKind::InvalidInput, + "Parent requested but we hit the recursion limit", + ))); + } + Some(self.load_theme(parent_name.as_str(), Some(max_depth - 1))) + } + None => None, + }; + + if debug && name != theme_config.theme.name { + log::warn!( + "Your theme config name is not the name of your loaded theme {} != {}", + name, + theme_config.theme.name + ); + } + + let theme = Theme::from_map(theme_config.theme.name, parent, colors, debug); + let name = name.to_string(); + self.loaded_themes.insert(name.clone(), theme); + let theme = self.loaded_themes.get(&name).unwrap(); + Ok(theme) + } + + // Check if the requested theme is loaded and, if not, then attempt to get it + // from the builtins or, if not there, from file + pub fn load_theme(&mut self, name: &str, max_depth: Option) -> &Theme { + if self.loaded_themes.contains_key(name) { + return self.loaded_themes.get(name).unwrap(); + } + let built_ins = &BUILTIN_THEMES; + match built_ins.get(name) { + Some(theme) => theme, + None => match self.load_theme_from_file(name, max_depth.unwrap_or(DEFAULT_MAX_DEPTH)) { + Ok(theme) => theme, + Err(err) => { + log::warn!("Could not load theme {}: {}", name, err); + built_ins.get("").unwrap() + } + }, + } + } +} + +#[cfg(test)] +mod theme_tests { + use super::*; + + #[test] + fn test_can_load_builtin_theme() { + let mut manager = ThemeManager::new(Some(false), Some("".to_string())); + let theme = manager.load_theme("autumn", None); + assert_eq!( + theme.as_style(Meaning::Guidance).foreground_color, + from_string("brown").ok() + ); + } + + #[test] + fn test_can_create_theme() { + let mut manager = ThemeManager::new(Some(false), Some("".to_string())); + let mytheme = Theme::new( + "mytheme".to_string(), + None, + HashMap::from([(Meaning::AlertError, _from_known("yellowgreen"))]), + ); + manager.loaded_themes.insert("mytheme".to_string(), mytheme); + let theme = manager.load_theme("mytheme", None); + assert_eq!( + theme.as_style(Meaning::AlertError).foreground_color, + from_string("yellowgreen").ok() + ); + } + + #[test] + fn test_can_fallback_when_meaning_missing() { + let mut manager = ThemeManager::new(Some(false), Some("".to_string())); + + // We use title as an example of a meaning that is not defined + // even in the base theme. + assert!(!BUILTIN_THEMES[""].colors.contains_key(&Meaning::Title)); + + let config = Config::builder() + .add_source(ConfigFile::from_str( + " + [theme] + name = \"title_theme\" + + [colors] + Guidance = \"white\" + AlertInfo = \"zomp\" + ", + FileFormat::Toml, + )) + .build() + .unwrap(); + let theme = manager + .load_theme_from_config("config_theme", config, 1) + .unwrap(); + + // Correctly picks overridden color. + assert_eq!( + theme.as_style(Meaning::Guidance).foreground_color, + from_string("white").ok() + ); + + // Falls back to grey as general "unknown" color. + assert_eq!( + theme.as_style(Meaning::AlertInfo).foreground_color, + Some(Color::Grey) + ); + + // Falls back to red as meaning missing from theme, so picks base default. + assert_eq!( + theme.as_style(Meaning::AlertError).foreground_color, + Some(Color::Red) + ); + + // Falls back to Important as Title not available. + assert_eq!( + theme.as_style(Meaning::Title).foreground_color, + theme.as_style(Meaning::Important).foreground_color, + ); + + let title_config = Config::builder() + .add_source(ConfigFile::from_str( + " + [theme] + name = \"title_theme\" + + [colors] + Title = \"white\" + AlertInfo = \"zomp\" + ", + FileFormat::Toml, + )) + .build() + .unwrap(); + let title_theme = manager + .load_theme_from_config("title_theme", title_config, 1) + .unwrap(); + + assert_eq!( + title_theme.as_style(Meaning::Title).foreground_color, + Some(Color::White) + ); + } + + #[test] + fn test_no_fallbacks_are_circular() { + let mytheme = Theme::new("mytheme".to_string(), None, HashMap::from([])); + MEANING_FALLBACKS + .iter() + .for_each(|pair| assert_eq!(mytheme.closest_meaning(pair.0), &Meaning::Base)) + } + + #[test] + fn test_can_get_colors_via_convenience_functions() { + let mut manager = ThemeManager::new(Some(true), Some("".to_string())); + let theme = manager.load_theme("", None); + assert_eq!(theme.get_error(), Color::Red); + assert_eq!(theme.get_warning(), Color::Yellow); + assert_eq!(theme.get_info(), Color::Green); + assert_eq!(theme.get_base(), Color::Grey); + assert_eq!(theme.get_alert(log::Level::Error), Color::Red) + } + + #[test] + fn test_can_use_parent_theme_for_fallbacks() { + testing_logger::setup(); + + let mut manager = ThemeManager::new(Some(false), Some("".to_string())); + + // First, we introduce a base theme + let solarized = Config::builder() + .add_source(ConfigFile::from_str( + " + [theme] + name = \"solarized\" + + [colors] + Guidance = \"white\" + AlertInfo = \"pink\" + ", + FileFormat::Toml, + )) + .build() + .unwrap(); + let solarized_theme = manager + .load_theme_from_config("solarized", solarized, 1) + .unwrap(); + + assert_eq!( + solarized_theme + .as_style(Meaning::AlertInfo) + .foreground_color, + from_string("pink").ok() + ); + + // Then we introduce a derived theme + let unsolarized = Config::builder() + .add_source(ConfigFile::from_str( + " + [theme] + name = \"unsolarized\" + parent = \"solarized\" + + [colors] + AlertInfo = \"red\" + ", + FileFormat::Toml, + )) + .build() + .unwrap(); + let unsolarized_theme = manager + .load_theme_from_config("unsolarized", unsolarized, 1) + .unwrap(); + + // It will take its own values + assert_eq!( + unsolarized_theme + .as_style(Meaning::AlertInfo) + .foreground_color, + from_string("red").ok() + ); + + // ...or fall back to the parent + assert_eq!( + unsolarized_theme + .as_style(Meaning::Guidance) + .foreground_color, + from_string("white").ok() + ); + + testing_logger::validate(|captured_logs| assert_eq!(captured_logs.len(), 0)); + + // If the parent is not found, we end up with the base theme colors + let nunsolarized = Config::builder() + .add_source(ConfigFile::from_str( + " + [theme] + name = \"nunsolarized\" + parent = \"nonsolarized\" + + [colors] + AlertInfo = \"red\" + ", + FileFormat::Toml, + )) + .build() + .unwrap(); + let nunsolarized_theme = manager + .load_theme_from_config("nunsolarized", nunsolarized, 1) + .unwrap(); + + assert_eq!( + nunsolarized_theme + .as_style(Meaning::Guidance) + .foreground_color, + Some(Color::Blue) + ); + + testing_logger::validate(|captured_logs| { + assert_eq!(captured_logs.len(), 1); + assert_eq!(captured_logs[0].body, + "Could not load theme nonsolarized: Empty theme directory override and could not find theme elsewhere" + ); + assert_eq!(captured_logs[0].level, log::Level::Warn) + }); + } + + #[test] + fn test_can_debug_theme() { + testing_logger::setup(); + [true, false].iter().for_each(|debug| { + let mut manager = ThemeManager::new(Some(*debug), Some("".to_string())); + let config = Config::builder() + .add_source(ConfigFile::from_str( + " + [theme] + name = \"mytheme\" + + [colors] + Guidance = \"white\" + AlertInfo = \"xinetic\" + ", + FileFormat::Toml, + )) + .build() + .unwrap(); + manager + .load_theme_from_config("config_theme", config, 1) + .unwrap(); + testing_logger::validate(|captured_logs| { + if *debug { + assert_eq!(captured_logs.len(), 2); + assert_eq!( + captured_logs[0].body, + "Your theme config name is not the name of your loaded theme config_theme != mytheme" + ); + assert_eq!(captured_logs[0].level, log::Level::Warn); + assert_eq!( + captured_logs[1].body, + "Could not load theme color: No such color in palette -> xinetic" + ); + assert_eq!(captured_logs[1].level, log::Level::Warn) + } else { + assert_eq!(captured_logs.len(), 0) + } + }) + }) + } + + #[test] + fn test_can_parse_color_strings_correctly() { + assert_eq!( + from_string("brown").unwrap(), + Color::Rgb { + r: 165, + g: 42, + b: 42 + } + ); + + assert_eq!(from_string(""), Err("Empty string".into())); + + ["manatee", "caput mortuum", "123456"] + .iter() + .for_each(|inp| { + assert_eq!(from_string(inp), Err("No such color in palette".into())); + }); + + assert_eq!( + from_string("#ff1122").unwrap(), + Color::Rgb { + r: 255, + g: 17, + b: 34 + } + ); + ["#1122", "#ffaa112", "#brown"].iter().for_each(|inp| { + assert_eq!( + from_string(inp), + Err("Could not parse 3 hex values from string".into()) + ); + }); + } +} diff --git a/crates/atuin-history/src/stats.rs b/crates/atuin-history/src/stats.rs index b73a5dbbb5a..92e08340681 100644 --- a/crates/atuin-history/src/stats.rs +++ b/crates/atuin-history/src/stats.rs @@ -1,10 +1,10 @@ use std::collections::{HashMap, HashSet}; -use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor}; +use crossterm::style::{ResetColor, SetAttribute, SetForegroundColor}; use serde::{Deserialize, Serialize}; use unicode_segmentation::UnicodeSegmentation; -use atuin_client::{history::History, settings::Settings}; +use atuin_client::{history::History, settings::Settings, theme::Theme}; #[derive(Debug, Serialize, Deserialize)] pub struct Stats { @@ -109,7 +109,7 @@ fn split_at_pipe(command: &str) -> Vec<&str> { result } -pub fn pretty_print(stats: Stats, ngram_size: usize) { +pub fn pretty_print(stats: Stats, ngram_size: usize, theme: &Theme) { let max = stats.top.iter().map(|x| x.1).max().unwrap(); let num_pad = max.ilog10() as usize + 1; @@ -126,21 +126,21 @@ pub fn pretty_print(stats: Stats, ngram_size: usize) { }); for (command, count) in stats.top { - let gray = SetForegroundColor(Color::Grey); + let gray = SetForegroundColor(theme.get_base()); let bold = SetAttribute(crossterm::style::Attribute::Bold); let in_ten = 10 * count / max; print!("["); - print!("{}", SetForegroundColor(Color::Red)); + print!("{}", SetForegroundColor(theme.get_error())); for i in 0..in_ten { if i == 2 { - print!("{}", SetForegroundColor(Color::Yellow)); + print!("{}", SetForegroundColor(theme.get_warning())); } if i == 5 { - print!("{}", SetForegroundColor(Color::Green)); + print!("{}", SetForegroundColor(theme.get_info())); } print!("▮"); diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index a129e6ac508..ce10120150d 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -3,7 +3,9 @@ use std::path::PathBuf; use clap::Subcommand; use eyre::{Result, WrapErr}; -use atuin_client::{database::Sqlite, record::sqlite_store::SqliteStore, settings::Settings}; +use atuin_client::{ + database::Sqlite, record::sqlite_store::SqliteStore, settings::Settings, theme, +}; use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*}; #[cfg(feature = "sync")] @@ -94,14 +96,19 @@ impl Cmd { .unwrap(); let settings = Settings::new().wrap_err("could not load client settings")?; - let res = runtime.block_on(self.run_inner(settings)); + let theme_manager = theme::ThemeManager::new(settings.theme.debug, None); + let res = runtime.block_on(self.run_inner(settings, theme_manager)); runtime.shutdown_timeout(std::time::Duration::from_millis(50)); res } - async fn run_inner(self, mut settings: Settings) -> Result<()> { + async fn run_inner( + self, + mut settings: Settings, + mut theme_manager: theme::ThemeManager, + ) -> Result<()> { let filter = EnvFilter::from_env("ATUIN_LOG").add_directive("sqlx_sqlite::regexp=off".parse()?); @@ -127,10 +134,13 @@ impl Cmd { let db = Sqlite::new(db_path, settings.local_timeout).await?; let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?; + let theme_name = settings.theme.name.clone(); + let theme = theme_manager.load_theme(theme_name.as_str(), settings.theme.max_depth); + match self { Self::Import(import) => import.run(&db).await, - Self::Stats(stats) => stats.run(&db, &settings).await, - Self::Search(search) => search.run(db, &mut settings, sqlite_store).await, + Self::Stats(stats) => stats.run(&db, &settings, theme).await, + Self::Search(search) => search.run(db, &mut settings, sqlite_store, theme).await, #[cfg(feature = "sync")] Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await, diff --git a/crates/atuin/src/command/client/search.rs b/crates/atuin/src/command/client/search.rs index f3626afe426..12e73458ffd 100644 --- a/crates/atuin/src/command/client/search.rs +++ b/crates/atuin/src/command/client/search.rs @@ -11,6 +11,7 @@ use atuin_client::{ history::{store::HistoryStore, History}, record::sqlite_store::SqliteStore, settings::{FilterMode, KeymapMode, SearchMode, Settings, Timezone}, + theme::Theme, }; use super::history::ListMode; @@ -130,6 +131,7 @@ impl Cmd { db: impl Database, settings: &mut Settings, store: SqliteStore, + theme: &Theme, ) -> Result<()> { let query = self.query.map_or_else( || { @@ -196,7 +198,7 @@ impl Cmd { let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); if self.interactive { - let item = interactive::history(&query, settings, db, &history_store).await?; + let item = interactive::history(&query, settings, db, &history_store, theme).await?; if stderr().is_terminal() { eprintln!("{}", item.escape_control()); } else { diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs index e27d0ce262b..87f803aa46f 100644 --- a/crates/atuin/src/command/client/search/history_list.rs +++ b/crates/atuin/src/command/client/search/history_list.rs @@ -1,11 +1,14 @@ use std::time::Duration; -use atuin_client::history::History; +use atuin_client::{ + history::History, + theme::{Meaning, Theme}, +}; use atuin_common::utils::Escapable as _; use ratatui::{ buffer::Buffer, layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, widgets::{Block, StatefulWidget, Widget}, }; use time::OffsetDateTime; @@ -19,6 +22,7 @@ pub struct HistoryList<'a> { /// Apply an alternative highlighting to the selected row alternate_highlight: bool, now: &'a dyn Fn() -> OffsetDateTime, + theme: &'a Theme, } #[derive(Default)] @@ -70,6 +74,7 @@ impl<'a> StatefulWidget for HistoryList<'a> { inverted: self.inverted, alternate_highlight: self.alternate_highlight, now: &self.now, + theme: self.theme, }; for item in self.history.iter().skip(state.offset).take(end - start) { @@ -91,6 +96,7 @@ impl<'a> HistoryList<'a> { inverted: bool, alternate_highlight: bool, now: &'a dyn Fn() -> OffsetDateTime, + theme: &'a Theme, ) -> Self { Self { history, @@ -98,6 +104,7 @@ impl<'a> HistoryList<'a> { inverted, alternate_highlight, now, + theme, } } @@ -130,6 +137,7 @@ struct DrawState<'a> { inverted: bool, alternate_highlight: bool, now: &'a dyn Fn() -> OffsetDateTime, + theme: &'a Theme, } // longest line prefix I could come up with @@ -151,18 +159,18 @@ impl DrawState<'_> { } fn duration(&mut self, h: &History) { - let status = Style::default().fg(if h.success() { - Color::Green + let status = self.theme.as_style(if h.success() { + Meaning::AlertInfo } else { - Color::Red + Meaning::AlertError }); let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0)); - self.draw(&format_duration(duration), status); + self.draw(&format_duration(duration), status.into()); } #[allow(clippy::cast_possible_truncation)] // we know that time.len() will be <6 fn time(&mut self, h: &History) { - let style = Style::default().fg(Color::Blue); + let style = self.theme.as_style(Meaning::Guidance); // Account for the chance that h.timestamp is "in the future" // This would mean that "since" is negative, and the unwrap here @@ -178,26 +186,27 @@ impl DrawState<'_> { usize::from(PREFIX_LENGTH).saturating_sub(usize::from(self.x) + 4 + time.len()); self.draw(&SPACES[..padding], Style::default()); - self.draw(&time, style); - self.draw(" ago", style); + self.draw(&time, style.into()); + self.draw(" ago", style.into()); } fn command(&mut self, h: &History) { - let mut style = Style::default(); + let mut style = self.theme.as_style(Meaning::Base); if !self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected) { // if not applying alternative highlighting to the whole row, color the command - style = style.fg(Color::Red).add_modifier(Modifier::BOLD); + style = self.theme.as_style(Meaning::AlertError); + style.attributes.set(crossterm::style::Attribute::Bold); } for section in h.command.escape_control().split_ascii_whitespace() { - self.draw(" ", style); + self.draw(" ", style.into()); if self.x > self.list_area.width { // Avoid attempting to draw a command section beyond the width // of the list return; } - self.draw(section, style); + self.draw(section, style.into()); } } diff --git a/crates/atuin/src/command/client/search/inspector.rs b/crates/atuin/src/command/client/search/inspector.rs index 060b4df692e..05a0fe2173a 100644 --- a/crates/atuin/src/command/client/search/inspector.rs +++ b/crates/atuin/src/command/client/search/inspector.rs @@ -16,6 +16,7 @@ use ratatui::{ use super::duration::format_duration; +use super::super::theme::{Meaning, Theme}; use super::interactive::{InputAction, State}; #[allow(clippy::cast_sign_loss)] @@ -27,7 +28,13 @@ fn u64_or_zero(num: i64) -> u64 { } } -pub fn draw_commands(f: &mut Frame<'_>, parent: Rect, history: &History, stats: &HistoryStats) { +pub fn draw_commands( + f: &mut Frame<'_>, + parent: Rect, + history: &History, + stats: &HistoryStats, + theme: &Theme, +) { let commands = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -41,6 +48,7 @@ pub fn draw_commands(f: &mut Frame<'_>, parent: Rect, history: &History, stats: Block::new() .borders(Borders::ALL) .title("Command") + .style(theme.as_style(Meaning::Base)) .padding(Padding::horizontal(1)), ); @@ -54,6 +62,7 @@ pub fn draw_commands(f: &mut Frame<'_>, parent: Rect, history: &History, stats: Block::new() .borders(Borders::ALL) .title("Previous command") + .style(theme.as_style(Meaning::Annotation)) .padding(Padding::horizontal(1)), ); @@ -67,6 +76,7 @@ pub fn draw_commands(f: &mut Frame<'_>, parent: Rect, history: &History, stats: Block::new() .borders(Borders::ALL) .title("Next command") + .style(theme.as_style(Meaning::Annotation)) .padding(Padding::horizontal(1)), ); @@ -75,7 +85,13 @@ pub fn draw_commands(f: &mut Frame<'_>, parent: Rect, history: &History, stats: f.render_widget(next, commands[2]); } -pub fn draw_stats_table(f: &mut Frame<'_>, parent: Rect, history: &History, stats: &HistoryStats) { +pub fn draw_stats_table( + f: &mut Frame<'_>, + parent: Rect, + history: &History, + stats: &HistoryStats, + theme: &Theme, +) { let duration = Duration::from_nanos(u64_or_zero(history.duration)); let avg_duration = Duration::from_nanos(stats.average_duration); @@ -98,6 +114,7 @@ pub fn draw_stats_table(f: &mut Frame<'_>, parent: Rect, history: &History, stat Block::default() .title("Command stats") .borders(Borders::ALL) + .style(theme.as_style(Meaning::Base)) .padding(Padding::vertical(1)), ); @@ -144,7 +161,7 @@ fn sort_duration_over_time(durations: &[(String, i64)]) -> Vec<(String, i64)> { .collect() } -fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) { +fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats, theme: &Theme) { let exits: Vec = stats .exits .iter() @@ -159,6 +176,7 @@ fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) { .block( Block::default() .title("Exit code distribution") + .style(theme.as_style(Meaning::Base)) .borders(Borders::ALL), ) .bar_width(3) @@ -179,7 +197,12 @@ fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) { .collect(); let day_of_week = BarChart::default() - .block(Block::default().title("Runs per day").borders(Borders::ALL)) + .block( + Block::default() + .title("Runs per day") + .style(theme.as_style(Meaning::Base)) + .borders(Borders::ALL), + ) .bar_width(3) .bar_gap(1) .bar_style(Style::default()) @@ -203,6 +226,7 @@ fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) { .block( Block::default() .title("Duration over time") + .style(theme.as_style(Meaning::Base)) .borders(Borders::ALL), ) .bar_width(5) @@ -226,7 +250,13 @@ fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) { f.render_widget(duration_over_time, layout[2]); } -pub fn draw(f: &mut Frame<'_>, chunk: Rect, history: &History, stats: &HistoryStats) { +pub fn draw( + f: &mut Frame<'_>, + chunk: Rect, + history: &History, + stats: &HistoryStats, + theme: &Theme, +) { let vert_layout = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)]) @@ -237,9 +267,9 @@ pub fn draw(f: &mut Frame<'_>, chunk: Rect, history: &History, stats: &HistorySt .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) .split(vert_layout[1]); - draw_commands(f, vert_layout[0], history, stats); - draw_stats_table(f, stats_layout[0], history, stats); - draw_stats_charts(f, stats_layout[1], stats); + draw_commands(f, vert_layout[0], history, stats, theme); + draw_stats_table(f, stats_layout[0], history, stats, theme); + draw_stats_charts(f, stats_layout[1], stats, theme); } // I'm going to break this out more, but just starting to move things around before changing diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index e323b76d9f1..825e978ad97 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -33,13 +33,14 @@ use super::{ history_list::{HistoryList, ListState, PREFIX_LENGTH}, }; +use crate::command::client::theme::{Meaning, Theme}; use crate::{command::client::search::engines, VERSION}; use ratatui::{ backend::CrosstermBackend, layout::{Alignment, Constraint, Direction, Layout}, prelude::*, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span, Text}, widgets::{block::Title, Block, BorderType, Borders, Padding, Paragraph, Tabs}, Frame, Terminal, TerminalOptions, Viewport, @@ -598,6 +599,7 @@ impl State { results: &[History], stats: Option, settings: &Settings, + theme: &Theme, ) { let compact = match settings.style { atuin_client::settings::Style::Auto => f.size().height < 14, @@ -622,7 +624,7 @@ impl State { .direction(Direction::Vertical) .margin(0) .horizontal_margin(1) - .constraints( + .constraints::<&[Constraint]>( if invert { [ Constraint::Length(1 + border_size), // input @@ -671,7 +673,7 @@ impl State { let header_chunks = Layout::default() .direction(Direction::Horizontal) - .constraints( + .constraints::<&[Constraint]>( [ Constraint::Ratio(1, 5), Constraint::Ratio(3, 5), @@ -681,19 +683,19 @@ impl State { ) .split(header_chunk); - let title = self.build_title(); + let title = self.build_title(theme); f.render_widget(title, header_chunks[0]); - let help = self.build_help(settings); + let help = self.build_help(settings, theme); f.render_widget(help, header_chunks[1]); - let stats_tab = self.build_stats(); + let stats_tab = self.build_stats(theme); f.render_widget(stats_tab, header_chunks[2]); match self.tab_index { 0 => { let results_list = - Self::build_results_list(style, results, self.keymap_mode, &self.now); + Self::build_results_list(style, results, self.keymap_mode, &self.now, theme); f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state); } @@ -716,6 +718,7 @@ impl State { results_list_chunk, &results[self.results_state.selected()], &stats.expect("Drawing inspector, but no stats"), + theme, ); } @@ -740,8 +743,13 @@ impl State { } else { preview_width - 2 }; - let preview = - self.build_preview(results, compact, preview_width, preview_chunk.width.into()); + let preview = self.build_preview( + results, + compact, + preview_width, + preview_chunk.width.into(), + theme, + ); f.render_widget(preview, preview_chunk); let extra_width = UnicodeWidthStr::width(self.search.input.substring()); @@ -754,23 +762,27 @@ impl State { ); } - fn build_title(&mut self) -> Paragraph { + fn build_title(&mut self, theme: &Theme) -> Paragraph { let title = if self.update_needed.is_some() { Paragraph::new(Text::from(Span::styled( format!("Atuin v{VERSION} - UPGRADE"), - Style::default().add_modifier(Modifier::BOLD).fg(Color::Red), + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme.get_error().into()), ))) } else { Paragraph::new(Text::from(Span::styled( format!("Atuin v{VERSION}"), - Style::default().add_modifier(Modifier::BOLD), + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme.get_base().into()), ))) }; title.alignment(Alignment::Left) } #[allow(clippy::unused_self)] - fn build_help(&self, settings: &Settings) -> Paragraph { + fn build_help(&self, settings: &Settings, theme: &Theme) -> Paragraph { match self.tab_index { // search 0 => Paragraph::new(Text::from(Line::from(vec![ @@ -804,16 +816,16 @@ impl State { _ => unreachable!("invalid tab index"), } - .style(Style::default().fg(Color::DarkGray)) + .style(theme.as_style(Meaning::Annotation)) .alignment(Alignment::Center) } - fn build_stats(&mut self) -> Paragraph { + fn build_stats(&mut self, theme: &Theme) -> Paragraph { let stats = Paragraph::new(Text::from(Span::raw(format!( "history count: {}", self.history_count, )))) - .style(Style::default().fg(Color::DarkGray)) + .style(theme.as_style(Meaning::Annotation)) .alignment(Alignment::Right); stats } @@ -823,12 +835,14 @@ impl State { results: &'a [History], keymap_mode: KeymapMode, now: &'a dyn Fn() -> OffsetDateTime, + theme: &'a Theme, ) -> HistoryList<'a> { let results_list = HistoryList::new( results, style.invert, keymap_mode == KeymapMode::VimNormal, now, + theme, ); if style.compact { @@ -886,6 +900,7 @@ impl State { compact: bool, preview_width: u16, chunk_width: usize, + theme: &Theme, ) -> Paragraph { let selected = self.results_state.selected(); let command = if results.is_empty() { @@ -905,7 +920,7 @@ impl State { .join("\n") }; let preview = if compact { - Paragraph::new(command).style(Style::default().fg(Color::DarkGray)) + Paragraph::new(command).style(theme.as_style(Meaning::Annotation)) } else { Paragraph::new(command).block( Block::default() @@ -993,6 +1008,7 @@ pub async fn history( settings: &Settings, mut db: impl Database, history_store: &HistoryStore, + theme: &Theme, ) -> Result { let stdout = Stdout::new(settings.inline_height > 0)?; let backend = CrosstermBackend::new(stdout); @@ -1069,7 +1085,7 @@ pub async fn history( let mut stats: Option = None; let accept; let result = 'render: loop { - terminal.draw(|f| app.draw(f, &results, stats.clone(), settings))?; + terminal.draw(|f| app.draw(f, &results, stats.clone(), settings, theme))?; let initial_input = app.search.input.as_str().to_owned(); let initial_filter_mode = app.search.filter_mode; @@ -1103,7 +1119,7 @@ pub async fn history( }, InputAction::Redraw => { terminal.clear()?; - terminal.draw(|f| app.draw(f, &results, stats.clone(), settings))?; + terminal.draw(|f| app.draw(f, &results, stats.clone(), settings, theme))?; }, r => { accept = app.accept; diff --git a/crates/atuin/src/command/client/stats.rs b/crates/atuin/src/command/client/stats.rs index 2de70d1d17a..aa931085f45 100644 --- a/crates/atuin/src/command/client/stats.rs +++ b/crates/atuin/src/command/client/stats.rs @@ -6,6 +6,7 @@ use time::{Duration, OffsetDateTime, Time}; use atuin_client::{ database::{current_context, Database}, settings::Settings, + theme::Theme, }; use atuin_history::stats::{compute, pretty_print}; @@ -26,7 +27,7 @@ pub struct Cmd { } impl Cmd { - pub async fn run(&self, db: &impl Database, settings: &Settings) -> Result<()> { + pub async fn run(&self, db: &impl Database, settings: &Settings, theme: &Theme) -> Result<()> { let context = current_context(); let words = if self.period.is_empty() { String::from("all") @@ -64,7 +65,7 @@ impl Cmd { let stats = compute(settings, &history, self.count, self.ngram_size); if let Some(stats) = stats { - pretty_print(stats, self.ngram_size); + pretty_print(stats, self.ngram_size, theme); } Ok(())