Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions doc/lsd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 8 additions & 1 deletion doc/samples/config-sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <config-dir>/my-dark-colors.yaml
# light: my-light-colors # resolves to <config-dir>/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 ==
Expand Down
22 changes: 18 additions & 4 deletions src/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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::<ColorTheme>(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,
};

Expand Down
9 changes: 8 additions & 1 deletion src/config_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <config-dir>/my-dark-colors.yaml
# light: my-light-colors # resolves to <config-dir>/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 ==
Expand Down
88 changes: 85 additions & 3 deletions src/flags/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
light: Option<String>,
},
}

impl ThemeOption {
Expand All @@ -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 <theme-file-path>")
formatter.write_str(
"`default`, `custom`, or a map with `light` and/or `dark` theme paths",
)
}

fn visit_str<E>(self, value: &str) -> Result<ThemeOption, E>
Expand All @@ -82,9 +91,27 @@ impl<'de> de::Deserialize<'de> for ThemeOption {
str => Ok(ThemeOption::CustomLegacy(str.to_string())),
}
}

fn visit_map<M>(self, mut map: M) -> Result<ThemeOption, M::Error>
where
M: de::MapAccess<'de>,
{
let mut dark = None;
let mut light = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"dark" => dark = Some(map.next_value::<String>()?),
"light" => light = Some(map.next_value::<String>()?),
other => {
return Err(de::Error::unknown_field(other, &["dark", "light"]));
}
}
}
Ok(ThemeOption::DualCustom { dark, light })
}
}

deserializer.deserialize_identifier(ThemeOptionVisitor)
deserializer.deserialize_any(ThemeOptionVisitor)
}
}

Expand Down Expand Up @@ -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::<ThemeOption>("default").unwrap()
);
assert_eq!(
ThemeOption::Custom,
serde_yaml::from_str::<ThemeOption>("custom").unwrap()
);
assert_eq!(
ThemeOption::CustomLegacy("old-path".to_string()),
serde_yaml::from_str::<ThemeOption>("old-path").unwrap()
);
}
}
134 changes: 132 additions & 2 deletions src/theme/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}

Expand All @@ -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)]
Expand Down Expand Up @@ -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());
}
}