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