From 34d84d758496a441386a9c449d475a122fb754bd Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Mon, 18 Mar 2024 12:02:32 +0100 Subject: [PATCH 01/18] wip: add theme --- Cargo.lock | 2 ++ crates/atuin/Cargo.toml | 2 ++ crates/atuin/src/command/client.rs | 3 ++ crates/atuin/src/command/client/theme.rs | 41 ++++++++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 crates/atuin/src/command/client/theme.rs diff --git a/Cargo.lock b/Cargo.lock index 7aa99bcb8ff..5630e1797a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -247,6 +247,8 @@ dependencies = [ "semver", "serde", "serde_json", + "strum", + "strum_macros", "sysinfo", "time", "tiny-bip39", diff --git a/crates/atuin/Cargo.toml b/crates/atuin/Cargo.toml index fa1cadd93cd..ce14416f738 100644 --- a/crates/atuin/Cargo.toml +++ b/crates/atuin/Cargo.toml @@ -85,6 +85,8 @@ uuid = { workspace = true } unicode-segmentation = "1.11.0" sysinfo = "0.30.7" regex="1.10.5" +strum_macros = "0.26.3" +strum = { version = "0.26.2", features = ["strum_macros"] } [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] arboard = { version = "3.4", optional = true } diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index a129e6ac508..712f29f9495 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -26,6 +26,7 @@ mod kv; mod search; mod stats; mod store; +mod theme; #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] @@ -127,6 +128,8 @@ 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 = theme::load_theme(settings.theme); + match self { Self::Import(import) => import.run(&db).await, Self::Stats(stats) => stats.run(&db, &settings).await, diff --git a/crates/atuin/src/command/client/theme.rs b/crates/atuin/src/command/client/theme.rs new file mode 100644 index 00000000000..8417e465f48 --- /dev/null +++ b/crates/atuin/src/command/client/theme.rs @@ -0,0 +1,41 @@ +use strum_macros; +use std::collections::HashMap; + +#[derive(Hash, Debug, Eq, PartialEq, strum_macros::Display)] +#[strum(serialize_all = "snake_case")] +pub enum Meaning { + Alert { + severity: usize, + }, + Annotation, + Base, + Guidance, + Important, +} + +use ratatui::{ + style::{Color} +}; + +pub struct Theme { + colors: HashMap:: +} + +impl Theme { + pub fn new(colors: HashMap::) -> Theme { + Theme { colors } + } +} + +pub fn load_theme(_name: String) -> Theme { + let default_theme = HashMap::from([ + (Meaning::Alert { severity: 3 }, Color::Red), + (Meaning::Alert { severity: 2 }, Color::Yellow), + (Meaning::Alert { severity: 1 }, Color::Green), + (Meaning::Annotation, Color::DarkGray), + (Meaning::Guidance, Color::Blue), + (Meaning::Important, Color::White), + (Meaning::Base, Color::Gray), + ]); + Theme::new(default_theme) +} From ce7ca0b0632165b510e11f5566c8358c8c25054b Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Tue, 19 Mar 2024 23:18:38 -0700 Subject: [PATCH 02/18] feat(theme): basic theming approach --- Cargo.lock | 129 +++++++++++++++++- crates/atuin-client/Cargo.toml | 7 + crates/atuin-client/src/lib.rs | 1 + crates/atuin-client/src/settings.rs | 2 + crates/atuin-client/src/theme.rs | 110 +++++++++++++++ crates/atuin-history/src/stats.rs | 18 +-- crates/atuin/Cargo.toml | 2 - crates/atuin/src/command/client.rs | 9 +- crates/atuin/src/command/client/search.rs | 4 +- .../src/command/client/search/history_list.rs | 35 +++-- .../src/command/client/search/inspector.rs | 10 +- .../src/command/client/search/interactive.rs | 16 ++- crates/atuin/src/command/client/stats.rs | 5 +- crates/atuin/src/command/client/theme.rs | 41 ------ 14 files changed, 304 insertions(+), 85 deletions(-) create mode 100644 crates/atuin-client/src/theme.rs delete mode 100644 crates/atuin/src/command/client/theme.rs diff --git a/Cargo.lock b/Cargo.lock index 5630e1797a8..2d1925314b9 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" @@ -247,8 +256,6 @@ dependencies = [ "semver", "serde", "serde_json", - "strum", - "strum_macros", "sysinfo", "time", "tiny-bip39", @@ -271,6 +278,7 @@ dependencies = [ "base64 0.22.1", "clap", "config", + "crossterm", "crypto_secretbox", "directories", "eyre", @@ -282,9 +290,11 @@ dependencies = [ "indicatif", "interim", "itertools 0.12.1", + "lazy_static", "log", "memchr", "minspan", + "palette", "pretty_assertions", "rand", "regex", @@ -301,6 +311,8 @@ dependencies = [ "shellexpand", "sql-builder", "sqlx", + "strum 0.25.0", + "strum_macros 0.25.3", "thiserror", "time", "tiny-bip39", @@ -640,6 +652,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" @@ -1343,6 +1361,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" @@ -2641,6 +2665,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" @@ -2721,6 +2770,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" @@ -3039,8 +3130,8 @@ dependencies = [ "lru", "paste", "stability", - "strum", - "strum_macros", + "strum 0.26.3", + "strum_macros 0.26.4", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -3702,6 +3793,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" @@ -4011,13 +4108,35 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros 0.25.3", +] + [[package]] name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.70", ] [[package]] diff --git a/crates/atuin-client/Cargo.toml b/crates/atuin-client/Cargo.toml index 79a2f0a647d..d28c5c5528c 100644 --- a/crates/atuin-client/Cargo.toml +++ b/crates/atuin-client/Cargo.toml @@ -69,6 +69,13 @@ hex = { version = "0.4", optional = true } sha2 = { version = "0.10", optional = true } indicatif = "0.17.7" tiny-bip39 = "1" +palette = { version = "0.7.5", features = ["serializing"] } +lazy_static = "1.4.0" + +# theme +crossterm = "0.27.0" +strum_macros = "0.25.3" +strum = { version = "0.25.0", features = ["strum_macros"] } [dev-dependencies] tokio = { version = "1", features = ["full"] } 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..4b571e24bc8 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -424,6 +424,7 @@ pub struct Settings { pub history_format: String, pub prefers_reduced_motion: bool, pub store_failed: bool, + pub theme: String, #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)] pub history_filter: RegexSet, @@ -734,6 +735,7 @@ impl Settings { .map(|_| config::Value::new(None, config::ValueKind::Boolean(true))) .unwrap_or_else(|| config::Value::new(None, config::ValueKind::Boolean(false))), )? + .set_default("theme", "")? .add_source( Environment::with_prefix("atuin") .prefix_separator("_") diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs new file mode 100644 index 00000000000..e4bfaf16e10 --- /dev/null +++ b/crates/atuin-client/src/theme.rs @@ -0,0 +1,110 @@ +use strum_macros; +use std::collections::HashMap; +use palette::named; +use lazy_static::lazy_static; + +#[derive(Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display)] +#[strum(serialize_all = "snake_case")] +pub enum Level { + Info, + Warning, + Error, +} + +#[derive(Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display)] +#[strum(serialize_all = "snake_case")] +pub enum Meaning { + Alert { + severity: Level, + }, + Annotation, + Base, + Guidance, + Important, +} + +use crossterm::{ + style::{Color, ContentStyle}, +}; + +pub struct Theme { + pub colors: HashMap:: +} + +impl Theme { + pub fn get_base(&self) -> Color { + self.colors[&Meaning::Base] + } + + pub fn get_info(&self) -> Color { + self.get_alert(Level::Info) + } + + pub fn get_warning(&self) -> Color { + self.get_alert(Level::Warning) + } + + pub fn get_error(&self) -> Color { + self.get_alert(Level::Error) + } + + pub fn get_alert(&self, severity: Level) -> Color { + self.colors[&Meaning::Alert { severity: severity }] + } + + pub fn new(colors: HashMap::) -> Theme { + Theme { colors } + } + + pub fn as_style(&self, meaning: Meaning) -> ContentStyle { + let mut style = ContentStyle::default(); + style.foreground_color = Some(self.colors[&meaning]); + style + } +} + +fn from_named(name: &str) -> Color { + let srgb = named::from_str(name).unwrap(); + Color::Rgb { + r: srgb.red, + g: srgb.green, + b: srgb.blue, + } +} + +lazy_static! { + static ref BUILTIN_THEMES: HashMap<&'static str, HashMap> = { + HashMap::from([ + ("autumn", HashMap::from([ + (Meaning::Alert { severity: Level::Error }, from_named("saddlebrown")), + (Meaning::Alert { severity: Level::Warning }, from_named("darkorange")), + (Meaning::Alert { severity: Level::Info }, from_named("gold")), + (Meaning::Annotation, Color::DarkGrey), + (Meaning::Guidance, from_named("khaki")), + ])) + ]) + }; +} + +pub fn load_theme(name: &str) -> Theme { + let mut default_theme = HashMap::from([ + (Meaning::Alert { severity: Level::Error }, Color::Red), + (Meaning::Alert { severity: Level::Warning }, Color::Yellow), + (Meaning::Alert { severity: Level::Info }, Color::Green), + (Meaning::Annotation, Color::DarkGrey), + (Meaning::Guidance, Color::Blue), + (Meaning::Important, Color::White), + (Meaning::Base, Color::Grey), + ]); + let built_ins = &BUILTIN_THEMES; + let theme = match built_ins.get(name) { + Some(theme) => { + theme.iter().for_each(|(k, v)| { + default_theme.insert(*k, *v); + }); + default_theme + }, + None => default_theme + }; + Theme::new(theme) +} diff --git a/crates/atuin-history/src/stats.rs b/crates/atuin-history/src/stats.rs index b73a5dbbb5a..295651e8ecd 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().into()); let bold = SetAttribute(crossterm::style::Attribute::Bold); let in_ten = 10 * count / max; print!("["); - print!("{}", SetForegroundColor(Color::Red)); + print!("{}", SetForegroundColor(theme.get_error().into())); for i in 0..in_ten { if i == 2 { - print!("{}", SetForegroundColor(Color::Yellow)); + print!("{}", SetForegroundColor(theme.get_warning().into())); } if i == 5 { - print!("{}", SetForegroundColor(Color::Green)); + print!("{}", SetForegroundColor(theme.get_info().into())); } print!("▮"); @@ -225,6 +225,7 @@ pub fn compute( mod tests { use atuin_client::history::History; use atuin_client::settings::Settings; + use crate::command::client::theme::ThemeManager; use time::OffsetDateTime; use super::compute; @@ -248,7 +249,8 @@ mod tests { .into(), ]; - let stats = compute(&settings, &history, 10, 1).expect("failed to compute stats"); + let mut theme = ThemeManager::new().load_theme(""); + let stats = compute(&settings, &history, 10, 1, &theme).expect("failed to compute stats"); assert_eq!(stats.total_commands, 1); assert_eq!(stats.unique_commands, 1); } diff --git a/crates/atuin/Cargo.toml b/crates/atuin/Cargo.toml index ce14416f738..fa1cadd93cd 100644 --- a/crates/atuin/Cargo.toml +++ b/crates/atuin/Cargo.toml @@ -85,8 +85,6 @@ uuid = { workspace = true } unicode-segmentation = "1.11.0" sysinfo = "0.30.7" regex="1.10.5" -strum_macros = "0.26.3" -strum = { version = "0.26.2", features = ["strum_macros"] } [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] arboard = { version = "3.4", optional = true } diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index 712f29f9495..11984de1d55 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -3,7 +3,7 @@ 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")] @@ -26,7 +26,6 @@ mod kv; mod search; mod stats; mod store; -mod theme; #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] @@ -128,12 +127,12 @@ 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 = theme::load_theme(settings.theme); + let theme = theme::load_theme(settings.theme.as_str()); 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..f437df22f9d 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::{Theme, Meaning, Level}, +}; 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::Alert { severity: Level::Info } } else { - Color::Red + Meaning::Alert { severity: Level::Error } }); 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::Alert { severity: Level::Error }); + 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..07205f693dc 100644 --- a/crates/atuin/src/command/client/search/inspector.rs +++ b/crates/atuin/src/command/client/search/inspector.rs @@ -17,6 +17,7 @@ use ratatui::{ use super::duration::format_duration; use super::interactive::{InputAction, State}; +use super::super::theme::{Theme, Meaning}; #[allow(clippy::cast_sign_loss)] fn u64_or_zero(num: i64) -> u64 { @@ -27,7 +28,7 @@ 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 +42,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 +56,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 +70,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)), ); @@ -226,7 +230,7 @@ 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,7 +241,7 @@ 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_commands(f, vert_layout[0], history, stats, theme); draw_stats_table(f, stats_layout[0], history, stats); draw_stats_charts(f, stats_layout[1], stats); } diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index e323b76d9f1..3a7fa5aaacd 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -34,6 +34,7 @@ use super::{ }; use crate::{command::client::search::engines, VERSION}; +use crate::command::client::theme::Theme; use ratatui::{ backend::CrosstermBackend, @@ -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), @@ -693,7 +695,7 @@ impl State { 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 ); } @@ -823,12 +826,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 { @@ -993,6 +998,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 +1075,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 +1109,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(()) diff --git a/crates/atuin/src/command/client/theme.rs b/crates/atuin/src/command/client/theme.rs deleted file mode 100644 index 8417e465f48..00000000000 --- a/crates/atuin/src/command/client/theme.rs +++ /dev/null @@ -1,41 +0,0 @@ -use strum_macros; -use std::collections::HashMap; - -#[derive(Hash, Debug, Eq, PartialEq, strum_macros::Display)] -#[strum(serialize_all = "snake_case")] -pub enum Meaning { - Alert { - severity: usize, - }, - Annotation, - Base, - Guidance, - Important, -} - -use ratatui::{ - style::{Color} -}; - -pub struct Theme { - colors: HashMap:: -} - -impl Theme { - pub fn new(colors: HashMap::) -> Theme { - Theme { colors } - } -} - -pub fn load_theme(_name: String) -> Theme { - let default_theme = HashMap::from([ - (Meaning::Alert { severity: 3 }, Color::Red), - (Meaning::Alert { severity: 2 }, Color::Yellow), - (Meaning::Alert { severity: 1 }, Color::Green), - (Meaning::Annotation, Color::DarkGray), - (Meaning::Guidance, Color::Blue), - (Meaning::Important, Color::White), - (Meaning::Base, Color::Gray), - ]); - Theme::new(default_theme) -} From 46dc659d972db4f87de364423794e72a88820fe0 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Mon, 25 Mar 2024 13:27:24 -0700 Subject: [PATCH 03/18] feat(theme): adds theming support --- crates/atuin-client/src/theme.rs | 143 +++++++++++++----- crates/atuin/src/command/client.rs | 8 +- .../src/command/client/search/history_list.rs | 8 +- 3 files changed, 117 insertions(+), 42 deletions(-) diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index e4bfaf16e10..80d5569e203 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -1,22 +1,27 @@ use strum_macros; +use std::path::PathBuf; +use std::io::BufReader; +use std::fs::File; +use eyre::Result; use std::collections::HashMap; use palette::named; +use serde::{Serialize, Deserialize}; use lazy_static::lazy_static; -#[derive(Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display)] -#[strum(serialize_all = "snake_case")] +#[derive(Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display)] +#[strum(serialize_all = "camel_case")] pub enum Level { Info, Warning, Error, } -#[derive(Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display)] -#[strum(serialize_all = "snake_case")] +#[derive(Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display)] +#[strum(serialize_all = "camel_case")] pub enum Meaning { - Alert { - severity: Level, - }, + AlertInfo, + AlertWarning, + AlertError, Annotation, Base, Guidance, @@ -49,7 +54,7 @@ impl Theme { } pub fn get_alert(&self, severity: Level) -> Color { - self.colors[&Meaning::Alert { severity: severity }] + self.colors[ALERT_TYPES.get(&severity).unwrap()] } pub fn new(colors: HashMap::) -> Theme { @@ -61,6 +66,12 @@ impl Theme { style.foreground_color = Some(self.colors[&meaning]); style } + + pub fn from_named(colors: HashMap::) -> Theme { + let colors: HashMap:: = + colors.iter().map(|(name, color)| { (*name, from_named(color)) }).collect(); + make_theme(&colors) + } } fn from_named(name: &str) -> Color { @@ -72,39 +83,101 @@ fn from_named(name: &str) -> Color { } } +fn make_theme(overrides: &HashMap) -> Theme { + let colors = HashMap::from([ + (Meaning::AlertError, Color::Red), + (Meaning::AlertWarning, 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(colors) +} + lazy_static! { - static ref BUILTIN_THEMES: HashMap<&'static str, HashMap> = { + static ref ALERT_TYPES: HashMap = { HashMap::from([ + (Level::Info, Meaning::AlertInfo), + (Level::Warning, Meaning::AlertWarning), + (Level::Error, Meaning::AlertError), + ]) + }; + + static ref BUILTIN_THEMES: HashMap<&'static str, Theme> = { + HashMap::from([ + ("", HashMap::new()), ("autumn", HashMap::from([ - (Meaning::Alert { severity: Level::Error }, from_named("saddlebrown")), - (Meaning::Alert { severity: Level::Warning }, from_named("darkorange")), - (Meaning::Alert { severity: Level::Info }, from_named("gold")), + (Meaning::AlertError, from_named("saddlebrown")), + (Meaning::AlertWarning, from_named("darkorange")), + (Meaning::AlertInfo, from_named("gold")), (Meaning::Annotation, Color::DarkGrey), - (Meaning::Guidance, from_named("khaki")), + (Meaning::Guidance, from_named("brown")), + ])), + ("marine", HashMap::from([ + (Meaning::AlertError, from_named("seagreen")), + (Meaning::AlertWarning, from_named("turquoise")), + (Meaning::AlertInfo, from_named("cyan")), + (Meaning::Annotation, from_named("midnightblue")), + (Meaning::Guidance, from_named("teal")), ])) - ]) + ]).iter().map(|(name, theme)| (*name, make_theme(theme))).collect() }; } -pub fn load_theme(name: &str) -> Theme { - let mut default_theme = HashMap::from([ - (Meaning::Alert { severity: Level::Error }, Color::Red), - (Meaning::Alert { severity: Level::Warning }, Color::Yellow), - (Meaning::Alert { severity: Level::Info }, Color::Green), - (Meaning::Annotation, Color::DarkGrey), - (Meaning::Guidance, Color::Blue), - (Meaning::Important, Color::White), - (Meaning::Base, Color::Grey), - ]); - let built_ins = &BUILTIN_THEMES; - let theme = match built_ins.get(name) { - Some(theme) => { - theme.iter().for_each(|(k, v)| { - default_theme.insert(*k, *v); - }); - default_theme - }, - None => default_theme - }; - Theme::new(theme) +pub struct ThemeManager { + loaded_themes: HashMap:: +} + +impl ThemeManager { + pub fn new() -> Self { + Self { loaded_themes: HashMap::new() } + } + + pub fn load_theme_from_file(&mut self, name: &str) -> Result<&Theme> { + let mut theme_file = if let Ok(p) = std::env::var("ATUIN_THEME_DIR") { + 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_yaml = format!["{}.yaml", name]; + theme_file.push(theme_yaml); + + let file = File::open(theme_file.as_path())?; + let reader: BufReader = BufReader::new(file); + let colors: HashMap = serde_json::from_reader(reader)?; + let theme = Theme::from_named(colors); + let name = name.to_string(); + self.loaded_themes.insert(name.clone(), theme); + let theme = self.loaded_themes.get(&name).unwrap(); + Ok(theme) + } + + pub fn load_theme(&mut self, name: &str) -> &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) { + Ok(theme) => theme, + Err(err) => { + print!["Could not load theme {}: {}", name, err]; + built_ins.get("").unwrap() + } + } + } + } } diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index 11984de1d55..e47d9bfa45d 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -94,14 +94,15 @@ 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(); + 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,7 +128,8 @@ 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 = theme::load_theme(settings.theme.as_str()); + let theme_name = settings.theme.clone(); + let theme = theme_manager.load_theme(theme_name.as_str()); match self { Self::Import(import) => import.run(&db).await, diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs index f437df22f9d..defc7e34713 100644 --- a/crates/atuin/src/command/client/search/history_list.rs +++ b/crates/atuin/src/command/client/search/history_list.rs @@ -2,7 +2,7 @@ use std::time::Duration; use atuin_client::{ history::History, - theme::{Theme, Meaning, Level}, + theme::{Theme, Meaning}, }; use atuin_common::utils::Escapable as _; use ratatui::{ @@ -160,9 +160,9 @@ impl DrawState<'_> { fn duration(&mut self, h: &History) { let status = self.theme.as_style(if h.success() { - Meaning::Alert { severity: Level::Info } + Meaning::AlertInfo } else { - Meaning::Alert { severity: Level::Error } + Meaning::AlertError }); let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0)); self.draw(&format_duration(duration), status.into()); @@ -195,7 +195,7 @@ impl DrawState<'_> { 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 = self.theme.as_style(Meaning::Alert { severity: Level::Error }); + style = self.theme.as_style(Meaning::AlertError); style.attributes.set(crossterm::style::Attribute::Bold) } From ba98219a94194cbde729ec97c8a1d61d9fd2b976 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Sun, 7 Jul 2024 15:39:25 +0100 Subject: [PATCH 04/18] fix: split out palette without compact inspector --- crates/atuin-client/src/theme.rs | 9 +++--- .../src/command/client/search/inspector.rs | 18 ++++++++---- .../src/command/client/search/interactive.rs | 29 ++++++++++--------- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index 80d5569e203..e7123b7c9b5 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -121,10 +121,11 @@ lazy_static! { (Meaning::Guidance, from_named("brown")), ])), ("marine", HashMap::from([ - (Meaning::AlertError, from_named("seagreen")), - (Meaning::AlertWarning, from_named("turquoise")), - (Meaning::AlertInfo, from_named("cyan")), - (Meaning::Annotation, from_named("midnightblue")), + (Meaning::AlertError, from_named("yellowgreen")), + (Meaning::AlertWarning, from_named("cyan")), + (Meaning::AlertInfo, from_named("turquoise")), + (Meaning::Annotation, from_named("steelblue")), + (Meaning::Base, from_named("lightsteelblue")), (Meaning::Guidance, from_named("teal")), ])) ]).iter().map(|(name, theme)| (*name, make_theme(theme))).collect() diff --git a/crates/atuin/src/command/client/search/inspector.rs b/crates/atuin/src/command/client/search/inspector.rs index 07205f693dc..05a420a050a 100644 --- a/crates/atuin/src/command/client/search/inspector.rs +++ b/crates/atuin/src/command/client/search/inspector.rs @@ -79,7 +79,7 @@ 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); @@ -102,6 +102,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)), ); @@ -148,7 +149,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() @@ -163,6 +164,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) @@ -183,7 +185,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()) @@ -207,6 +214,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) @@ -242,8 +250,8 @@ pub fn draw(f: &mut Frame<'_>, chunk: Rect, history: &History, stats: &HistorySt .split(vert_layout[1]); draw_commands(f, vert_layout[0], history, stats, theme); - draw_stats_table(f, stats_layout[0], history, stats); - draw_stats_charts(f, stats_layout[1], stats); + 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 3a7fa5aaacd..3fadbc1e7cc 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -34,13 +34,13 @@ use super::{ }; use crate::{command::client::search::engines, VERSION}; -use crate::command::client::theme::Theme; +use crate::command::client::theme::{Theme, Meaning}; 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, @@ -683,13 +683,13 @@ 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 { @@ -744,7 +744,7 @@ impl State { preview_width - 2 }; let preview = - self.build_preview(results, compact, preview_width, preview_chunk.width.into()); + 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()); @@ -757,23 +757,23 @@ 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![ @@ -807,16 +807,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 } @@ -891,6 +891,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() { @@ -910,7 +911,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() From edcd4e35f27653f8291d89da385e370bb590e1af Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Mon, 8 Jul 2024 00:05:05 +0100 Subject: [PATCH 05/18] fix(theme): tidy up implementation --- crates/atuin-client/Cargo.toml | 4 +- crates/atuin-client/config.toml | 16 ++++ crates/atuin-client/src/settings.rs | 25 +++++- crates/atuin-client/src/theme.rs | 124 +++++++++++++++++++++------- crates/atuin-history/src/stats.rs | 4 +- crates/atuin/src/command/client.rs | 4 +- 6 files changed, 140 insertions(+), 37 deletions(-) diff --git a/crates/atuin-client/Cargo.toml b/crates/atuin-client/Cargo.toml index d28c5c5528c..92745f846ca 100644 --- a/crates/atuin-client/Cargo.toml +++ b/crates/atuin-client/Cargo.toml @@ -69,11 +69,11 @@ hex = { version = "0.4", optional = true } sha2 = { version = "0.10", optional = true } indicatif = "0.17.7" tiny-bip39 = "1" -palette = { version = "0.7.5", features = ["serializing"] } -lazy_static = "1.4.0" # theme crossterm = "0.27.0" +palette = { version = "0.7.5", features = ["serializing"] } +lazy_static = "1.4.0" strum_macros = "0.25.3" strum = { version = "0.25.0", features = ["strum_macros"] } diff --git a/crates/atuin-client/config.toml b/crates/atuin-client/config.toml index bf3cff6877b..ee0ce6a79b1 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 renderering 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, AlertWarning, 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/settings.rs b/crates/atuin-client/src/settings.rs index 4b571e24bc8..5c609c7af8d 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -338,6 +338,15 @@ 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, +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Daemon { /// Use the daemon to sync @@ -366,6 +375,15 @@ impl Default for Preview { } } +impl Default for Theme { + fn default() -> Self { + Self { + name: "".to_string(), + debug: None::, + } + } +} + impl Default for Daemon { fn default() -> Self { Self { @@ -424,7 +442,6 @@ pub struct Settings { pub history_format: String, pub prefers_reduced_motion: bool, pub store_failed: bool, - pub theme: String, #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)] pub history_filter: RegexSet, @@ -459,6 +476,9 @@ pub struct Settings { #[serde(default)] pub daemon: Daemon, + + #[serde(default)] + pub theme: Theme, } impl Settings { @@ -728,6 +748,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") @@ -735,7 +757,6 @@ impl Settings { .map(|_| config::Value::new(None, config::ValueKind::Boolean(true))) .unwrap_or_else(|| config::Value::new(None, config::ValueKind::Boolean(false))), )? - .set_default("theme", "")? .add_source( Environment::with_prefix("atuin") .prefix_separator("_") diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index e7123b7c9b5..20396008533 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -1,13 +1,15 @@ use strum_macros; use std::path::PathBuf; -use std::io::BufReader; -use std::fs::File; -use eyre::Result; +use std::error::Error; +use config::{ + Config, File as ConfigFile, FileFormat, +}; use std::collections::HashMap; use palette::named; use serde::{Serialize, Deserialize}; use lazy_static::lazy_static; +// Standard log-levels that may occur in the interface. #[derive(Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display)] #[strum(serialize_all = "camel_case")] pub enum Level { @@ -16,6 +18,7 @@ pub enum Level { Error, } +// Collection of settable "meanings" that can have colors set. #[derive(Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display)] #[strum(serialize_all = "camel_case")] pub enum Meaning { @@ -32,11 +35,17 @@ 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 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] } @@ -53,6 +62,8 @@ impl Theme { self.get_alert(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: Level) -> Color { self.colors[ALERT_TYPES.get(&severity).unwrap()] } @@ -61,28 +72,50 @@ impl Theme { Theme { colors } } + // General access - if you have a meaning, this will give you a (crossterm) style pub fn as_style(&self, meaning: Meaning) -> ContentStyle { let mut style = ContentStyle::default(); style.foreground_color = Some(self.colors[&meaning]); style } - pub fn from_named(colors: HashMap::) -> Theme { + // 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_named(colors: HashMap::, debug: bool) -> Theme { let colors: HashMap:: = - colors.iter().map(|(name, color)| { (*name, from_named(color)) }).collect(); + colors.iter().map(|(name, color)| { (*name, from_named(color).unwrap_or_else(|msg: String| { + if debug { + print!["Could not load theme color: {} -> {}\n", msg, color]; + } else { + print!["Could not load theme color: {}\n", msg]; + } + Color::Grey + })) }).collect(); make_theme(&colors) } } -fn from_named(name: &str) -> Color { - let srgb = named::from_str(name).unwrap(); - Color::Rgb { +// Use palette to get a color from a string name, if possible +fn from_named(name: &str) -> Result { + 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_named(name).unwrap() +} + +// Boil down a meaning-color hashmap into a theme, by taking the defaults +// for any unknown colors fn make_theme(overrides: &HashMap) -> Theme { let colors = HashMap::from([ (Meaning::AlertError, Color::Red), @@ -101,6 +134,9 @@ fn make_theme(overrides: &HashMap) -> Theme { Theme::new(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([ @@ -114,35 +150,45 @@ lazy_static! { HashMap::from([ ("", HashMap::new()), ("autumn", HashMap::from([ - (Meaning::AlertError, from_named("saddlebrown")), - (Meaning::AlertWarning, from_named("darkorange")), - (Meaning::AlertInfo, from_named("gold")), + (Meaning::AlertError, _from_known("saddlebrown")), + (Meaning::AlertWarning, _from_known("darkorange")), + (Meaning::AlertInfo, _from_known("gold")), (Meaning::Annotation, Color::DarkGrey), - (Meaning::Guidance, from_named("brown")), + (Meaning::Guidance, _from_known("brown")), ])), ("marine", HashMap::from([ - (Meaning::AlertError, from_named("yellowgreen")), - (Meaning::AlertWarning, from_named("cyan")), - (Meaning::AlertInfo, from_named("turquoise")), - (Meaning::Annotation, from_named("steelblue")), - (Meaning::Base, from_named("lightsteelblue")), - (Meaning::Guidance, from_named("teal")), + (Meaning::AlertError, _from_known("yellowgreen")), + (Meaning::AlertWarning, _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(theme))).collect() }; } +// To avoid themes being repeatedly loaded, we store them in a theme manager pub struct ThemeManager { - loaded_themes: HashMap:: + loaded_themes: HashMap::, + debug: bool, + override_theme_dir: Option, } +// Theme-loading logic impl ThemeManager { - pub fn new() -> Self { - Self { loaded_themes: HashMap::new() } + pub fn new(debug: Option) -> Self { + Self { + loaded_themes: HashMap::new(), + debug: debug.unwrap_or(false), + override_theme_dir: std::env::var("ATUIN_THEME_DIR").ok(), + } } - pub fn load_theme_from_file(&mut self, name: &str) -> Result<&Theme> { - let mut theme_file = if let Ok(p) = std::env::var("ATUIN_THEME_DIR") { + // 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) -> Result<&Theme, Box> { + let mut theme_file = if let Some(p) = &self.override_theme_dir { PathBuf::from(p) } else { let config_dir = atuin_common::utils::config_dir(); @@ -155,16 +201,26 @@ impl ThemeManager { let theme_yaml = format!["{}.yaml", name]; theme_file.push(theme_yaml); - let file = File::open(theme_file.as_path())?; - let reader: BufReader = BufReader::new(file); - let colors: HashMap = serde_json::from_reader(reader)?; - let theme = Theme::from_named(colors); + 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()?; + let colors: HashMap = config + .try_deserialize() + .map_err(|e| println!("failed to deserialize: {}", e)).unwrap(); + let theme = Theme::from_named(colors, self.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) -> &Theme { if self.loaded_themes.contains_key(name) { return self.loaded_themes.get(name).unwrap(); @@ -182,3 +238,15 @@ impl ThemeManager { } } } + +#[cfg(test)] +mod theme_tests { + use super::*; + + #[test] + fn load_theme() { + let mut manager = ThemeManager::new(Some(false)); + let theme = manager.load_theme("autumn"); + assert_eq!(theme.as_style(Meaning::Annotation).foreground_color, Some(Color::DarkGrey)); + } +} diff --git a/crates/atuin-history/src/stats.rs b/crates/atuin-history/src/stats.rs index 295651e8ecd..8d20b1835a0 100644 --- a/crates/atuin-history/src/stats.rs +++ b/crates/atuin-history/src/stats.rs @@ -225,7 +225,6 @@ pub fn compute( mod tests { use atuin_client::history::History; use atuin_client::settings::Settings; - use crate::command::client::theme::ThemeManager; use time::OffsetDateTime; use super::compute; @@ -249,8 +248,7 @@ mod tests { .into(), ]; - let mut theme = ThemeManager::new().load_theme(""); - let stats = compute(&settings, &history, 10, 1, &theme).expect("failed to compute stats"); + let stats = compute(&settings, &history, 10, 1).expect("failed to compute stats"); assert_eq!(stats.total_commands, 1); assert_eq!(stats.unique_commands, 1); } diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index e47d9bfa45d..5126c41049c 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -94,7 +94,7 @@ impl Cmd { .unwrap(); let settings = Settings::new().wrap_err("could not load client settings")?; - let theme_manager = theme::ThemeManager::new(); + let theme_manager = theme::ThemeManager::new(settings.theme.debug); let res = runtime.block_on(self.run_inner(settings, theme_manager)); runtime.shutdown_timeout(std::time::Duration::from_millis(50)); @@ -128,7 +128,7 @@ 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.clone(); + let theme_name = settings.theme.name.clone(); let theme = theme_manager.load_theme(theme_name.as_str()); match self { From b65e08211ccd373da66d498ee8d3c72dacb6e503 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Mon, 8 Jul 2024 00:22:54 +0100 Subject: [PATCH 06/18] fix(theme): correct yaml to toml --- crates/atuin-client/src/theme.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index 20396008533..e9f2619fd39 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -198,8 +198,8 @@ impl ThemeManager { theme_file }; - let theme_yaml = format!["{}.yaml", name]; - theme_file.push(theme_yaml); + let theme_toml = format!["{}.toml", name]; + theme_file.push(theme_toml); let mut config_builder = Config::builder(); From 79ebe369ee58dbb0b1226c9042a5142a9745b375 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Mon, 8 Jul 2024 00:29:31 +0100 Subject: [PATCH 07/18] fix(theme): typo in comments --- crates/atuin-client/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/atuin-client/config.toml b/crates/atuin-client/config.toml index ee0ce6a79b1..ffd33114935 100644 --- a/crates/atuin-client/config.toml +++ b/crates/atuin-client/config.toml @@ -232,7 +232,7 @@ records = true # tcp_port = 8889 # [theme] -## Color theme to use for renderering in the terminal. +## 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 From 4ab3009671a85d6852a65df466a4489847559099 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Mon, 8 Jul 2024 00:34:51 +0100 Subject: [PATCH 08/18] chore: cheer up clippy --- crates/atuin-client/src/theme.rs | 11 +++++------ crates/atuin-history/src/stats.rs | 8 ++++---- crates/atuin/src/command/client.rs | 4 ++-- .../atuin/src/command/client/search/history_list.rs | 2 +- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index e9f2619fd39..e75ad2b683b 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -74,9 +74,10 @@ impl Theme { // General access - if you have a meaning, this will give you a (crossterm) style pub fn as_style(&self, meaning: Meaning) -> ContentStyle { - let mut style = ContentStyle::default(); - style.foreground_color = Some(self.colors[&meaning]); - style + ContentStyle { + foreground_color: Some(self.colors[&meaning]), + .. ContentStyle::default() + } } // Turns a map of meanings to colornames into a theme @@ -88,9 +89,7 @@ impl Theme { let colors: HashMap:: = colors.iter().map(|(name, color)| { (*name, from_named(color).unwrap_or_else(|msg: String| { if debug { - print!["Could not load theme color: {} -> {}\n", msg, color]; - } else { - print!["Could not load theme color: {}\n", msg]; + println!["Could not load theme color: {} -> {}", msg, color]; } Color::Grey })) }).collect(); diff --git a/crates/atuin-history/src/stats.rs b/crates/atuin-history/src/stats.rs index 8d20b1835a0..92e08340681 100644 --- a/crates/atuin-history/src/stats.rs +++ b/crates/atuin-history/src/stats.rs @@ -126,21 +126,21 @@ pub fn pretty_print(stats: Stats, ngram_size: usize, theme: &Theme) { }); for (command, count) in stats.top { - let gray = SetForegroundColor(theme.get_base().into()); + let gray = SetForegroundColor(theme.get_base()); let bold = SetAttribute(crossterm::style::Attribute::Bold); let in_ten = 10 * count / max; print!("["); - print!("{}", SetForegroundColor(theme.get_error().into())); + print!("{}", SetForegroundColor(theme.get_error())); for i in 0..in_ten { if i == 2 { - print!("{}", SetForegroundColor(theme.get_warning().into())); + print!("{}", SetForegroundColor(theme.get_warning())); } if i == 5 { - print!("{}", SetForegroundColor(theme.get_info().into())); + print!("{}", SetForegroundColor(theme.get_info())); } print!("▮"); diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index 5126c41049c..782e65e851d 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -133,8 +133,8 @@ impl Cmd { match self { Self::Import(import) => import.run(&db).await, - Self::Stats(stats) => stats.run(&db, &settings, &theme).await, - Self::Search(search) => search.run(db, &mut settings, sqlite_store, &theme).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/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs index defc7e34713..70c90619535 100644 --- a/crates/atuin/src/command/client/search/history_list.rs +++ b/crates/atuin/src/command/client/search/history_list.rs @@ -196,7 +196,7 @@ impl DrawState<'_> { { // if not applying alternative highlighting to the whole row, color the command style = self.theme.as_style(Meaning::AlertError); - style.attributes.set(crossterm::style::Attribute::Bold) + style.attributes.set(crossterm::style::Attribute::Bold); } for section in h.command.escape_control().split_ascii_whitespace() { From ed79342551a07e0b787b8def46463ba71fe5c19a Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Mon, 8 Jul 2024 00:57:42 +0100 Subject: [PATCH 09/18] fix(themes): ensure tests cannot hit real loading directory --- crates/atuin-client/src/theme.rs | 24 +++++++++++++++++------- crates/atuin/src/command/client.rs | 2 +- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index e75ad2b683b..2d2e1e3a5e0 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -1,6 +1,7 @@ use strum_macros; use std::path::PathBuf; -use std::error::Error; +use std::error; +use std::io::{Error, ErrorKind}; use config::{ Config, File as ConfigFile, FileFormat, }; @@ -176,18 +177,27 @@ pub struct ThemeManager { // Theme-loading logic impl ThemeManager { - pub fn new(debug: Option) -> Self { + pub fn new(debug: Option, theme_dir: Option) -> Self { Self { loaded_themes: HashMap::new(), debug: debug.unwrap_or(false), - override_theme_dir: std::env::var("ATUIN_THEME_DIR").ok(), + 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) -> Result<&Theme, Box> { + pub fn load_theme_from_file(&mut self, name: &str) -> 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(); @@ -230,7 +240,7 @@ impl ThemeManager { None => match self.load_theme_from_file(name) { Ok(theme) => theme, Err(err) => { - print!["Could not load theme {}: {}", name, err]; + println!["Could not load theme {}: {}", name, err]; built_ins.get("").unwrap() } } @@ -244,8 +254,8 @@ mod theme_tests { #[test] fn load_theme() { - let mut manager = ThemeManager::new(Some(false)); + let mut manager = ThemeManager::new(Some(false), Some("".to_string())); let theme = manager.load_theme("autumn"); - assert_eq!(theme.as_style(Meaning::Annotation).foreground_color, Some(Color::DarkGrey)); + assert_eq!(theme.as_style(Meaning::Guidance).foreground_color, from_named("brown").ok()); } } diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index 782e65e851d..a35e5bd054a 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -94,7 +94,7 @@ impl Cmd { .unwrap(); let settings = Settings::new().wrap_err("could not load client settings")?; - let theme_manager = theme::ThemeManager::new(settings.theme.debug); + 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)); From c82cf6108431f600eef7dc4faa4ddc9a740571f3 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Mon, 8 Jul 2024 10:38:45 +0100 Subject: [PATCH 10/18] chore: rustfmt --- crates/atuin-client/src/theme.rs | 128 ++++++++++-------- crates/atuin/src/command/client.rs | 10 +- .../src/command/client/search/history_list.rs | 6 +- .../src/command/client/search/inspector.rs | 28 +++- .../src/command/client/search/interactive.rs | 25 ++-- 5 files changed, 125 insertions(+), 72 deletions(-) diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index 2d2e1e3a5e0..8b59c743b95 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -1,17 +1,17 @@ -use strum_macros; -use std::path::PathBuf; +use config::{Config, File as ConfigFile, FileFormat}; +use lazy_static::lazy_static; +use palette::named; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::error; use std::io::{Error, ErrorKind}; -use config::{ - Config, File as ConfigFile, FileFormat, -}; -use std::collections::HashMap; -use palette::named; -use serde::{Serialize, Deserialize}; -use lazy_static::lazy_static; +use std::path::PathBuf; +use strum_macros; // Standard log-levels that may occur in the interface. -#[derive(Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display)] +#[derive( + Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display, +)] #[strum(serialize_all = "camel_case")] pub enum Level { Info, @@ -20,7 +20,9 @@ pub enum Level { } // Collection of settable "meanings" that can have colors set. -#[derive(Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display)] +#[derive( + Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display, +)] #[strum(serialize_all = "camel_case")] pub enum Meaning { AlertInfo, @@ -32,14 +34,12 @@ pub enum Meaning { Important, } -use crossterm::{ - style::{Color, ContentStyle}, -}; +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 colors: HashMap:: + pub colors: HashMap, } // Themes have a number of convenience functions for the most commonly used meanings. @@ -69,7 +69,7 @@ impl Theme { self.colors[ALERT_TYPES.get(&severity).unwrap()] } - pub fn new(colors: HashMap::) -> Theme { + pub fn new(colors: HashMap) -> Theme { Theme { colors } } @@ -77,7 +77,7 @@ impl Theme { pub fn as_style(&self, meaning: Meaning) -> ContentStyle { ContentStyle { foreground_color: Some(self.colors[&meaning]), - .. ContentStyle::default() + ..ContentStyle::default() } } @@ -86,14 +86,21 @@ impl Theme { // 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_named(colors: HashMap::, debug: bool) -> Theme { - let colors: HashMap:: = - colors.iter().map(|(name, color)| { (*name, from_named(color).unwrap_or_else(|msg: String| { - if debug { - println!["Could not load theme color: {} -> {}", msg, color]; - } - Color::Grey - })) }).collect(); + pub fn from_named(colors: HashMap, debug: bool) -> Theme { + let colors: HashMap = colors + .iter() + .map(|(name, color)| { + ( + *name, + from_named(color).unwrap_or_else(|msg: String| { + if debug { + println!["Could not load theme color: {} -> {}", msg, color]; + } + Color::Grey + }), + ) + }) + .collect(); make_theme(&colors) } } @@ -125,12 +132,13 @@ fn make_theme(overrides: &HashMap) -> Theme { (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(); + ]) + .iter() + .map(|(name, color)| match overrides.get(name) { + Some(value) => (*name, *value), + None => (*name, *color), + }) + .collect(); Theme::new(colors) } @@ -145,32 +153,40 @@ lazy_static! { (Level::Error, Meaning::AlertError), ]) }; - static ref BUILTIN_THEMES: HashMap<&'static str, Theme> = { HashMap::from([ ("", HashMap::new()), - ("autumn", HashMap::from([ - (Meaning::AlertError, _from_known("saddlebrown")), - (Meaning::AlertWarning, _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::AlertWarning, _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(theme))).collect() + ( + "autumn", + HashMap::from([ + (Meaning::AlertError, _from_known("saddlebrown")), + (Meaning::AlertWarning, _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::AlertWarning, _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(theme))) + .collect() }; } // To avoid themes being repeatedly loaded, we store them in a theme manager pub struct ThemeManager { - loaded_themes: HashMap::, + loaded_themes: HashMap, debug: bool, override_theme_dir: Option, } @@ -183,7 +199,7 @@ impl ThemeManager { 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() + None => std::env::var("ATUIN_THEME_DIR").ok(), }, } } @@ -195,7 +211,7 @@ impl ThemeManager { if p.is_empty() { return Err(Box::new(Error::new( ErrorKind::NotFound, - "Empty theme directory override and could not find theme elsewhere" + "Empty theme directory override and could not find theme elsewhere", ))); } PathBuf::from(p) @@ -220,7 +236,8 @@ impl ThemeManager { let config = config_builder.build()?; let colors: HashMap = config .try_deserialize() - .map_err(|e| println!("failed to deserialize: {}", e)).unwrap(); + .map_err(|e| println!("failed to deserialize: {}", e)) + .unwrap(); let theme = Theme::from_named(colors, self.debug); let name = name.to_string(); self.loaded_themes.insert(name.clone(), theme); @@ -243,7 +260,7 @@ impl ThemeManager { println!["Could not load theme {}: {}", name, err]; built_ins.get("").unwrap() } - } + }, } } } @@ -256,6 +273,9 @@ mod theme_tests { fn load_theme() { let mut manager = ThemeManager::new(Some(false), Some("".to_string())); let theme = manager.load_theme("autumn"); - assert_eq!(theme.as_style(Meaning::Guidance).foreground_color, from_named("brown").ok()); + assert_eq!( + theme.as_style(Meaning::Guidance).foreground_color, + from_named("brown").ok() + ); } } diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index a35e5bd054a..6a406dd9de3 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, theme}; +use atuin_client::{ + database::Sqlite, record::sqlite_store::SqliteStore, settings::Settings, theme, +}; use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*}; #[cfg(feature = "sync")] @@ -102,7 +104,11 @@ impl Cmd { res } - async fn run_inner(self, mut settings: Settings, mut theme_manager: theme::ThemeManager) -> 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()?); diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs index 70c90619535..87f803aa46f 100644 --- a/crates/atuin/src/command/client/search/history_list.rs +++ b/crates/atuin/src/command/client/search/history_list.rs @@ -2,7 +2,7 @@ use std::time::Duration; use atuin_client::{ history::History, - theme::{Theme, Meaning}, + theme::{Meaning, Theme}, }; use atuin_common::utils::Escapable as _; use ratatui::{ @@ -74,7 +74,7 @@ impl<'a> StatefulWidget for HistoryList<'a> { inverted: self.inverted, alternate_highlight: self.alternate_highlight, now: &self.now, - theme: self.theme + theme: self.theme, }; for item in self.history.iter().skip(state.offset).take(end - start) { @@ -96,7 +96,7 @@ impl<'a> HistoryList<'a> { inverted: bool, alternate_highlight: bool, now: &'a dyn Fn() -> OffsetDateTime, - theme: &'a Theme + theme: &'a Theme, ) -> Self { Self { history, diff --git a/crates/atuin/src/command/client/search/inspector.rs b/crates/atuin/src/command/client/search/inspector.rs index 05a420a050a..05a0fe2173a 100644 --- a/crates/atuin/src/command/client/search/inspector.rs +++ b/crates/atuin/src/command/client/search/inspector.rs @@ -16,8 +16,8 @@ use ratatui::{ use super::duration::format_duration; +use super::super::theme::{Meaning, Theme}; use super::interactive::{InputAction, State}; -use super::super::theme::{Theme, Meaning}; #[allow(clippy::cast_sign_loss)] fn u64_or_zero(num: i64) -> u64 { @@ -28,7 +28,13 @@ fn u64_or_zero(num: i64) -> u64 { } } -pub fn draw_commands(f: &mut Frame<'_>, parent: Rect, history: &History, stats: &HistoryStats, theme: &Theme) { +pub fn draw_commands( + f: &mut Frame<'_>, + parent: Rect, + history: &History, + stats: &HistoryStats, + theme: &Theme, +) { let commands = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -79,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, theme: &Theme) { +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); @@ -189,7 +201,7 @@ fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats, them Block::default() .title("Runs per day") .style(theme.as_style(Meaning::Base)) - .borders(Borders::ALL) + .borders(Borders::ALL), ) .bar_width(3) .bar_gap(1) @@ -238,7 +250,13 @@ fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats, them f.render_widget(duration_over_time, layout[2]); } -pub fn draw(f: &mut Frame<'_>, chunk: Rect, history: &History, stats: &HistoryStats, theme: &Theme) { +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)]) diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index 3fadbc1e7cc..825e978ad97 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -33,8 +33,8 @@ use super::{ history_list::{HistoryList, ListState, PREFIX_LENGTH}, }; +use crate::command::client::theme::{Meaning, Theme}; use crate::{command::client::search::engines, VERSION}; -use crate::command::client::theme::{Theme, Meaning}; use ratatui::{ backend::CrosstermBackend, @@ -718,7 +718,7 @@ impl State { results_list_chunk, &results[self.results_state.selected()], &stats.expect("Drawing inspector, but no stats"), - theme + theme, ); } @@ -743,8 +743,13 @@ impl State { } else { preview_width - 2 }; - let preview = - self.build_preview(results, compact, preview_width, preview_chunk.width.into(), theme); + 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()); @@ -761,12 +766,16 @@ impl State { 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(theme.get_error().into()), + 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).fg(theme.get_base().into()), + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme.get_base().into()), ))) }; title.alignment(Alignment::Left) @@ -826,7 +835,7 @@ impl State { results: &'a [History], keymap_mode: KeymapMode, now: &'a dyn Fn() -> OffsetDateTime, - theme: &'a Theme + theme: &'a Theme, ) -> HistoryList<'a> { let results_list = HistoryList::new( results, @@ -891,7 +900,7 @@ impl State { compact: bool, preview_width: u16, chunk_width: usize, - theme: &Theme + theme: &Theme, ) -> Paragraph { let selected = self.results_state.selected(); let command = if results.is_empty() { From bebe2688884288f5ac183fd03f97bbca8fa697a3 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Mon, 8 Jul 2024 20:13:27 +0100 Subject: [PATCH 11/18] chore: rebase --- Cargo.lock | 32 +++++--------------------------- crates/atuin-client/Cargo.toml | 4 ++-- 2 files changed, 7 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d1925314b9..b06d6c8009b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -311,8 +311,8 @@ dependencies = [ "shellexpand", "sql-builder", "sqlx", - "strum 0.25.0", - "strum_macros 0.25.3", + "strum", + "strum_macros", "thiserror", "time", "tiny-bip39", @@ -3130,8 +3130,8 @@ dependencies = [ "lru", "paste", "stability", - "strum 0.26.3", - "strum_macros 0.26.4", + "strum", + "strum_macros", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -4108,35 +4108,13 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" -dependencies = [ - "strum_macros 0.25.3", -] - [[package]] name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros 0.26.4", -] - -[[package]] -name = "strum_macros" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.70", + "strum_macros", ] [[package]] diff --git a/crates/atuin-client/Cargo.toml b/crates/atuin-client/Cargo.toml index 92745f846ca..347385c2731 100644 --- a/crates/atuin-client/Cargo.toml +++ b/crates/atuin-client/Cargo.toml @@ -74,8 +74,8 @@ tiny-bip39 = "1" crossterm = "0.27.0" palette = { version = "0.7.5", features = ["serializing"] } lazy_static = "1.4.0" -strum_macros = "0.25.3" -strum = { version = "0.25.0", features = ["strum_macros"] } +strum_macros = "0.26.3" +strum = { version = "0.26.2", features = ["strum_macros"] } [dev-dependencies] tokio = { version = "1", features = ["full"] } From 75bd4e20cd90c85b7c09a77f2eee1a53f14ef797 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Fri, 12 Jul 2024 12:20:26 +0100 Subject: [PATCH 12/18] feat(themes): add rgb hexcode support --- crates/atuin-client/src/theme.rs | 47 ++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index 8b59c743b95..9a62ef1c61f 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -1,4 +1,5 @@ use config::{Config, File as ConfigFile, FileFormat}; +use itertools::Itertools; use lazy_static::lazy_static; use palette::named; use serde::{Deserialize, Serialize}; @@ -86,13 +87,13 @@ impl Theme { // 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_named(colors: HashMap, debug: bool) -> Theme { + pub fn from_map(colors: HashMap, debug: bool) -> Theme { let colors: HashMap = colors .iter() .map(|(name, color)| { ( *name, - from_named(color).unwrap_or_else(|msg: String| { + from_string(color).unwrap_or_else(|msg: String| { if debug { println!["Could not load theme color: {} -> {}", msg, color]; } @@ -106,19 +107,41 @@ impl Theme { } // Use palette to get a color from a string name, if possible -fn from_named(name: &str) -> Result { - 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, - }) +fn from_string(name: &str) -> Result { + if name.len() == 0 { + return Err("Empty string".into()); + } + if name.starts_with("#") { + let hexcode = &name[1..]; + 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_named(name).unwrap() + from_string(name).unwrap() } // Boil down a meaning-color hashmap into a theme, by taking the defaults @@ -238,7 +261,7 @@ impl ThemeManager { .try_deserialize() .map_err(|e| println!("failed to deserialize: {}", e)) .unwrap(); - let theme = Theme::from_named(colors, self.debug); + let theme = Theme::from_map(colors, self.debug); let name = name.to_string(); self.loaded_themes.insert(name.clone(), theme); let theme = self.loaded_themes.get(&name).unwrap(); @@ -275,7 +298,7 @@ mod theme_tests { let theme = manager.load_theme("autumn"); assert_eq!( theme.as_style(Meaning::Guidance).foreground_color, - from_named("brown").ok() + from_string("brown").ok() ); } } From 7978a1103a4088d2dd424a05982d8600f2651f71 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Sat, 13 Jul 2024 00:38:14 +0100 Subject: [PATCH 13/18] fix(theme): add tests --- crates/atuin-client/src/theme.rs | 143 +++++++++++++++++++++++++++++-- 1 file changed, 137 insertions(+), 6 deletions(-) diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index 9a62ef1c61f..e814d1f333b 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -1,5 +1,4 @@ use config::{Config, File as ConfigFile, FileFormat}; -use itertools::Itertools; use lazy_static::lazy_static; use palette::named; use serde::{Deserialize, Serialize}; @@ -7,6 +6,7 @@ use std::collections::HashMap; use std::error; use std::io::{Error, ErrorKind}; use std::path::PathBuf; +use log; use strum_macros; // Standard log-levels that may occur in the interface. @@ -33,6 +33,7 @@ pub enum Meaning { Base, Guidance, Important, + Title, } use crossterm::style::{Color, ContentStyle}; @@ -74,10 +75,20 @@ impl Theme { Theme { 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[&meaning]), + foreground_color: Some(self.colors[&self.closest_meaning(&meaning)]), ..ContentStyle::default() } } @@ -95,7 +106,7 @@ impl Theme { *name, from_string(color).unwrap_or_else(|msg: String| { if debug { - println!["Could not load theme color: {} -> {}", msg, color]; + log::warn!("Could not load theme color: {} -> {}", msg, color); } Color::Grey }), @@ -176,6 +187,13 @@ lazy_static! { (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()), @@ -257,9 +275,13 @@ impl ThemeManager { )); let config = config_builder.build()?; + self.load_theme_from_config(name, config) + } + + pub fn load_theme_from_config(&mut self, name: &str, config: Config) -> Result<&Theme, Box> { let colors: HashMap = config .try_deserialize() - .map_err(|e| println!("failed to deserialize: {}", e)) + .map_err(|e| log::warn!("failed to deserialize: {}", e)) .unwrap(); let theme = Theme::from_map(colors, self.debug); let name = name.to_string(); @@ -280,7 +302,7 @@ impl ThemeManager { None => match self.load_theme_from_file(name) { Ok(theme) => theme, Err(err) => { - println!["Could not load theme {}: {}", name, err]; + log::warn!("Could not load theme {}: {}", name, err); built_ins.get("").unwrap() } }, @@ -293,7 +315,7 @@ mod theme_tests { use super::*; #[test] - fn load_theme() { + fn test_can_load_builtin_theme() { let mut manager = ThemeManager::new(Some(false), Some("".to_string())); let theme = manager.load_theme("autumn"); assert_eq!( @@ -301,4 +323,113 @@ mod theme_tests { from_string("brown").ok() ); } + + #[test] + fn test_can_create_theme() { + let mut manager = ThemeManager::new(Some(false), Some("".to_string())); + let mytheme = Theme::new(HashMap::from([ + (Meaning::AlertError, _from_known("yellowgreen")), + ])); + manager.loaded_themes.insert("mytheme".to_string(), mytheme); + let theme = manager.load_theme("mytheme"); + 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() + .set_default("Guidance", "white").unwrap() + .set_default("AlertInfo", "zomp").unwrap() + .build().unwrap(); + let theme = manager.load_theme_from_config("config_theme", config).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() + .set_default("Title", "white").unwrap() + .set_default("AlertInfo", "zomp").unwrap() + .build().unwrap(); + let title_theme = manager.load_theme_from_config("title_theme", title_config).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(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(""); + 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(Level::Error), Color::Red) + } + + #[test] + fn test_can_debug_theme() { + let mut manager = ThemeManager::new(Some(true), Some("".to_string())); + let theme = manager.load_theme("autumn"); + assert_eq!( + theme.as_style(Meaning::Guidance).foreground_color, + from_string("brown").ok() + ); + } + + #[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())); + }); + } } From fbb8f240c09c87110b231ec2da2a8569be574747 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Sat, 13 Jul 2024 01:04:51 +0100 Subject: [PATCH 14/18] fix(theme): use builtin log levels and correct debug test --- Cargo.lock | 10 ++++++ crates/atuin-client/Cargo.toml | 1 + crates/atuin-client/config.toml | 2 +- crates/atuin-client/src/theme.rs | 61 ++++++++++++++++---------------- 4 files changed, 43 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b06d6c8009b..64920627aec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,6 +313,7 @@ dependencies = [ "sqlx", "strum", "strum_macros", + "testing_logger", "thiserror", "time", "tiny-bip39", @@ -4228,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 347385c2731..7e050f653a1 100644 --- a/crates/atuin-client/Cargo.toml +++ b/crates/atuin-client/Cargo.toml @@ -80,3 +80,4 @@ 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 ffd33114935..4ddd93f5df8 100644 --- a/crates/atuin-client/config.toml +++ b/crates/atuin-client/config.toml @@ -236,7 +236,7 @@ records = true ## 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, AlertWarning, AlertError, Annotation, Base, Guidance, Important, and +## 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 diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index e814d1f333b..568e411bd87 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -9,17 +9,6 @@ use std::path::PathBuf; use log; use strum_macros; -// Standard log-levels that may occur in the interface. -#[derive( - Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display, -)] -#[strum(serialize_all = "camel_case")] -pub enum Level { - Info, - Warning, - Error, -} - // Collection of settable "meanings" that can have colors set. #[derive( Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display, @@ -27,7 +16,7 @@ pub enum Level { #[strum(serialize_all = "camel_case")] pub enum Meaning { AlertInfo, - AlertWarning, + AlertWarn, AlertError, Annotation, Base, @@ -54,20 +43,20 @@ impl Theme { } pub fn get_info(&self) -> Color { - self.get_alert(Level::Info) + self.get_alert(log::Level::Info) } pub fn get_warning(&self) -> Color { - self.get_alert(Level::Warning) + self.get_alert(log::Level::Warn) } pub fn get_error(&self) -> Color { - self.get_alert(Level::Error) + 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: Level) -> Color { + pub fn get_alert(&self, severity: log::Level) -> Color { self.colors[ALERT_TYPES.get(&severity).unwrap()] } @@ -160,7 +149,7 @@ fn _from_known(name: &str) -> Color { fn make_theme(overrides: &HashMap) -> Theme { let colors = HashMap::from([ (Meaning::AlertError, Color::Red), - (Meaning::AlertWarning, Color::Yellow), + (Meaning::AlertWarn, Color::Yellow), (Meaning::AlertInfo, Color::Green), (Meaning::Annotation, Color::DarkGrey), (Meaning::Guidance, Color::Blue), @@ -180,11 +169,11 @@ fn make_theme(overrides: &HashMap) -> Theme { // is available, this gives a couple of basic options, demonstrating the use // of themes: autumn and marine lazy_static! { - static ref ALERT_TYPES: HashMap = { + static ref ALERT_TYPES: HashMap = { HashMap::from([ - (Level::Info, Meaning::AlertInfo), - (Level::Warning, Meaning::AlertWarning), - (Level::Error, Meaning::AlertError), + (log::Level::Info, Meaning::AlertInfo), + (log::Level::Warn, Meaning::AlertWarn), + (log::Level::Error, Meaning::AlertError), ]) }; static ref MEANING_FALLBACKS: HashMap = { @@ -201,7 +190,7 @@ lazy_static! { "autumn", HashMap::from([ (Meaning::AlertError, _from_known("saddlebrown")), - (Meaning::AlertWarning, _from_known("darkorange")), + (Meaning::AlertWarn, _from_known("darkorange")), (Meaning::AlertInfo, _from_known("gold")), (Meaning::Annotation, Color::DarkGrey), (Meaning::Guidance, _from_known("brown")), @@ -211,7 +200,7 @@ lazy_static! { "marine", HashMap::from([ (Meaning::AlertError, _from_known("yellowgreen")), - (Meaning::AlertWarning, _from_known("cyan")), + (Meaning::AlertWarn, _from_known("cyan")), (Meaning::AlertInfo, _from_known("turquoise")), (Meaning::Annotation, _from_known("steelblue")), (Meaning::Base, _from_known("lightsteelblue")), @@ -404,17 +393,29 @@ mod theme_tests { 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(Level::Error), Color::Red) + assert_eq!(theme.get_alert(log::Level::Error), Color::Red) } #[test] fn test_can_debug_theme() { - let mut manager = ThemeManager::new(Some(true), Some("".to_string())); - let theme = manager.load_theme("autumn"); - assert_eq!( - theme.as_style(Meaning::Guidance).foreground_color, - from_string("brown").ok() - ); + testing_logger::setup(); + [true, false].iter().for_each(|debug| { + let mut manager = ThemeManager::new(Some(*debug), Some("".to_string())); + let config = Config::builder() + .set_default("Guidance", "white").unwrap() + .set_default("AlertInfo", "xinetic").unwrap() + .build().unwrap(); + manager.load_theme_from_config("config_theme", config).unwrap(); + testing_logger::validate(|captured_logs| { + if *debug { + assert_eq!(captured_logs.len(), 1); + assert_eq!(captured_logs[0].body, "Could not load theme color: No such color in palette -> xinetic"); + assert_eq!(captured_logs[0].level, log::Level::Warn) + } else { + assert_eq!(captured_logs.len(), 0) + } + }) + }) } #[test] From 3bbb14a1182d4506ef761030a3a6b34474e2c5e3 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Sat, 13 Jul 2024 02:41:02 +0100 Subject: [PATCH 15/18] feat(theme): adds the ability to derive from a non-base theme --- crates/atuin-client/src/settings.rs | 4 + crates/atuin-client/src/theme.rs | 231 ++++++++++++++++++++++------ crates/atuin/src/command/client.rs | 2 +- 3 files changed, 190 insertions(+), 47 deletions(-) diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index 5c609c7af8d..682ba551221 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -345,6 +345,9 @@ pub struct Theme { /// 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)] @@ -380,6 +383,7 @@ impl Default for Theme { Self { name: "".to_string(), debug: None::, + max_depth: Some(10) } } } diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index 568e411bd87..e081ebcde23 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -9,7 +9,13 @@ use std::path::PathBuf; use log; 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, )] @@ -25,11 +31,31 @@ pub enum Meaning { 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, } @@ -60,8 +86,8 @@ impl Theme { self.colors[ALERT_TYPES.get(&severity).unwrap()] } - pub fn new(colors: HashMap) -> Theme { - Theme { colors } + 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 { @@ -87,7 +113,7 @@ impl Theme { // 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(colors: HashMap, debug: bool) -> Theme { + pub fn from_map(name: String, parent: Option<&Theme>, colors: HashMap, debug: bool) -> Theme { let colors: HashMap = colors .iter() .map(|(name, color)| { @@ -102,7 +128,7 @@ impl Theme { ) }) .collect(); - make_theme(&colors) + make_theme(name, parent, &colors) } } @@ -146,23 +172,26 @@ fn _from_known(name: &str) -> Color { // Boil down a meaning-color hashmap into a theme, by taking the defaults // for any unknown colors -fn make_theme(overrides: &HashMap) -> Theme { - let colors = 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), - ]) +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(colors) + Theme::new(name, parent.map_or(None, |p| Some(p.name.clone())), colors) } // Built-in themes. Rather than having extra files added before any theming @@ -209,7 +238,7 @@ lazy_static! { ), ]) .iter() - .map(|(name, theme)| (*name, make_theme(theme))) + .map(|(name, theme)| (*name, make_theme(name.to_string(), None, theme))) .collect() }; } @@ -236,7 +265,7 @@ impl ThemeManager { // 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) -> Result<&Theme, Box> { + 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( @@ -264,15 +293,39 @@ impl ThemeManager { )); let config = config_builder.build()?; - self.load_theme_from_config(name, config) + self.load_theme_from_config(name, config, max_depth) } - pub fn load_theme_from_config(&mut self, name: &str, config: Config) -> Result<&Theme, Box> { - let colors: HashMap = config - .try_deserialize() - .map_err(|e| log::warn!("failed to deserialize: {}", e)) - .unwrap(); - let theme = Theme::from_map(colors, self.debug); + 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 + }; + 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(); @@ -281,14 +334,14 @@ impl ThemeManager { // 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) -> &Theme { + 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) { + 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); @@ -306,7 +359,7 @@ mod theme_tests { #[test] fn test_can_load_builtin_theme() { let mut manager = ThemeManager::new(Some(false), Some("".to_string())); - let theme = manager.load_theme("autumn"); + let theme = manager.load_theme("autumn", None); assert_eq!( theme.as_style(Meaning::Guidance).foreground_color, from_string("brown").ok() @@ -316,11 +369,11 @@ mod theme_tests { #[test] fn test_can_create_theme() { let mut manager = ThemeManager::new(Some(false), Some("".to_string())); - let mytheme = Theme::new(HashMap::from([ + 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"); + let theme = manager.load_theme("mytheme", None); assert_eq!( theme.as_style(Meaning::AlertError).foreground_color, from_string("yellowgreen").ok() @@ -335,11 +388,15 @@ mod theme_tests { // even in the base theme. assert!(!BUILTIN_THEMES[""].colors.contains_key(&Meaning::Title)); - let config = Config::builder() - .set_default("Guidance", "white").unwrap() - .set_default("AlertInfo", "zomp").unwrap() - .build().unwrap(); - let theme = manager.load_theme_from_config("config_theme", config).unwrap(); + 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, None).unwrap(); // Correctly picks overridden color. assert_eq!( @@ -365,11 +422,15 @@ mod theme_tests { theme.as_style(Meaning::Important).foreground_color, ); - let title_config = Config::builder() - .set_default("Title", "white").unwrap() - .set_default("AlertInfo", "zomp").unwrap() - .build().unwrap(); - let title_theme = manager.load_theme_from_config("title_theme", title_config).unwrap(); + 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, None).unwrap(); assert_eq!( title_theme.as_style(Meaning::Title).foreground_color, @@ -379,7 +440,7 @@ mod theme_tests { #[test] fn test_no_fallbacks_are_circular() { - let mytheme = Theme::new(HashMap::from([])); + 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) }) @@ -388,7 +449,7 @@ mod theme_tests { #[test] fn test_can_get_colors_via_convenience_functions() { let mut manager = ThemeManager::new(Some(true), Some("".to_string())); - let theme = manager.load_theme(""); + 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); @@ -396,16 +457,94 @@ mod theme_tests { 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, Some(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, Some(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, Some(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() - .set_default("Guidance", "white").unwrap() - .set_default("AlertInfo", "xinetic").unwrap() - .build().unwrap(); - manager.load_theme_from_config("config_theme", config).unwrap(); + 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(), 1); diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index 6a406dd9de3..ce10120150d 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -135,7 +135,7 @@ impl Cmd { 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()); + let theme = theme_manager.load_theme(theme_name.as_str(), settings.theme.max_depth); match self { Self::Import(import) => import.run(&db).await, From 52299494a90563bc6a1db2f818b78706aa1aed42 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Sat, 13 Jul 2024 02:43:20 +0100 Subject: [PATCH 16/18] fix(theme): warn if the in-file name of a theme does not match the filename --- crates/atuin-client/src/theme.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index e081ebcde23..ccfd33d3e1b 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -320,6 +320,11 @@ impl ThemeManager { }, 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, From 54f821962d5aa0f7e1601fb80e2f4ff218486691 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Sat, 13 Jul 2024 03:10:27 +0100 Subject: [PATCH 17/18] chore: tidy for rustfmt and clippy --- crates/atuin-client/src/theme.rs | 233 ++++++++++++++++++++++--------- 1 file changed, 170 insertions(+), 63 deletions(-) diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index ccfd33d3e1b..f0e51cd79a6 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -1,12 +1,12 @@ 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 log; use strum_macros; static DEFAULT_MAX_DEPTH: u8 = 10; @@ -37,7 +37,7 @@ pub struct ThemeConfig { pub theme: ThemeDefinitionConfigBlock, // Colors - pub colors: HashMap + pub colors: HashMap, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -87,7 +87,11 @@ impl Theme { } pub fn new(name: String, parent: Option, colors: HashMap) -> Theme { - Theme { name, parent, colors } + Theme { + name, + parent, + colors, + } } pub fn closest_meaning<'a>(&self, meaning: &'a Meaning) -> &'a Meaning { @@ -103,7 +107,7 @@ impl Theme { // 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)]), + foreground_color: Some(self.colors[self.closest_meaning(&meaning)]), ..ContentStyle::default() } } @@ -113,7 +117,12 @@ impl Theme { // 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 { + pub fn from_map( + name: String, + parent: Option<&Theme>, + colors: HashMap, + debug: bool, + ) -> Theme { let colors: HashMap = colors .iter() .map(|(name, color)| { @@ -134,11 +143,11 @@ impl Theme { // Use palette to get a color from a string name, if possible fn from_string(name: &str) -> Result { - if name.len() == 0 { + if name.is_empty() { return Err("Empty string".into()); } - if name.starts_with("#") { - let hexcode = &name[1..]; + if let Some(name) = name.strip_prefix('#') { + let hexcode = name; let vec: Vec = hexcode .chars() .collect::>() @@ -183,7 +192,7 @@ fn make_theme(name: String, parent: Option<&Theme>, overrides: &HashMap, overrides: &HashMap (*name, *color), }) .collect(); - Theme::new(name, parent.map_or(None, |p| Some(p.name.clone())), colors) + Theme::new(name, parent.map(|p| p.name.clone()), colors) } // Built-in themes. Rather than having extra files added before any theming @@ -265,7 +274,11 @@ impl ThemeManager { // 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> { + 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( @@ -296,14 +309,26 @@ impl ThemeManager { 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> { + 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() }) + format!( + "Failed to deserialize theme: {}", + if debug { + e.to_string() + } else { + "set theme debug on for more info".to_string() + } + ), ))) } }; @@ -314,23 +339,22 @@ impl ThemeManager { 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 + } + 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); + 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 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(); @@ -374,9 +398,11 @@ mod theme_tests { #[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")), - ])); + 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!( @@ -393,15 +419,23 @@ mod theme_tests { // even in the base theme. assert!(!BUILTIN_THEMES[""].colors.contains_key(&Meaning::Title)); - let config = Config::builder().add_source(ConfigFile::from_str(" + 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, None).unwrap(); + ", + FileFormat::Toml, + )) + .build() + .unwrap(); + let theme = manager + .load_theme_from_config("config_theme", config, 1) + .unwrap(); // Correctly picks overridden color. assert_eq!( @@ -427,15 +461,23 @@ mod theme_tests { theme.as_style(Meaning::Important).foreground_color, ); - let title_config = Config::builder().add_source(ConfigFile::from_str(" + 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, None).unwrap(); + ", + 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, @@ -446,9 +488,9 @@ mod theme_tests { #[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) - }) + MEANING_FALLBACKS + .iter() + .for_each(|pair| assert_eq!(mytheme.closest_meaning(pair.0), &Meaning::Base)) } #[test] @@ -469,61 +511,91 @@ mod theme_tests { 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(" + 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, Some(1)).unwrap(); + ", + 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, + 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(" + 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, Some(1)).unwrap(); + ", + 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, + 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, + unsolarized_theme + .as_style(Meaning::Guidance) + .foreground_color, from_string("white").ok() ); - testing_logger::validate(|captured_logs| { - assert_eq!(captured_logs.len(), 0) - }); + 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(" + 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, Some(1)).unwrap(); + ", + 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, + nunsolarized_theme + .as_style(Meaning::Guidance) + .foreground_color, Some(Color::Blue) ); @@ -541,20 +613,36 @@ mod theme_tests { 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(" + 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(); + ", + 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(), 1); - assert_eq!(captured_logs[0].body, "Could not load theme color: No such color in palette -> xinetic"); - assert_eq!(captured_logs[0].level, log::Level::Warn) + 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) } @@ -564,17 +652,36 @@ mod theme_tests { #[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("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())); - }); + ["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 }); + 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())); + assert_eq!( + from_string(inp), + Err("Could not parse 3 hex values from string".into()) + ); }); } } From abe4d1d1b4a8c63a2d16b044281af8ee8aa4c7d3 Mon Sep 17 00:00:00 2001 From: Phil Weir Date: Sat, 13 Jul 2024 03:11:42 +0100 Subject: [PATCH 18/18] chore: tidy for rustfmt and clippy --- crates/atuin-client/src/settings.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index 682ba551221..b64418cdc25 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -347,7 +347,7 @@ pub struct Theme { pub debug: Option, /// How many levels of parenthood will be traversed if needed - pub max_depth: Option + pub max_depth: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -383,7 +383,7 @@ impl Default for Theme { Self { name: "".to_string(), debug: None::, - max_depth: Some(10) + max_depth: Some(10), } } }