diff --git a/doc/lsd.md b/doc/lsd.md index 78a120465..d9b2f919d 100644 --- a/doc/lsd.md +++ b/doc/lsd.md @@ -175,3 +175,6 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich `SHELL_COMPLETIONS_DIR` or `OUT_DIR` : Used to specify the directory for generating a shell completions file. If neither are set, no completions file will be generated. The directory will be created if it does not exist. + +`TERM_BACKGROUND` +: Controls which theme is loaded when `color.theme` is set to a dark/light map in the config file. Set to `light` to select the light theme; any other value or unset selects the dark theme. diff --git a/doc/samples/config-sample.yaml b/doc/samples/config-sample.yaml index 3da640e5d..ff9a5632b 100644 --- a/doc/samples/config-sample.yaml +++ b/doc/samples/config-sample.yaml @@ -27,8 +27,15 @@ color: when: auto # How to colorize the output. # When "classic" is set, this is set to "no-color". - # Possible values: default, custom + # Possible values: default, custom, or a dark/light map (see below). # When "custom" is set, lsd will look in the config directory for `colors.yaml`. + # To use different themes for dark and light terminals, specify a map: + # theme: + # dark: my-dark-colors # resolves to /my-dark-colors.yaml + # light: my-light-colors # resolves to /my-light-colors.yaml + # Either key may be omitted; omitting falls back to the built-in default for + # that mode. The active mode is read from the TERM_BACKGROUND env var + # ("light" → light mode; anything else or unset → dark mode). theme: default # == Date == diff --git a/src/color.rs b/src/color.rs index a88d0e076..ccf44b4df 100644 --- a/src/color.rs +++ b/src/color.rs @@ -6,7 +6,10 @@ use std::path::Path; pub use crate::flags::color::ThemeOption; use crate::git::GitStatus; use crate::print_output; -use crate::theme::{Theme, color::ColorTheme}; +use crate::theme::{ + Theme, + color::{ColorTheme, is_dark_mode}, +}; use jiff::{Span, SpanTotal, Timestamp, ToSpan, Unit}; #[allow(dead_code)] @@ -212,11 +215,22 @@ impl Colors { .unwrap_or_default(), ) } + ThemeOption::DualCustom { + ref dark, + ref light, + } => { + let name = if is_dark_mode() { dark } else { light }; + Some(match name { + Some(n) => Theme::from_path::(n).unwrap_or_default(), + None => ColorTheme::default(), + }) + } }; let lscolors = match t { - ThemeOption::Default | ThemeOption::Custom | ThemeOption::CustomLegacy(_) => { - Some(LsColors::from_env().unwrap_or_default()) - } + ThemeOption::Default + | ThemeOption::Custom + | ThemeOption::CustomLegacy(_) + | ThemeOption::DualCustom { .. } => Some(LsColors::from_env().unwrap_or_default()), _ => None, }; diff --git a/src/config_file.rs b/src/config_file.rs index 3a5dbe53b..6e0d46501 100644 --- a/src/config_file.rs +++ b/src/config_file.rs @@ -242,8 +242,15 @@ color: when: auto # How to colorize the output. # When "classic" is set, this is set to "no-color". - # Possible values: default, custom + # Possible values: default, custom, or a dark/light map (see below). # When "custom" is set, lsd will look in the config directory for `colors.yaml`. + # To use different themes for dark and light terminals, specify a map: + # theme: + # dark: my-dark-colors # resolves to /my-dark-colors.yaml + # light: my-light-colors # resolves to /my-light-colors.yaml + # Either key may be omitted; omitting falls back to the built-in default for + # that mode. The active mode is read from the TERM_BACKGROUND env var + # ("light" → light mode; anything else or unset → dark mode). theme: default # == Date == diff --git a/src/flags/color.rs b/src/flags/color.rs index f8505e172..5af45835f 100644 --- a/src/flags/color.rs +++ b/src/flags/color.rs @@ -42,6 +42,13 @@ pub enum ThemeOption { NoLscolors, CustomLegacy(String), Custom, + /// Load separate theme files depending on whether the terminal is in dark or light mode. + /// Each path follows the same resolution rules as `Custom` (relative to config dir). + /// A `None` value falls back to the built-in default for that mode. + DualCustom { + dark: Option, + light: Option, + }, } impl ThemeOption { @@ -65,11 +72,13 @@ impl<'de> de::Deserialize<'de> for ThemeOption { { struct ThemeOptionVisitor; - impl Visitor<'_> for ThemeOptionVisitor { + impl<'de> Visitor<'de> for ThemeOptionVisitor { type Value = ThemeOption; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("`default` or ") + formatter.write_str( + "`default`, `custom`, or a map with `light` and/or `dark` theme paths", + ) } fn visit_str(self, value: &str) -> Result @@ -82,9 +91,27 @@ impl<'de> de::Deserialize<'de> for ThemeOption { str => Ok(ThemeOption::CustomLegacy(str.to_string())), } } + + fn visit_map(self, mut map: M) -> Result + where + M: de::MapAccess<'de>, + { + let mut dark = None; + let mut light = None; + while let Some(key) = map.next_key::()? { + match key.as_str() { + "dark" => dark = Some(map.next_value::()?), + "light" => light = Some(map.next_value::()?), + other => { + return Err(de::Error::unknown_field(other, &["dark", "light"])); + } + } + } + Ok(ThemeOption::DualCustom { dark, light }) + } } - deserializer.deserialize_identifier(ThemeOptionVisitor) + deserializer.deserialize_any(ThemeOptionVisitor) } } @@ -320,4 +347,59 @@ mod test_theme_option { c.classic = Some(true); assert_eq!(ThemeOption::NoColor, ThemeOption::from_config(&c)); } + + #[test] + fn test_deserialize_dual_custom_both() { + let yaml = "dark: my-dark\nlight: my-light\n"; + let t: ThemeOption = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + ThemeOption::DualCustom { + dark: Some("my-dark".to_string()), + light: Some("my-light".to_string()), + }, + t + ); + } + + #[test] + fn test_deserialize_dual_custom_dark_only() { + let yaml = "dark: my-dark\n"; + let t: ThemeOption = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + ThemeOption::DualCustom { + dark: Some("my-dark".to_string()), + light: None, + }, + t + ); + } + + #[test] + fn test_deserialize_dual_custom_light_only() { + let yaml = "light: my-light\n"; + let t: ThemeOption = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + ThemeOption::DualCustom { + dark: None, + light: Some("my-light".to_string()), + }, + t + ); + } + + #[test] + fn test_deserialize_string_variants_still_work() { + assert_eq!( + ThemeOption::Default, + serde_yaml::from_str::("default").unwrap() + ); + assert_eq!( + ThemeOption::Custom, + serde_yaml::from_str::("custom").unwrap() + ); + assert_eq!( + ThemeOption::CustomLegacy("old-path".to_string()), + serde_yaml::from_str::("old-path").unwrap() + ); + } } diff --git a/src/theme/color.rs b/src/theme/color.rs index 91bda92d2..cb7223b49 100644 --- a/src/theme/color.rs +++ b/src/theme/color.rs @@ -439,10 +439,24 @@ impl Default for GitStatus { } } +/// Returns `true` when the terminal background is dark. +/// +/// Reads `TERM_BACKGROUND`: `"light"` (case-insensitive) → false, anything +/// else or unset → true. Set `TERM_BACKGROUND=light` in your shell config to +/// enable light-mode theme selection. +pub(crate) fn is_dark_mode() -> bool { + std::env::var("TERM_BACKGROUND") + .map(|v| v.to_ascii_lowercase() != "light") + .unwrap_or(true) +} + impl Default for ColorTheme { fn default() -> Self { - // TODO(zwpaper): check terminal color and return light or dark - Self::default_dark() + if is_dark_mode() { + Self::default_dark() + } else { + Self::default_light() + } } } @@ -462,6 +476,89 @@ impl ColorTheme { git_status: Default::default(), } } + + pub fn default_light() -> Self { + ColorTheme { + user: Color::AnsiValue(26), // DodgerBlue3 + group: Color::AnsiValue(28), // Green4 + permission: Permission { + read: Color::DarkGreen, + write: Color::DarkYellow, + exec: Color::DarkRed, + exec_sticky: Color::AnsiValue(5), + no_access: Color::AnsiValue(240), // DarkGrey + octal: Color::AnsiValue(6), + acl: Color::DarkCyan, + context: Color::Cyan, + }, + attributes: Attributes::default(), + file_type: FileType { + file: File { + exec_uid: Color::AnsiValue(28), // Green4 + uid_no_exec: Color::AnsiValue(100), // Yellow4 + exec_no_uid: Color::AnsiValue(28), // Green4 + no_exec_no_uid: Color::AnsiValue(100), // Yellow4 + }, + dir: Dir { + uid: Color::AnsiValue(20), // Blue3 + no_uid: Color::AnsiValue(20), // Blue3 + }, + symlink: Symlink { + default: Color::AnsiValue(30), // Teal + broken: Color::AnsiValue(124), // Red3 + missing_target: Color::AnsiValue(124), // Red3 + }, + pipe: Color::AnsiValue(30), // Teal + block_device: Color::AnsiValue(30), // Teal + char_device: Color::AnsiValue(130), // DarkOrange3 + socket: Color::AnsiValue(30), // Teal + special: Color::AnsiValue(30), // Teal + }, + date: Date { + hour_old: None, + day_old: None, + older: Color::AnsiValue(30), // Teal + relative: vec![ + RelativeTimeColor { + threshold: "1h".into(), + color: Color::AnsiValue(28), // Green4 + }, + RelativeTimeColor { + threshold: "1d".into(), + color: Color::AnsiValue(34), // Green3 + }, + ], + absolute: Vec::new(), + }, + size: Size { + none: Color::AnsiValue(240), // DarkGrey + small: Color::AnsiValue(130), // DarkOrange3 + medium: Color::AnsiValue(166), // DarkOrange + large: Color::AnsiValue(124), // Red3 + }, + inode: INode { + valid: Color::AnsiValue(90), // DarkMagenta + invalid: Color::AnsiValue(240), // DarkGrey + }, + links: Links { + valid: Color::AnsiValue(90), // DarkMagenta + invalid: Color::AnsiValue(240), // DarkGrey + }, + tree_edge: Color::AnsiValue(240), // DarkGrey + git_status: GitStatus { + default: Color::AnsiValue(240), // DarkGrey + unmodified: Color::AnsiValue(240), // DarkGrey + ignored: Color::AnsiValue(240), // DarkGrey + new_in_index: Color::DarkGreen, + new_in_workdir: Color::DarkGreen, + typechange: Color::DarkYellow, + deleted: Color::DarkRed, + renamed: Color::DarkGreen, + modified: Color::DarkYellow, + conflicted: Color::DarkRed, + }, + } + } } #[cfg(test)] @@ -574,4 +671,37 @@ permission: theme.permission.read = Color::AnsiValue(130); assert_eq!(empty_theme, theme); } + + #[test] + fn test_is_dark_mode_unset_defaults_dark() { + temp_env::with_var("TERM_BACKGROUND", None::<&str>, || { + assert!(super::is_dark_mode()); + }); + } + + #[test] + fn test_is_dark_mode_term_background_light() { + temp_env::with_var("TERM_BACKGROUND", Some("light"), || { + assert!(!super::is_dark_mode()); + }); + } + + #[test] + fn test_is_dark_mode_term_background_light_uppercase() { + temp_env::with_var("TERM_BACKGROUND", Some("Light"), || { + assert!(!super::is_dark_mode()); + }); + } + + #[test] + fn test_is_dark_mode_term_background_dark() { + temp_env::with_var("TERM_BACKGROUND", Some("dark"), || { + assert!(super::is_dark_mode()); + }); + } + + #[test] + fn test_default_light_is_distinct_from_default_dark() { + assert_ne!(ColorTheme::default_dark(), ColorTheme::default_light()); + } }