mirror of
https://github.com/atuinsh/atuin.git
synced 2024-11-22 00:03:49 +01:00
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:
parent
44d8f6dffd
commit
61c6e5e46a
109
Cargo.lock
generated
109
Cargo.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -20,5 +20,6 @@ pub mod record;
|
||||
pub mod register;
|
||||
pub mod secrets;
|
||||
pub mod settings;
|
||||
pub mod theme;
|
||||
|
||||
mod utils;
|
||||
|
@ -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")
|
||||
|
687
crates/atuin-client/src/theme.rs
Normal file
687
crates/atuin-client/src/theme.rs
Normal 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())
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@ -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!("▮");
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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(())
|
||||
|
Loading…
Reference in New Issue
Block a user