feat(tui): Customizable Themes (#2236)

* wip: add theme

* feat(theme): basic theming approach

* feat(theme): adds theming support

* fix: split out palette without compact inspector

* fix(theme): tidy up implementation

* fix(theme): correct yaml to toml

* fix(theme): typo in comments

* chore: cheer up clippy

* fix(themes): ensure tests cannot hit real loading directory

* chore: rustfmt

* chore: rebase

* feat(themes): add rgb hexcode support

* fix(theme): add tests

* fix(theme): use builtin log levels and correct debug test

* feat(theme): adds the ability to derive from a non-base theme

* fix(theme): warn if the in-file name of a theme does not match the filename

* chore: tidy for rustfmt and clippy

* chore: tidy for rustfmt and clippy
This commit is contained in:
P T Weir 2024-07-15 10:18:46 +01:00 committed by GitHub
parent 44d8f6dffd
commit 61c6e5e46a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 971 additions and 55 deletions

109
Cargo.lock generated
View File

@ -125,6 +125,15 @@ version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arboard"
version = "3.4.0"
@ -269,6 +278,7 @@ dependencies = [
"base64 0.22.1",
"clap",
"config",
"crossterm",
"crypto_secretbox",
"directories",
"eyre",
@ -280,9 +290,11 @@ dependencies = [
"indicatif",
"interim",
"itertools 0.12.1",
"lazy_static",
"log",
"memchr",
"minspan",
"palette",
"pretty_assertions",
"rand",
"regex",
@ -299,6 +311,9 @@ dependencies = [
"shellexpand",
"sql-builder",
"sqlx",
"strum",
"strum_macros",
"testing_logger",
"thiserror",
"time",
"tiny-bip39",
@ -638,6 +653,12 @@ version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]]
name = "bytemuck"
version = "1.16.1"
@ -1341,6 +1362,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "fast-srgb8"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
[[package]]
name = "fastrand"
version = "2.1.0"
@ -2639,6 +2666,31 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "palette"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
dependencies = [
"approx",
"fast-srgb8",
"palette_derive",
"phf",
"serde",
]
[[package]]
name = "palette_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
dependencies = [
"by_address",
"proc-macro2",
"quote",
"syn 2.0.70",
]
[[package]]
name = "parking_lot"
version = "0.12.3"
@ -2719,6 +2771,48 @@ dependencies = [
"indexmap 2.2.6",
]
[[package]]
name = "phf"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_macros"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.70",
]
[[package]]
name = "phf_shared"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.5"
@ -3700,6 +3794,12 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]]
name = "siphasher"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "sketches-ddsketch"
version = "0.2.2"
@ -4129,6 +4229,15 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "testing_logger"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d92b727cb45d33ae956f7f46b966b25f1bc712092aeef9dba5ac798fc89f720"
dependencies = [
"log",
]
[[package]]
name = "thiserror"
version = "1.0.61"

View File

@ -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"

View File

@ -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

View File

@ -20,5 +20,6 @@ pub mod record;
pub mod register;
pub mod secrets;
pub mod settings;
pub mod theme;
mod utils;

View File

@ -338,6 +338,18 @@ pub struct Preview {
pub strategy: PreviewStrategy,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Theme {
/// Name of desired theme ("" for base)
pub name: String,
/// Whether any available additional theme debug should be shown
pub debug: Option<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
@ -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")

View File

@ -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())
);
});
}
}

View File

@ -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!("");

View File

@ -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,

View File

@ -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 {

View File

@ -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());
}
}

View File

@ -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

View File

@ -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;

View File

@ -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(())