use nu_ansi_term::Style;
use crate::fs::File;
mod ui_styles;
pub use self::ui_styles::{DateAge, UiStyles};
pub mod key_registry;
mod lsc;
pub use self::lsc::{LSColors, render_style_to_lx};
mod default_theme;
mod error;
pub use self::error::ThemeError;
mod oklab;
mod smooth;
pub use self::smooth::age_to_position;
#[derive(PartialEq, Eq, Debug)]
pub struct Options {
pub use_colours: UseColours,
pub gradient: GradientFlags,
pub definitions: Definitions,
pub theme_override: Option<String>,
}
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
#[allow(clippy::struct_excessive_bools)] pub struct GradientFlags {
pub size: bool,
pub modified: bool,
pub accessed: bool,
pub changed: bool,
pub created: bool,
pub smooth: bool,
}
impl GradientFlags {
pub const ALL: Self = Self {
size: true,
modified: true,
accessed: true,
changed: true,
created: true,
smooth: true,
};
pub const NONE: Self = Self {
size: false,
modified: false,
accessed: false,
changed: false,
created: false,
smooth: false,
};
}
impl Default for GradientFlags {
fn default() -> Self {
Self::ALL
}
}
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub enum UseColours {
Always,
Automatic,
Never,
}
#[derive(PartialEq, Eq, Debug, Default)]
pub struct Definitions {
pub ls: Option<String>,
}
pub struct Theme {
pub ui: UiStyles,
pub exts: Box<dyn FileColours>,
}
impl Theme {
pub fn test_default() -> Self {
Self {
ui: UiStyles::default(),
exts: Box::new(NoFileColours),
}
}
}
impl Options {
#[allow(trivial_casts)] pub fn to_theme(&self, isatty: bool) -> Result<Theme, ThemeError> {
if let Some(ref name) = self.theme_override {
let empty_cfg = crate::config::Config::default();
let cfg = crate::config::config().unwrap_or(&empty_cfg);
Self::validate_theme_name(name, cfg)?;
}
if self.use_colours == UseColours::Never
|| (self.use_colours == UseColours::Automatic && !isatty)
{
let ui = UiStyles::plain();
let exts = Box::new(NoFileColours);
return Ok(Theme { ui, exts });
}
let mut ui = if self.theme_override.is_some() {
UiStyles::plain()
} else {
UiStyles::default_theme()
};
let mut exts = self.definitions.parse_colour_vars(&mut ui);
let empty_cfg = crate::config::Config::default();
let cfg = crate::config::config().unwrap_or(&empty_cfg);
self.apply_config_theme(cfg, &mut ui, &mut exts)?;
ui.apply_gradient_flags(self.gradient);
let exts: Box<dyn FileColours> = if exts.is_non_empty() {
Box::new(exts)
} else {
Box::new(NoFileColours)
};
Ok(Theme { ui, exts })
}
fn validate_theme_name(name: &str, cfg: &crate::config::Config) -> Result<(), ThemeError> {
let mut current = Some(name.to_string());
let mut visited = Vec::new();
while let Some(ref tname) = current {
if visited.contains(tname) {
return Ok(()); }
visited.push(tname.clone());
if crate::config::is_builtin_theme(tname) {
return Ok(()); } else if let Some(theme) = cfg.theme.get(tname) {
current = theme.inherits.clone();
} else {
return Err(ThemeError::Unknown {
name: tname.clone(),
});
}
}
Ok(())
}
fn apply_config_theme(
&self,
cfg: &crate::config::Config,
ui: &mut UiStyles,
exts: &mut ExtensionMappings,
) -> Result<(), ThemeError> {
let Some(ref name) = self.theme_override else {
return Ok(()); };
let mut chain: Vec<&crate::config::ThemeDef> = Vec::new();
let mut visited: Vec<String> = Vec::new();
let mut current = Some(name.clone());
while let Some(ref tname) = current {
if visited.contains(tname) {
visited.push(tname.clone());
let chain_str = visited.join(" \u{2192} ");
return Err(ThemeError::Cycle { chain: chain_str });
}
visited.push(tname.clone());
if tname == "exa" {
*ui = UiStyles::default_theme();
let exa_style = crate::config::compiled_exa_style();
Self::apply_style(&exa_style, cfg, exts);
current = None;
} else if tname == "lx-256" {
*ui = UiStyles::lx_256_theme();
let exa_style = crate::config::compiled_exa_style();
Self::apply_style(&exa_style, cfg, exts);
current = None;
} else if tname == "lx-24bit" {
*ui = UiStyles::lx_24bit_theme();
let exa_style = crate::config::compiled_exa_style();
Self::apply_style(&exa_style, cfg, exts);
current = None;
} else if let Some(theme) = cfg.theme.get(tname) {
chain.push(theme);
current = theme.inherits.clone();
} else {
return Err(ThemeError::Unknown {
name: tname.clone(),
});
}
}
for theme in chain.into_iter().rev() {
Self::apply_theme_def(theme, cfg, ui, exts);
}
Ok(())
}
fn apply_theme_def(
theme: &crate::config::ThemeDef,
cfg: &crate::config::Config,
ui: &mut UiStyles,
exts: &mut ExtensionMappings,
) {
use log::*;
let mut entries: Vec<(&String, &String)> = theme.ui.iter().collect();
entries.sort_by_key(|(k, _)| theme_key_precedence(k));
for (key, value) in entries {
if !ui.set_config(key, value) {
warn!("Unknown theme key '{key}'; ignoring");
}
}
if let Some(ref style_name) = theme.use_style {
if let Some(style) = crate::config::resolve_style(style_name) {
Self::apply_style(&style, cfg, exts);
} else {
warn!("Style set '{style_name}' not found in config; ignoring");
}
}
}
fn apply_style(
style: &crate::config::StyleDef,
_cfg: &crate::config::Config,
exts: &mut ExtensionMappings,
) {
use log::*;
let classes = crate::config::resolve_classes();
for (class_name, colour_str) in &style.classes {
if let Some(patterns) = classes.get(class_name) {
let colour = lsc::parse_style(colour_str);
for pattern in patterns {
match glob::Pattern::new(pattern) {
Ok(pat) => exts.add(pat, colour),
Err(e) => warn!("Bad pattern '{pattern}' in class '{class_name}': {e}"),
}
}
} else {
warn!("Unknown class '{class_name}' in style; ignoring");
}
}
for (key, value) in &style.patterns {
match glob::Pattern::new(key) {
Ok(pat) => exts.add(pat, lsc::parse_style(value)),
Err(e) => warn!("Bad style glob '{key}': {e}"),
}
}
}
}
fn theme_key_precedence(key: &str) -> u8 {
const COLUMNS: &[&str] = &["modified", "accessed", "changed", "created"];
const NEUTRAL: u8 = 10;
if key == "date" {
return 0;
}
let Some(rest) = key.strip_prefix("date-") else {
return NEUTRAL;
};
if COLUMNS.contains(&rest) {
return 2;
}
for col in COLUMNS {
if rest
.strip_prefix(col)
.and_then(|r| r.strip_prefix('-'))
.is_some()
{
return 3;
}
}
1
}
#[cfg(test)]
mod theme_key_precedence_test {
use super::theme_key_precedence;
#[test]
fn bulk_date_is_most_generic() {
assert_eq!(theme_key_precedence("date"), 0);
}
#[test]
fn bulk_per_tier_is_next() {
assert_eq!(theme_key_precedence("date-now"), 1);
assert_eq!(theme_key_precedence("date-today"), 1);
assert_eq!(theme_key_precedence("date-flat"), 1);
}
#[test]
fn bulk_per_column_overrides_bulk_per_tier() {
assert_eq!(theme_key_precedence("date-modified"), 2);
assert_eq!(theme_key_precedence("date-accessed"), 2);
}
#[test]
fn per_column_per_tier_is_most_specific() {
assert_eq!(theme_key_precedence("date-modified-now"), 3);
assert_eq!(theme_key_precedence("date-accessed-flat"), 3);
assert_eq!(theme_key_precedence("date-created-old"), 3);
}
#[test]
fn non_date_keys_land_in_neutral_bucket() {
assert_eq!(theme_key_precedence("directory"), 10);
assert_eq!(theme_key_precedence("size-number-byte"), 10);
assert_eq!(theme_key_precedence("permissions-user-read"), 10);
}
#[test]
fn buckets_order_correctly() {
assert!(theme_key_precedence("date") < theme_key_precedence("date-now"));
assert!(theme_key_precedence("date-now") < theme_key_precedence("date-modified"));
assert!(theme_key_precedence("date-modified") < theme_key_precedence("date-modified-now"));
}
}
#[cfg(test)]
mod apply_theme_def_test {
use super::*;
use crate::config::{Config, ThemeDef};
use crate::theme::lsc::parse_style;
use crate::theme::ui_styles::UiStyles;
use std::collections::HashMap;
fn theme_def_with<I, K, V>(entries: I) -> ThemeDef
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let mut ui = HashMap::new();
for (k, v) in entries {
ui.insert(k.into(), v.into());
}
ThemeDef {
description: None,
inherits: None,
use_style: None,
ui,
}
}
fn apply(theme: &ThemeDef) -> UiStyles {
let mut ui = UiStyles::default();
let mut exts = ExtensionMappings::default();
let cfg = Config::default();
Options::apply_theme_def(theme, &cfg, &mut ui, &mut exts);
ui
}
#[test]
fn per_column_override_wins_over_bulk_date() {
let theme = theme_def_with([("date", "blue"), ("date-modified-now", "bright green")]);
let ui = apply(&theme);
assert_eq!(ui.date_modified.now, parse_style("bright green"));
assert_eq!(ui.date_modified.today, parse_style("blue"));
assert_eq!(ui.date_accessed.now, parse_style("blue"));
assert_eq!(ui.date_changed.now, parse_style("blue"));
assert_eq!(ui.date_created.now, parse_style("blue"));
}
#[test]
fn full_precedence_chain_is_honoured() {
let theme = theme_def_with([
("date", "blue"), ("date-now", "bright cyan"), ("date-modified", "white"), ("date-modified-now", "bright green"), ]);
let ui = apply(&theme);
assert_eq!(ui.date_modified.now, parse_style("bright green"));
assert_eq!(ui.date_modified.today, parse_style("white"));
assert_eq!(ui.date_modified.flat, parse_style("white"));
assert_eq!(ui.date_accessed.now, parse_style("bright cyan"));
assert_eq!(ui.date_accessed.today, parse_style("blue"));
assert_eq!(ui.date_changed.now, parse_style("bright cyan"));
assert_eq!(ui.date_changed.today, parse_style("blue"));
assert_eq!(ui.date_created.now, parse_style("bright cyan"));
assert_eq!(ui.date_created.today, parse_style("blue"));
}
#[test]
fn each_column_can_be_themed_independently() {
let theme = theme_def_with([
("date-modified-now", "cyan"),
("date-accessed-now", "green"),
("date-changed-now", "magenta"),
("date-created-now", "red"),
]);
let ui = apply(&theme);
assert_eq!(ui.date_modified.now, parse_style("cyan"));
assert_eq!(ui.date_accessed.now, parse_style("green"));
assert_eq!(ui.date_changed.now, parse_style("magenta"));
assert_eq!(ui.date_created.now, parse_style("red"));
}
#[test]
fn per_column_bulk_sets_every_tier_on_one_column() {
let theme = theme_def_with([("date-modified", "red")]);
let ui = apply(&theme);
let red = parse_style("red");
assert_eq!(ui.date_modified.now, red);
assert_eq!(ui.date_modified.today, red);
assert_eq!(ui.date_modified.week, red);
assert_eq!(ui.date_modified.month, red);
assert_eq!(ui.date_modified.year, red);
assert_eq!(ui.date_modified.old, red);
assert_eq!(ui.date_modified.flat, red);
assert_eq!(ui.date_accessed.now, Style::default());
assert_eq!(ui.date_changed.now, Style::default());
assert_eq!(ui.date_created.now, Style::default());
}
#[test]
fn every_per_column_tier_key_parses_and_applies() {
let tiers = &["now", "today", "week", "month", "year", "old", "flat"];
let columns = &["modified", "accessed", "changed", "created"];
for &col in columns {
for &tier in tiers {
let key = format!("date-{col}-{tier}");
let theme = theme_def_with([(key.clone(), "red")]);
let ui = apply(&theme);
let red = parse_style("red");
let actual = match (col, tier) {
("modified", "now") => ui.date_modified.now,
("modified", "today") => ui.date_modified.today,
("modified", "week") => ui.date_modified.week,
("modified", "month") => ui.date_modified.month,
("modified", "year") => ui.date_modified.year,
("modified", "old") => ui.date_modified.old,
("modified", "flat") => ui.date_modified.flat,
("accessed", "now") => ui.date_accessed.now,
("accessed", "today") => ui.date_accessed.today,
("accessed", "week") => ui.date_accessed.week,
("accessed", "month") => ui.date_accessed.month,
("accessed", "year") => ui.date_accessed.year,
("accessed", "old") => ui.date_accessed.old,
("accessed", "flat") => ui.date_accessed.flat,
("changed", "now") => ui.date_changed.now,
("changed", "today") => ui.date_changed.today,
("changed", "week") => ui.date_changed.week,
("changed", "month") => ui.date_changed.month,
("changed", "year") => ui.date_changed.year,
("changed", "old") => ui.date_changed.old,
("changed", "flat") => ui.date_changed.flat,
("created", "now") => ui.date_created.now,
("created", "today") => ui.date_created.today,
("created", "week") => ui.date_created.week,
("created", "month") => ui.date_created.month,
("created", "year") => ui.date_created.year,
("created", "old") => ui.date_created.old,
("created", "flat") => ui.date_created.flat,
_ => unreachable!(),
};
assert_eq!(
actual, red,
"theme key {key:?} did not apply — missing set_config arm?"
);
}
}
}
}
impl Definitions {
fn parse_colour_vars(&self, colours: &mut UiStyles) -> ExtensionMappings {
use log::*;
let mut exts = ExtensionMappings::default();
if let Some(lsc) = &self.ls {
LSColors(lsc).each_pair(|pair| {
if !colours.set_ls(&pair) {
match glob::Pattern::new(pair.key) {
Ok(pat) => {
exts.add(pat, pair.to_style());
}
Err(e) => {
warn!("Couldn't parse glob pattern {:?}: {}", pair.key, e);
}
}
}
});
}
exts
}
}
pub trait FileColours: std::marker::Sync {
fn colour_file(&self, file: &File<'_>) -> Option<Style>;
}
#[derive(PartialEq, Debug)]
struct NoFileColours;
impl FileColours for NoFileColours {
fn colour_file(&self, _file: &File<'_>) -> Option<Style> {
None
}
}
impl<A, B> FileColours for (A, B)
where
A: FileColours,
B: FileColours,
{
fn colour_file(&self, file: &File<'_>) -> Option<Style> {
self.0
.colour_file(file)
.or_else(|| self.1.colour_file(file))
}
}
#[derive(PartialEq, Debug, Default)]
struct ExtensionMappings {
mappings: Vec<(glob::Pattern, Style)>,
}
impl FileColours for ExtensionMappings {
fn colour_file(&self, file: &File<'_>) -> Option<Style> {
self.mappings
.iter()
.rev()
.find(|t| t.0.matches(&file.name))
.map(|t| t.1)
}
}
impl ExtensionMappings {
fn is_non_empty(&self) -> bool {
!self.mappings.is_empty()
}
fn add(&mut self, pattern: glob::Pattern, style: Style) {
self.mappings.push((pattern, style));
}
}
impl Theme {
pub fn size_style(&self, bytes: u64, prefix: Option<unit_prefix::Prefix>) -> Style {
use unit_prefix::Prefix::*;
if let Some(lut) = self.ui.smooth_luts.size.as_deref() {
let position = smooth::size_to_position(bytes);
let bucket = (position * 255.0).round() as usize;
return lut[bucket.min(255)];
}
match prefix {
Some(Kilo | Kibi) => self.ui.size.number_kilo,
Some(Mega | Mebi) => self.ui.size.number_mega,
Some(Giga | Gibi) => self.ui.size.number_giga,
Some(_) => self.ui.size.number_huge,
None => self.ui.size.number_byte,
}
}
pub fn unit_style(&self, prefix: Option<unit_prefix::Prefix>) -> Style {
use unit_prefix::Prefix::*;
match prefix {
Some(Kilo | Kibi) => self.ui.size.unit_kilo,
Some(Mega | Mebi) => self.ui.size.unit_mega,
Some(Giga | Gibi) => self.ui.size.unit_giga,
Some(_) => self.ui.size.unit_huge,
None => self.ui.size.unit_byte,
}
}
}
impl Theme {
pub fn colour_file(&self, file: &File<'_>) -> Style {
self.exts
.colour_file(file)
.unwrap_or(self.ui.filekinds.normal)
}
}
pub fn apply_overlay(mut base: Style, overlay: Style) -> Style {
if let Some(fg) = overlay.foreground {
base.foreground = Some(fg);
}
if let Some(bg) = overlay.background {
base.background = Some(bg);
}
if overlay.is_bold {
base.is_bold = true;
}
if overlay.is_dimmed {
base.is_dimmed = true;
}
if overlay.is_italic {
base.is_italic = true;
}
if overlay.is_underline {
base.is_underline = true;
}
if overlay.is_blink {
base.is_blink = true;
}
if overlay.is_reverse {
base.is_reverse = true;
}
if overlay.is_hidden {
base.is_hidden = true;
}
if overlay.is_strikethrough {
base.is_strikethrough = true;
}
base
}
#[cfg(test)]
mod customs_test {
use super::*;
use crate::theme::ui_styles::UiStyles;
use nu_ansi_term::Color::*;
macro_rules! test {
($name:ident: ls $ls:expr => colours $expected:ident -> $process_expected:expr) => {
#[test]
fn $name() {
let mut $expected = UiStyles::default();
$process_expected();
let definitions = Definitions {
ls: Some($ls.into()),
};
let mut result = UiStyles::default();
let _exts = definitions.parse_colour_vars(&mut result);
assert_eq!($expected, result);
}
};
($name:ident: ls $ls:expr => exts $mappings:expr) => {
#[test]
fn $name() {
let mappings: Vec<(glob::Pattern, Style)> = $mappings
.iter()
.map(|t| (glob::Pattern::new(t.0).unwrap(), t.1))
.collect();
let definitions = Definitions {
ls: Some($ls.into()),
};
let result = definitions.parse_colour_vars(&mut UiStyles::default());
assert_eq!(ExtensionMappings { mappings }, result);
}
};
}
test!(ls_di: ls "di=31" => colours c -> { c.filekinds.directory = Red.normal(); });
test!(ls_ex: ls "ex=32" => colours c -> { c.filekinds.executable = Green.normal(); });
test!(ls_fi: ls "fi=33" => colours c -> { c.filekinds.normal = Yellow.normal(); });
test!(ls_pi: ls "pi=34" => colours c -> { c.filekinds.pipe = Blue.normal(); });
test!(ls_so: ls "so=35" => colours c -> { c.filekinds.socket = Purple.normal(); });
test!(ls_bd: ls "bd=36" => colours c -> { c.filekinds.block_device = Cyan.normal(); });
test!(ls_cd: ls "cd=35" => colours c -> { c.filekinds.char_device = Purple.normal(); });
test!(ls_ln: ls "ln=34" => colours c -> { c.filekinds.symlink = Blue.normal(); });
test!(ls_or: ls "or=33" => colours c -> { c.broken_symlink = Yellow.normal(); });
test!(ls_uu: ls "uu=38;5;117" => exts [ ("uu", Fixed(117).normal()) ]);
test!(ls_un: ls "un=38;5;118" => exts [ ("un", Fixed(118).normal()) ]);
test!(ls_gu: ls "gu=38;5;119" => exts [ ("gu", Fixed(119).normal()) ]);
test!(ls_gn: ls "gn=38;5;120" => exts [ ("gn", Fixed(120).normal()) ]);
test!(ls_txt: ls "*.txt=31" => exts [ ("*.txt", Red.normal()) ]);
test!(ls_mp3: ls "*.mp3=38;5;135" => exts [ ("*.mp3", Fixed(135).normal()) ]);
test!(ls_mak: ls "Makefile=1;32;4" => exts [ ("Makefile", Green.bold().underline()) ]);
test!(ls_multi: ls "*.txt=31:*.rtf=32" => exts [
("*.txt", Red.normal()), ("*.rtf", Green.normal())
]);
test!(ls_five: ls "1*1=31:2*2=32:3*3=1;33:4*4=34;1:5*5=35;4" => exts [
("1*1", Red.normal()), ("2*2", Green.normal()), ("3*3", Yellow.bold()),
("4*4", Blue.bold()), ("5*5", Purple.underline())
]);
test!(ls_overwrite: ls "pi=31:pi=32:pi=33" => colours c -> {
c.filekinds.pipe = Yellow.normal();
});
}
#[cfg(test)]
#[cfg(unix)]
mod uid_gid_theme_test {
use super::*;
use crate::theme::ui_styles::UiStyles;
use nu_ansi_term::Color::*;
fn theme_with(ui: UiStyles) -> Theme {
Theme {
ui,
exts: Box::new(NoFileColours),
}
}
#[test]
fn theme_exposes_uid_styles() {
let mut ui = UiStyles::default_theme();
ui.users.uid_you = Red.normal();
ui.users.uid_someone_else = Green.normal();
let theme = theme_with(ui);
assert_eq!(theme.ui.users.uid_you, Red.normal());
assert_eq!(theme.ui.users.uid_someone_else, Green.normal());
}
}