From 1999fa23039a88def91851fda46e32bcb335dc80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:16 +0200 Subject: [PATCH 01/17] Choose theme based on the terminal's color scheme --- src/lib.rs | 1 + src/theme.rs | 334 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 src/theme.rs diff --git a/src/lib.rs b/src/lib.rs index 0296ad32..57819237 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,6 +49,7 @@ pub(crate) mod printer; pub mod style; pub(crate) mod syntax_mapping; mod terminal; +pub mod theme; mod vscreen; pub(crate) mod wrapping; diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 00000000..b1607140 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,334 @@ +use std::convert::Infallible; +use std::str::FromStr; + +/// Chooses an appropriate theme or falls back to a default theme +/// based on the user-provided options and the color scheme of the terminal. +pub fn theme(options: ThemeOptions, detector: &dyn ColorSchemeDetector) -> String { + // Implementation note: This function is mostly pure (i.e. it has no side effects) for the sake of testing. + // All the side effects (e.g. querying the terminal for its colors) are performed in the detector. + if let Some(theme) = options.theme { + theme.into_theme(ColorScheme::default()) + } else { + let color_scheme = detect(options.detect_color_scheme, detector).unwrap_or_default(); + choose_theme(options, color_scheme) + .map(|t| t.into_theme(color_scheme)) + .unwrap_or_else(|| default_theme(color_scheme).to_owned()) + } +} + +fn choose_theme(options: ThemeOptions, color_scheme: ColorScheme) -> Option { + match color_scheme { + ColorScheme::Dark => options.dark_theme, + ColorScheme::Light => options.light_theme, + } +} + +fn detect(when: DetectColorScheme, detector: &dyn ColorSchemeDetector) -> Option { + let should_detect = match when { + DetectColorScheme::Auto => detector.should_detect(), + DetectColorScheme::Always => true, + DetectColorScheme::Never => false, + }; + should_detect.then(|| detector.detect()).flatten() +} + +const fn default_theme(color_scheme: ColorScheme) -> &'static str { + match color_scheme { + ColorScheme::Dark => "Monokai Extended", + ColorScheme::Light => "Monokai Extended Light", + } +} + +/// Options for configuring the theme used for syntax highlighting. +#[derive(Debug, Default)] +pub struct ThemeOptions { + /// Always use this theme regardless of the terminal's background color. + pub theme: Option, + /// The theme to use in case the terminal uses a dark background with light text. + pub dark_theme: Option, + /// The theme to use in case the terminal uses a light background with dark text. + pub light_theme: Option, + /// Detect whether or not the terminal is dark or light by querying for its colors. + pub detect_color_scheme: DetectColorScheme, +} + +/// The name of a theme or the default theme. +#[derive(Debug)] +pub enum ThemeRequest { + Named(String), + Default, +} + +impl FromStr for ThemeRequest { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + if s == "default" { + Ok(ThemeRequest::Default) + } else { + Ok(ThemeRequest::Named(s.to_owned())) + } + } +} + +impl ThemeRequest { + fn into_theme(self, color_scheme: ColorScheme) -> String { + match self { + ThemeRequest::Named(t) => t, + ThemeRequest::Default => default_theme(color_scheme).to_owned(), + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DetectColorScheme { + /// Only query the terminal for its colors when appropriate (e.g. when the the output is not redirected). + #[default] + Auto, + /// Always query the terminal for its colors. + Always, + /// Never query the terminal for its colors. + Never, +} + +/// The color scheme used to pick a fitting theme. Defaults to [`ColorScheme::Dark`]. +#[derive(Default, Copy, Clone)] +pub enum ColorScheme { + #[default] + Dark, + Light, +} + +pub trait ColorSchemeDetector { + fn should_detect(&self) -> bool; + + fn detect(&self) -> Option; +} + +#[cfg(test)] +impl ColorSchemeDetector for Option { + fn should_detect(&self) -> bool { + true + } + + fn detect(&self) -> Option { + *self + } +} + +#[cfg(test)] +mod tests { + use super::ColorScheme::*; + use super::DetectColorScheme::*; + use super::*; + use std::cell::Cell; + use std::iter; + + mod color_scheme_detection { + use super::*; + + #[test] + fn not_called_for_never() { + let detector = DetectorStub::should_detect(Some(Dark)); + let options = ThemeOptions { + detect_color_scheme: Never, + ..Default::default() + }; + _ = theme(options, &detector); + assert!(!detector.was_called.get()); + } + + #[test] + fn called_for_always() { + let detectors = [ + DetectorStub::should_detect(Some(Dark)), + DetectorStub::should_not_detect(), + ]; + for detector in detectors { + let options = ThemeOptions { + detect_color_scheme: Always, + ..Default::default() + }; + _ = theme(options, &detector); + assert!(detector.was_called.get()); + } + } + + #[test] + fn called_for_auto_if_should_detect() { + let detector = DetectorStub::should_detect(Some(Dark)); + _ = theme(ThemeOptions::default(), &detector); + assert!(detector.was_called.get()); + } + + #[test] + fn not_called_for_auto_if_not_should_detect() { + let detector = DetectorStub::should_not_detect(); + _ = theme(ThemeOptions::default(), &detector); + assert!(!detector.was_called.get()); + } + } + + mod precedence { + use super::*; + + #[test] + fn theme_is_preferred_over_light_or_dark_themes() { + for color_scheme in optional(color_schemes()) { + for options in [ + ThemeOptions { + theme: Some(ThemeRequest::Named("Theme".to_string())), + ..Default::default() + }, + ThemeOptions { + theme: Some(ThemeRequest::Named("Theme".to_string())), + dark_theme: Some(ThemeRequest::Named("Dark Theme".to_string())), + light_theme: Some(ThemeRequest::Named("Light Theme".to_string())), + ..Default::default() + }, + ] { + let detector = ConstantDetector(color_scheme); + assert_eq!("Theme", theme(options, &detector)); + } + } + } + + #[test] + fn detector_is_not_called_if_theme_is_present() { + let options = ThemeOptions { + theme: Some(ThemeRequest::Named("Theme".to_string())), + ..Default::default() + }; + let detector = DetectorStub::should_detect(Some(Dark)); + _ = theme(options, &detector); + assert!(!detector.was_called.get()); + } + } + + mod default_theme { + use super::*; + + #[test] + fn dark_if_unable_to_detect_color_scheme() { + let detector = ConstantDetector(None); + assert_eq!( + default_theme(ColorScheme::Dark), + theme(ThemeOptions::default(), &detector) + ); + } + + // For backwards compatibility, if the default theme is requested + // explicitly through BAT_THEME, we always pick the default dark theme. + #[test] + fn dark_if_requested_explicitly_through_theme() { + for color_scheme in optional(color_schemes()) { + let options = ThemeOptions { + theme: Some(ThemeRequest::Default), + ..Default::default() + }; + let detector = ConstantDetector(color_scheme); + assert_eq!(default_theme(ColorScheme::Dark), theme(options, &detector)); + } + } + + #[test] + fn varies_depending_on_color_scheme() { + for color_scheme in color_schemes() { + for options in [ + ThemeOptions::default(), + ThemeOptions { + dark_theme: Some(ThemeRequest::Default), + light_theme: Some(ThemeRequest::Default), + ..Default::default() + }, + ] { + let detector = ConstantDetector(Some(color_scheme)); + assert_eq!(default_theme(color_scheme), theme(options, &detector)); + } + } + } + } + + mod choosing { + use super::*; + + #[test] + fn chooses_dark_theme_if_dark_or_unknown() { + for color_scheme in [Some(Dark), None] { + let options = ThemeOptions { + dark_theme: Some(ThemeRequest::Named("Dark".to_string())), + light_theme: Some(ThemeRequest::Named("Light".to_string())), + ..Default::default() + }; + let detector = ConstantDetector(color_scheme); + assert_eq!("Dark", theme(options, &detector)); + } + } + + #[test] + fn chooses_light_theme_if_light() { + let options = ThemeOptions { + dark_theme: Some(ThemeRequest::Named("Dark".to_string())), + light_theme: Some(ThemeRequest::Named("Light".to_string())), + ..Default::default() + }; + let detector = ConstantDetector(Some(ColorScheme::Light)); + assert_eq!("Light", theme(options, &detector)); + } + } + + struct DetectorStub { + should_detect: bool, + color_scheme: Option, + was_called: Cell, + } + + impl DetectorStub { + fn should_detect(color_scheme: Option) -> Self { + DetectorStub { + should_detect: true, + color_scheme, + was_called: Cell::default(), + } + } + + fn should_not_detect() -> Self { + DetectorStub { + should_detect: false, + color_scheme: None, + was_called: Cell::default(), + } + } + } + + impl ColorSchemeDetector for DetectorStub { + fn should_detect(&self) -> bool { + self.should_detect + } + + fn detect(&self) -> Option { + self.was_called.set(true); + self.color_scheme + } + } + + struct ConstantDetector(Option); + + impl ColorSchemeDetector for ConstantDetector { + fn should_detect(&self) -> bool { + true + } + + fn detect(&self) -> Option { + self.0 + } + } + + fn optional(value: impl Iterator) -> impl Iterator> { + value.map(Some).chain(iter::once(None)) + } + + fn color_schemes() -> impl Iterator { + [Dark, Light].into_iter() + } +} From b9b04164d1f8774841cdfbb6eafce0c82905a22c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:18 +0200 Subject: [PATCH 02/17] Deprecate old `default_theme` function --- src/assets.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/assets.rs b/src/assets.rs index 9655553d..53414366 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -90,6 +90,7 @@ impl HighlightingAssets { /// /// See and /// for more context. + #[deprecated(note = "use bat::theme::theme instead")] pub fn default_theme() -> &'static str { #[cfg(not(target_os = "macos"))] { From 85335f76eb100b3912128f909fcf44f9409bb10a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:20 +0200 Subject: [PATCH 03/17] Use `default_theme()` function from theme module --- src/assets.rs | 27 ++++++++------------------- src/theme.rs | 2 +- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/assets.rs b/src/assets.rs index 53414366..857f416b 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -13,6 +13,7 @@ use crate::error::*; use crate::input::{InputReader, OpenedInput}; use crate::syntax_mapping::ignored_suffixes::IgnoredSuffixes; use crate::syntax_mapping::MappingTarget; +use crate::theme::{default_theme, ColorScheme}; use crate::{bat_warning, SyntaxMapping}; use lazy_theme_set::LazyThemeSet; @@ -94,33 +95,18 @@ impl HighlightingAssets { pub fn default_theme() -> &'static str { #[cfg(not(target_os = "macos"))] { - Self::default_dark_theme() + default_theme(ColorScheme::Dark) } #[cfg(target_os = "macos")] { if macos_dark_mode_active() { - Self::default_dark_theme() + default_theme(ColorScheme::Dark) } else { - Self::default_light_theme() + default_theme(ColorScheme::Light) } } } - /** - * The default theme that looks good on a dark background. - */ - fn default_dark_theme() -> &'static str { - "Monokai Extended" - } - - /** - * The default theme that looks good on a light background. - */ - #[cfg(target_os = "macos")] - fn default_light_theme() -> &'static str { - "Monokai Extended Light" - } - pub fn from_cache(cache_path: &Path) -> Result { Ok(HighlightingAssets::new( SerializedSyntaxSet::FromFile(cache_path.join("syntaxes.bin")), @@ -249,7 +235,10 @@ impl HighlightingAssets { bat_warning!("Unknown theme '{}', using default.", theme) } self.get_theme_set() - .get(self.fallback_theme.unwrap_or_else(Self::default_theme)) + .get( + self.fallback_theme + .unwrap_or_else(|| default_theme(ColorScheme::Dark)), + ) .expect("something is very wrong if the default theme is missing") } } diff --git a/src/theme.rs b/src/theme.rs index b1607140..8ce75e1f 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -32,7 +32,7 @@ fn detect(when: DetectColorScheme, detector: &dyn ColorSchemeDetector) -> Option should_detect.then(|| detector.detect()).flatten() } -const fn default_theme(color_scheme: ColorScheme) -> &'static str { +pub(crate) const fn default_theme(color_scheme: ColorScheme) -> &'static str { match color_scheme { ColorScheme::Dark => "Monokai Extended", ColorScheme::Light => "Monokai Extended Light", From 4b1b600994f221620a953bae9b7a0aa8630fce7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:22 +0200 Subject: [PATCH 04/17] Expose new theme selection in CLI --- Cargo.lock | 51 ++++++++++++++++++++---- Cargo.toml | 3 ++ doc/long-help.txt | 36 +++++++++++++++-- doc/short-help.txt | 6 +++ src/bin/bat/app.rs | 88 +++++++++++++++++++++++++++++++++++------ src/bin/bat/clap_app.rs | 53 ++++++++++++++++++++++++- src/bin/bat/config.rs | 2 + src/theme.rs | 24 +++++------ 8 files changed, 226 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3578ca08..ce11b472 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,7 @@ dependencies = [ "shell-words", "syntect", "tempfile", + "terminal-colorsaurus", "thiserror", "toml", "unicode-width", @@ -688,9 +689,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.149" +version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "libgit2-sys" @@ -755,9 +756,9 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" [[package]] name = "miniz_oxide" @@ -768,6 +769,17 @@ dependencies = [ "adler", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "nix" version = "0.26.4" @@ -1309,6 +1321,29 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal-colorsaurus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c374383f597b763eb3bd06bc4e18f85510a52d7f1ac762f0c7e413ce696079fc" +dependencies = [ + "libc", + "memchr", + "mio", + "terminal-trx", + "thiserror", +] + +[[package]] +name = "terminal-trx" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a4af7c93f02d5bd5e120c812f7fb413003b7060e8a22d0ea90346f1be769210" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "terminal_size" version = "0.3.0" @@ -1327,18 +1362,18 @@ checksum = "507e9898683b6c43a9aa55b64259b721b52ba226e0f3779137e50ad114a4c90b" [[package]] name = "thiserror" -version = "1.0.53" +version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2cd5904763bad08ad5513ddbb12cf2ae273ca53fa9f68e843e236ec6dfccc09" +checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.53" +version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dcf4a824cce0aeacd6f38ae6f24234c8e80d68632338ebaa1443b5df9e29e19" +checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index b45d7d88..ad1f15df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ application = [ "bugreport", "build-assets", "git", + "detect-color-scheme", "minimal-application", ] # Mainly for developers that want to iterate quickly @@ -35,6 +36,7 @@ git = ["git2"] # Support indicating git modifications paging = ["shell-words", "grep-cli"] # Support applying a pager on the output lessopen = ["run_script", "os_str_bytes"] # Support $LESSOPEN preprocessor build-assets = ["syntect/yaml-load", "syntect/plist-load", "regex", "walkdir"] +detect-color-scheme = ["dep:terminal-colorsaurus"] # You need to use one of these if you depend on bat as a library: regex-onig = ["syntect/regex-onig"] # Use the "oniguruma" regex engine @@ -68,6 +70,7 @@ bytesize = { version = "1.3.0" } encoding_rs = "0.8.33" os_str_bytes = { version = "~6.6", optional = true } run_script = { version = "^0.10.1", optional = true} +terminal-colorsaurus = { version = "0.3.1", optional = true } [dependencies.git2] version = "0.18" diff --git a/doc/long-help.txt b/doc/long-help.txt index a6ffe962..c395b8b0 100644 --- a/doc/long-help.txt +++ b/doc/long-help.txt @@ -109,9 +109,39 @@ Options: 'bat --ignored-suffix ".dev" my_file.json.dev' will use JSON syntax, and ignore '.dev' --theme - Set the theme for syntax highlighting. Use '--list-themes' to see all available themes. To - set a default theme, add the '--theme="..."' option to the configuration file or export - the BAT_THEME environment variable (e.g.: export BAT_THEME="..."). + Set the theme for syntax highlighting. Note that this option overrides '--theme-dark' and + '--theme-light'. Use '--list-themes' to see all available themes. To set a default theme, + add the '--theme="..."' option to the configuration file or export the BAT_THEME + environment variable (e.g.: export BAT_THEME="..."). + + --detect-color-scheme + Specify when to query the terminal for its colors in order to pick an appropriate syntax + highlighting theme. Use '--theme-light' and '--theme-dark' (or the environment variables + BAT_THEME_LIGHT and BAT_THEME_DARK) to configure which themes are picked. You may also use + '--theme' to set a theme that is used regardless of the terminal's colors. + + Possible values: + * auto (default): + Only query the terminals colors if the output is not redirected. This is to prevent + race conditions with pagers such as less. + * never + Never query the terminal for its colors and assume that the terminal has a dark + background. + * always + Always query the terminal for its colors, regardless of whether or not the output is + redirected. + + --theme-light + Sets the theme name for syntax highlighting used when the terminal uses a light + background. Use '--list-themes' to see all available themes. To set a default theme, add + the '--theme-light="..." option to the configuration file or export the BAT_THEME_LIGHT + environment variable (e.g. export BAT_THEME_LIGHT="..."). + + --theme-dark + Sets the theme name for syntax highlighting used when the terminal uses a dark background. + Use '--list-themes' to see all available themes. To set a default theme, add the + '--theme-dark="..." option to the configuration file or export the BAT_THEME_DARK + environment variable (e.g. export BAT_THEME_DARK="..."). --list-themes Display a list of supported themes for syntax highlighting. diff --git a/doc/short-help.txt b/doc/short-help.txt index 305bbf3d..3e369229 100644 --- a/doc/short-help.txt +++ b/doc/short-help.txt @@ -41,6 +41,12 @@ Options: Use the specified syntax for files matching the glob pattern ('*.cpp:C++'). --theme Set the color theme for syntax highlighting. + --detect-color-scheme + Specify when to query the terminal for its colors. + --theme-light + Sets the color theme for syntax highlighting used for light backgrounds. + --theme-dark + Sets the color theme for syntax highlighting used for dark backgrounds. --list-themes Display all supported highlighting themes. -s, --squeeze-blank diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index 6fc85321..4e167a88 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -2,18 +2,21 @@ use std::collections::HashSet; use std::env; use std::io::IsTerminal; use std::path::{Path, PathBuf}; +use std::str::FromStr as _; use crate::{ clap_app, config::{get_args_from_config_file, get_args_from_env_opts_var, get_args_from_env_vars}, }; +use bat::theme::{ + theme, ColorScheme, ColorSchemeDetector, DetectColorScheme, ThemeOptions, ThemeRequest, +}; use clap::ArgMatches; use console::Term; use crate::input::{new_file_input, new_stdin_input}; use bat::{ - assets::HighlightingAssets, bat_warning, config::{Config, VisibleLines}, error::*, @@ -242,18 +245,7 @@ impl App { 4 }, ), - theme: self - .matches - .get_one::("theme") - .map(String::from) - .map(|s| { - if s == "default" { - String::from(HighlightingAssets::default_theme()) - } else { - s - } - }) - .unwrap_or_else(|| String::from(HighlightingAssets::default_theme())), + theme: theme(self.theme_options(), &TerminalColorSchemeDetector), visible_lines: match self.matches.try_contains_id("diff").unwrap_or_default() && self.matches.get_flag("diff") { @@ -389,4 +381,74 @@ impl App { Ok(styled_components) } + + fn theme_options(&self) -> ThemeOptions { + let theme = self + .matches + .get_one::("theme") + .map(|t| ThemeRequest::from_str(t).unwrap()); + let theme_dark = self + .matches + .get_one::("theme-dark") + .map(|t| ThemeRequest::from_str(t).unwrap()); + let theme_light = self + .matches + .get_one::("theme-light") + .map(|t| ThemeRequest::from_str(t).unwrap()); + let detect_color_scheme = match self + .matches + .get_one::("detect-color-scheme") + .map(|s| s.as_str()) + { + Some("auto") => DetectColorScheme::Auto, + Some("never") => DetectColorScheme::Never, + Some("always") => DetectColorScheme::Always, + _ => unreachable!("other values for --detect-color-scheme are not allowed"), + }; + ThemeOptions { + theme, + theme_dark, + theme_light, + detect_color_scheme, + } + } +} + +struct TerminalColorSchemeDetector; + +#[cfg(feature = "detect-color-scheme")] +impl ColorSchemeDetector for TerminalColorSchemeDetector { + fn should_detect(&self) -> bool { + // Querying the terminal for its colors via OSC 10 / OSC 11 requires "exclusive" access + // since we read/write from the terminal and enable/disable raw mode. + // This causes race conditions with pagers such as less when they are attached to the + // same terminal as us. + // + // This is usually only an issue when the output is manually piped to a pager. + // For example: `bat Cargo.toml | less`. + // Otherwise, if we start the pager ourselves, then there's no race condition + // since the pager is started *after* the color is detected. + std::io::stdout().is_terminal() + } + + fn detect(&self) -> Option { + use terminal_colorsaurus::{color_scheme, QueryOptions}; + let colors = color_scheme(QueryOptions::default()).ok()?; + if colors.is_light_on_dark() { + Some(ColorScheme::Dark) + } else { + Some(ColorScheme::Light) + } + } +} + +#[cfg(not(feature = "detect-color-scheme"))] +impl ColorSchemeDetector for TerminalColorSchemeDetector { + fn should_detect(&self) -> bool { + false + } + + fn detect(&self) -> Option { + None + } } diff --git a/src/bin/bat/clap_app.rs b/src/bin/bat/clap_app.rs index b82762b6..adf466ab 100644 --- a/src/bin/bat/clap_app.rs +++ b/src/bin/bat/clap_app.rs @@ -373,13 +373,64 @@ pub fn build_app(interactive_output: bool) -> Command { .overrides_with("theme") .help("Set the color theme for syntax highlighting.") .long_help( - "Set the theme for syntax highlighting. Use '--list-themes' to \ + "Set the theme for syntax highlighting. Note that this option overrides \ + '--theme-dark' and '--theme-light'. Use '--list-themes' to \ see all available themes. To set a default theme, add the \ '--theme=\"...\"' option to the configuration file or export the \ BAT_THEME environment variable (e.g.: export \ BAT_THEME=\"...\").", ), ) + .arg( + Arg::new("detect-color-scheme") + .long("detect-color-scheme") + .overrides_with("detect-color-scheme") + .value_name("when") + .value_parser(["auto", "never", "always"]) + .default_value("auto") + .hide_default_value(true) + .help("Specify when to query the terminal for its colors.") + .long_help( + "Specify when to query the terminal for its colors \ + in order to pick an appropriate syntax highlighting theme. \ + Use '--theme-light' and '--theme-dark' (or the environment variables \ + BAT_THEME_LIGHT and BAT_THEME_DARK) to configure which themes are picked. \ + You may also use '--theme' to set a theme that is used regardless of the terminal's colors.\n\n\ + Possible values:\n\ + * auto (default):\n \ + Only query the terminals colors if the output is not redirected. \ + This is to prevent race conditions with pagers such as less.\n\ + * never\n \ + Never query the terminal for its colors \ + and assume that the terminal has a dark background.\n\ + * always\n \ + Always query the terminal for its colors, \ + regardless of whether or not the output is redirected."), + ) + .arg( + Arg::new("theme-light") + .long("theme-light") + .overrides_with("theme-light") + .value_name("theme") + .help("Sets the color theme for syntax highlighting used for light backgrounds.") + .long_help( + "Sets the theme name for syntax highlighting used when the terminal uses a light background. \ + Use '--list-themes' to see all available themes. To set a default theme, add the \ + '--theme-light=\"...\" option to the configuration file or export the BAT_THEME_LIGHT \ + environment variable (e.g. export BAT_THEME_LIGHT=\"...\")."), + ) + .arg( + Arg::new("theme-dark") + .long("theme-dark") + .overrides_with("theme-dark") + .value_name("theme") + .help("Sets the color theme for syntax highlighting used for dark backgrounds.") + .long_help( + "Sets the theme name for syntax highlighting used when the terminal uses a dark background. \ + Use '--list-themes' to see all available themes. To set a default theme, add the \ + '--theme-dark=\"...\" option to the configuration file or export the BAT_THEME_DARK \ + environment variable (e.g. export BAT_THEME_DARK=\"...\")."), + ) .arg( Arg::new("list-themes") .long("list-themes") diff --git a/src/bin/bat/config.rs b/src/bin/bat/config.rs index 9e38dfa4..0b2fc039 100644 --- a/src/bin/bat/config.rs +++ b/src/bin/bat/config.rs @@ -141,6 +141,8 @@ pub fn get_args_from_env_vars() -> Vec { [ ("--tabs", "BAT_TABS"), ("--theme", "BAT_THEME"), + ("--theme-dark", "BAT_THEME_DARK"), + ("--theme-light", "BAT_THEME_LIGHT"), ("--pager", "BAT_PAGER"), ("--paging", "BAT_PAGING"), ("--style", "BAT_STYLE"), diff --git a/src/theme.rs b/src/theme.rs index 8ce75e1f..ec3e3d34 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -18,8 +18,8 @@ pub fn theme(options: ThemeOptions, detector: &dyn ColorSchemeDetector) -> Strin fn choose_theme(options: ThemeOptions, color_scheme: ColorScheme) -> Option { match color_scheme { - ColorScheme::Dark => options.dark_theme, - ColorScheme::Light => options.light_theme, + ColorScheme::Dark => options.theme_dark, + ColorScheme::Light => options.theme_light, } } @@ -45,9 +45,9 @@ pub struct ThemeOptions { /// Always use this theme regardless of the terminal's background color. pub theme: Option, /// The theme to use in case the terminal uses a dark background with light text. - pub dark_theme: Option, + pub theme_dark: Option, /// The theme to use in case the terminal uses a light background with dark text. - pub light_theme: Option, + pub theme_light: Option, /// Detect whether or not the terminal is dark or light by querying for its colors. pub detect_color_scheme: DetectColorScheme, } @@ -182,8 +182,8 @@ mod tests { }, ThemeOptions { theme: Some(ThemeRequest::Named("Theme".to_string())), - dark_theme: Some(ThemeRequest::Named("Dark Theme".to_string())), - light_theme: Some(ThemeRequest::Named("Light Theme".to_string())), + theme_dark: Some(ThemeRequest::Named("Dark Theme".to_string())), + theme_light: Some(ThemeRequest::Named("Light Theme".to_string())), ..Default::default() }, ] { @@ -237,8 +237,8 @@ mod tests { for options in [ ThemeOptions::default(), ThemeOptions { - dark_theme: Some(ThemeRequest::Default), - light_theme: Some(ThemeRequest::Default), + theme_dark: Some(ThemeRequest::Default), + theme_light: Some(ThemeRequest::Default), ..Default::default() }, ] { @@ -256,8 +256,8 @@ mod tests { fn chooses_dark_theme_if_dark_or_unknown() { for color_scheme in [Some(Dark), None] { let options = ThemeOptions { - dark_theme: Some(ThemeRequest::Named("Dark".to_string())), - light_theme: Some(ThemeRequest::Named("Light".to_string())), + theme_dark: Some(ThemeRequest::Named("Dark".to_string())), + theme_light: Some(ThemeRequest::Named("Light".to_string())), ..Default::default() }; let detector = ConstantDetector(color_scheme); @@ -268,8 +268,8 @@ mod tests { #[test] fn chooses_light_theme_if_light() { let options = ThemeOptions { - dark_theme: Some(ThemeRequest::Named("Dark".to_string())), - light_theme: Some(ThemeRequest::Named("Light".to_string())), + theme_dark: Some(ThemeRequest::Named("Dark".to_string())), + theme_light: Some(ThemeRequest::Named("Light".to_string())), ..Default::default() }; let detector = ConstantDetector(Some(ColorScheme::Light)); From eb974fe11a6f9c70d51e2804e561ec9fd7297f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:24 +0200 Subject: [PATCH 05/17] Update completions and man page --- assets/completions/_bat.ps1.in | 3 +++ assets/completions/bat.bash.in | 9 ++++++-- assets/completions/bat.fish.in | 7 +++++++ assets/completions/bat.zsh.in | 3 +++ assets/manual/bat.1.in | 38 +++++++++++++++++++++++++++++++--- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/assets/completions/_bat.ps1.in b/assets/completions/_bat.ps1.in index c0c151e1..5635dea2 100644 --- a/assets/completions/_bat.ps1.in +++ b/assets/completions/_bat.ps1.in @@ -32,11 +32,14 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script [CompletionResult]::new('--color', 'color', [CompletionResultType]::ParameterName, 'When to use colors (*auto*, never, always).') [CompletionResult]::new('--italic-text', 'italic-text', [CompletionResultType]::ParameterName, 'Use italics in output (always, *never*)') [CompletionResult]::new('--decorations', 'decorations', [CompletionResultType]::ParameterName, 'When to show the decorations (*auto*, never, always).') + [CompletionResult]::new('--detect-color-scheme', 'detect-color-scheme', [CompletionResultType]::ParameterName, 'When to detect the terminal''s color scheme (*auto*, never, always).') [CompletionResult]::new('--paging', 'paging', [CompletionResultType]::ParameterName, 'Specify when to use the pager, or use `-P` to disable (*auto*, never, always).') [CompletionResult]::new('--pager', 'pager', [CompletionResultType]::ParameterName, 'Determine which pager to use.') [CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Use the specified syntax for files matching the glob pattern (''*.cpp:C++'').') [CompletionResult]::new('--map-syntax', 'map-syntax', [CompletionResultType]::ParameterName, 'Use the specified syntax for files matching the glob pattern (''*.cpp:C++'').') [CompletionResult]::new('--theme', 'theme', [CompletionResultType]::ParameterName, 'Set the color theme for syntax highlighting.') + [CompletionResult]::new('--theme-dark', 'theme', [CompletionResultType]::ParameterName, 'Set the color theme for syntax highlighting for dark backgrounds.') + [CompletionResult]::new('--theme-light', 'theme', [CompletionResultType]::ParameterName, 'Set the color theme for syntax highlighting for light backgrounds.') [CompletionResult]::new('--style', 'style', [CompletionResultType]::ParameterName, 'Comma-separated list of style elements to display (*default*, auto, full, plain, changes, header, header-filename, header-filesize, grid, rule, numbers, snip).') [CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'Only print the lines from N to M.') [CompletionResult]::new('--line-range', 'line-range', [CompletionResultType]::ParameterName, 'Only print the lines from N to M.') diff --git a/assets/completions/bat.bash.in b/assets/completions/bat.bash.in index de8651a8..3a52ed92 100644 --- a/assets/completions/bat.bash.in +++ b/assets/completions/bat.bash.in @@ -99,7 +99,7 @@ _bat() { COMPREPLY=($(compgen -W "auto never character" -- "$cur")) return 0 ;; - --color | --decorations | --paging) + --color | --decorations | --paging | --detect-color-scheme) COMPREPLY=($(compgen -W "auto never always" -- "$cur")) return 0 ;; @@ -111,7 +111,9 @@ _bat() { COMPREPLY=($(compgen -c -- "$cur")) return 0 ;; - --theme) + --theme | \ + --theme-dark | \ + --theme-light) local IFS=$'\n' COMPREPLY=($(compgen -W "$("$1" --list-themes)" -- "$cur")) __bat_escape_completions @@ -162,12 +164,15 @@ _bat() { --color --italic-text --decorations + --detect-color-scheme --force-colorization --paging --pager --map-syntax --ignored-suffix --theme + --theme-dark + --theme-light --list-themes --style --line-range diff --git a/assets/completions/bat.fish.in b/assets/completions/bat.fish.in index d86ebfe6..c34764b4 100644 --- a/assets/completions/bat.fish.in +++ b/assets/completions/bat.fish.in @@ -99,6 +99,7 @@ set -l color_opts ' ' set -l decorations_opts $color_opts set -l paging_opts $color_opts +set -l detect_color_scheme_opts $color_opts # Include some examples so we can indicate the default. set -l pager_opts ' @@ -141,6 +142,8 @@ complete -c $bat -l config-file -f -d "Display location of configuration file" - complete -c $bat -l decorations -x -a "$decorations_opts" -d "When to use --style decorations" -n __bat_no_excl_args +complete -c $bat -l detect-color-scheme -x -a "$detect_color_scheme_opts" -d "When to detect the terminal's color scheme" -n __bat_no_excl_args + complete -c $bat -l diagnostic -d "Print diagnostic info for bug reports" -n __fish_is_first_arg complete -c $bat -s d -l diff -d "Only show lines with Git changes" -n __bat_no_excl_args @@ -203,6 +206,10 @@ complete -c $bat -l terminal-width -x -d "Set terminal , +, or -< complete -c $bat -l theme -x -a "(command $bat --list-themes | command cat)" -d "Set the syntax highlighting theme" -n __bat_no_excl_args +complete -c $bat -l theme-dark -x -a "(command $bat --list-themes | command cat)" -d "Set the syntax highlighting theme for dark backgrounds" -n __bat_no_excl_args + +complete -c $bat -l theme-light -x -a "(command $bat --list-themes | command cat)" -d "Set the syntax highlighting theme for light backgrounds" -n __bat_no_excl_args + complete -c $bat -s V -l version -f -d "Show version information" -n __fish_is_first_arg complete -c $bat -l wrap -x -a "$wrap_opts" -d "Text-wrapping mode" -n __bat_no_excl_args diff --git a/assets/completions/bat.zsh.in b/assets/completions/bat.zsh.in index 69caceed..e876f035 100644 --- a/assets/completions/bat.zsh.in +++ b/assets/completions/bat.zsh.in @@ -40,9 +40,12 @@ _{{PROJECT_EXECUTABLE}}_main() { --color='[specify when to use colors]:when:(auto never always)' --italic-text='[use italics in output]:when:(always never)' --decorations='[specify when to show the decorations]:when:(auto never always)' + --detect-color-scheme="[specify when to detect the terminal's color scheme]:when:(auto never always)" --paging='[specify when to use the pager]:when:(auto never always)' '(-m --map-syntax)'{-m+,--map-syntax=}'[map a glob pattern to an existing syntax name]: :->syntax-maps' '(--theme)'--theme='[set the color theme for syntax highlighting]:theme:->themes' + '(--theme-dark)'--theme-dark='[set the color theme for syntax highlighting for dark backgrounds]:theme:->themes' + '(--theme-light)'--theme-light='[set the color theme for syntax highlighting for light backgrounds]:theme:->themes' '(: --list-themes --list-languages -L)'--list-themes'[show all supported highlighting themes]' --style='[comma-separated list of style elements to display]: : _values "style [default]" default auto full plain changes header header-filename header-filesize grid rule numbers snip' diff --git a/assets/manual/bat.1.in b/assets/manual/bat.1.in index b85520da..8e77dd2c 100644 --- a/assets/manual/bat.1.in +++ b/assets/manual/bat.1.in @@ -113,6 +113,24 @@ Specify when to use the decorations that have been specified via '\-\-style'. Th automatic mode only enables decorations if an interactive terminal is detected. Possible values: *auto*, never, always. .HP +\fB\-\-detect\-color\-scheme\fR +.IP +Specify when to query the terminal for its colors in order to pick an appropriate syntax +highlighting theme. Use \fB\-\-theme-light\fP and \fB\-\-theme-dark\fP (or the environment variables +\fBBAT_THEME_LIGHT\fP and \fBBAT_THEME_DARK\fP) to configure which themes are picked. You can also use +\fP\-\-theme\fP to set a theme that is used regardless of the terminal's colors. +.IP +\fI\fP can be one of: +.RS +.IP "\fBauto\fP" +Only query the terminals colors if the output is not redirected. This is to prevent +race conditions with pagers such as less. +.IP "never" +Never query the terminal for its colors and assume that the terminal has a dark background. +.IP "always" +Always query the terminal for its colors, regardless of whether or not the output is redirected. +.RE +.HP \fB\-f\fR, \fB\-\-force\-colorization\fR .IP Alias for '--decorations=always --color=always'. This is useful \ @@ -143,9 +161,23 @@ Note that the right-hand side is the *name* of the syntax, not a file extension. .HP \fB\-\-theme\fR .IP -Set the theme for syntax highlighting. Use '\-\-list\-themes' to see all available themes. -To set a default theme, add the '\-\-theme="..."' option to the configuration file or -export the BAT_THEME environment variable (e.g.: export BAT_THEME="..."). +Set the theme for syntax highlighting. Use \fB\-\-list\-themes\fP to see all available themes. +To set a default theme, add the \fB\-\-theme="..."\fP option to the configuration file or +export the \fBBAT_THEME\fP environment variable (e.g.: \fBexport BAT_THEME="..."\fP). +.HP +\fB\-\-theme\-dark\fR +.IP +Sets the theme name for syntax highlighting used when the terminal uses a dark background. +To set a default theme, add the \fB\-\-theme-dark="..."\fP option to the configuration file or +export the \fBBAT_THEME_DARK\fP environment variable (e.g. \fBexport BAT_THEME_DARK="..."\fP). +This option is ignored if \fB\-\-theme\fP option is set. +.HP +\fB\-\-theme\-light\fR +.IP +Sets the theme name for syntax highlighting used when the terminal uses a dark background. +To set a default theme, add the \fB\-\-theme-dark="..."\fP option to the configuration file or +export the \fBBAT_THEME_LIGHT\fP environment variable (e.g. \fBexport BAT_THEME_LIGHT="..."\fP). +This option is ignored if \fB\-\-theme\fP option is set. .HP \fB\-\-list\-themes\fR .IP From 09345582521ac90c5a073d2078a7146db24e9b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:27 +0200 Subject: [PATCH 06/17] Add generated powershell completion to ignore list --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a3ea8cff..fbfe6ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ **/*.rs.bk # Generated files +/assets/completions/_bat.ps1 /assets/completions/bat.bash /assets/completions/bat.fish /assets/completions/bat.zsh From 19488dbf915df6b3b9e962ced1f9159894f99e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:29 +0200 Subject: [PATCH 07/17] Remove HighlightingAssets::default_theme() --- src/assets.rs | 57 --------------------------------------------------- src/theme.rs | 4 +++- 2 files changed, 3 insertions(+), 58 deletions(-) diff --git a/src/assets.rs b/src/assets.rs index 857f416b..d32ccbd4 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -70,43 +70,6 @@ impl HighlightingAssets { } } - /// The default theme. - /// - /// ### Windows and Linux - /// - /// Windows and most Linux distributions has a dark terminal theme by - /// default. On these platforms, this function always returns a theme that - /// looks good on a dark background. - /// - /// ### macOS - /// - /// On macOS the default terminal background is light, but it is common that - /// Dark Mode is active, which makes the terminal background dark. On this - /// platform, the default theme depends on - /// ```bash - /// defaults read -globalDomain AppleInterfaceStyle - /// ``` - /// To avoid the overhead of the check on macOS, simply specify a theme - /// explicitly via `--theme`, `BAT_THEME`, or `~/.config/bat`. - /// - /// See and - /// for more context. - #[deprecated(note = "use bat::theme::theme instead")] - pub fn default_theme() -> &'static str { - #[cfg(not(target_os = "macos"))] - { - default_theme(ColorScheme::Dark) - } - #[cfg(target_os = "macos")] - { - if macos_dark_mode_active() { - default_theme(ColorScheme::Dark) - } else { - default_theme(ColorScheme::Light) - } - } - } - pub fn from_cache(cache_path: &Path) -> Result { Ok(HighlightingAssets::new( SerializedSyntaxSet::FromFile(cache_path.join("syntaxes.bin")), @@ -389,26 +352,6 @@ fn asset_from_cache( .map_err(|_| format!("Could not parse cached {description}").into()) } -#[cfg(target_os = "macos")] -fn macos_dark_mode_active() -> bool { - const PREFERENCES_FILE: &str = "Library/Preferences/.GlobalPreferences.plist"; - const STYLE_KEY: &str = "AppleInterfaceStyle"; - - let preferences_file = home::home_dir() - .map(|home| home.join(PREFERENCES_FILE)) - .expect("Could not get home directory"); - - match plist::Value::from_file(preferences_file).map(|file| file.into_dictionary()) { - Ok(Some(preferences)) => match preferences.get(STYLE_KEY).and_then(|val| val.as_string()) { - Some(value) => value == "Dark", - // If the key does not exist, then light theme is currently in use. - None => false, - }, - // Unreachable, in theory. All macOS users have a home directory and preferences file setup. - Ok(None) | Err(_) => true, - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/theme.rs b/src/theme.rs index ec3e3d34..53477939 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -32,7 +32,9 @@ fn detect(when: DetectColorScheme, detector: &dyn ColorSchemeDetector) -> Option should_detect.then(|| detector.detect()).flatten() } -pub(crate) const fn default_theme(color_scheme: ColorScheme) -> &'static str { +/// The default theme, suitable for the given color scheme. +/// Use [`theme`], if you want to automatically detect the color scheme from the terminal. +pub const fn default_theme(color_scheme: ColorScheme) -> &'static str { match color_scheme { ColorScheme::Dark => "Monokai Extended", ColorScheme::Light => "Monokai Extended Light", From e72a95f3ab0be3a051c8c95cb127186d4162f9bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:31 +0200 Subject: [PATCH 08/17] Move actual detection into library --- Cargo.toml | 3 +- src/bin/bat/app.rs | 45 +-------------- src/theme.rs | 135 +++++++++++++++++++++++++++++++-------------- 3 files changed, 97 insertions(+), 86 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ad1f15df..bed1eb1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,14 +13,13 @@ edition = '2021' rust-version = "1.70" [features] -default = ["application"] +default = ["application", "detect-color-scheme"] # Feature required for bat the application. Should be disabled when depending on # bat as a library. application = [ "bugreport", "build-assets", "git", - "detect-color-scheme", "minimal-application", ] # Mainly for developers that want to iterate quickly diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index 4e167a88..9a5621ec 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -8,9 +8,7 @@ use crate::{ clap_app, config::{get_args_from_config_file, get_args_from_env_opts_var, get_args_from_env_vars}, }; -use bat::theme::{ - theme, ColorScheme, ColorSchemeDetector, DetectColorScheme, ThemeOptions, ThemeRequest, -}; +use bat::theme::{theme, DetectColorScheme, ThemeOptions, ThemeRequest}; use clap::ArgMatches; use console::Term; @@ -245,7 +243,7 @@ impl App { 4 }, ), - theme: theme(self.theme_options(), &TerminalColorSchemeDetector), + theme: theme(self.theme_options()), visible_lines: match self.matches.try_contains_id("diff").unwrap_or_default() && self.matches.get_flag("diff") { @@ -413,42 +411,3 @@ impl App { } } } - -struct TerminalColorSchemeDetector; - -#[cfg(feature = "detect-color-scheme")] -impl ColorSchemeDetector for TerminalColorSchemeDetector { - fn should_detect(&self) -> bool { - // Querying the terminal for its colors via OSC 10 / OSC 11 requires "exclusive" access - // since we read/write from the terminal and enable/disable raw mode. - // This causes race conditions with pagers such as less when they are attached to the - // same terminal as us. - // - // This is usually only an issue when the output is manually piped to a pager. - // For example: `bat Cargo.toml | less`. - // Otherwise, if we start the pager ourselves, then there's no race condition - // since the pager is started *after* the color is detected. - std::io::stdout().is_terminal() - } - - fn detect(&self) -> Option { - use terminal_colorsaurus::{color_scheme, QueryOptions}; - let colors = color_scheme(QueryOptions::default()).ok()?; - if colors.is_light_on_dark() { - Some(ColorScheme::Dark) - } else { - Some(ColorScheme::Light) - } - } -} - -#[cfg(not(feature = "detect-color-scheme"))] -impl ColorSchemeDetector for TerminalColorSchemeDetector { - fn should_detect(&self) -> bool { - false - } - - fn detect(&self) -> Option { - None - } -} diff --git a/src/theme.rs b/src/theme.rs index 53477939..ab326049 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,35 +1,13 @@ +//! Utilities for choosing an appropriate theme for syntax highlighting. + use std::convert::Infallible; +use std::io::IsTerminal as _; use std::str::FromStr; /// Chooses an appropriate theme or falls back to a default theme /// based on the user-provided options and the color scheme of the terminal. -pub fn theme(options: ThemeOptions, detector: &dyn ColorSchemeDetector) -> String { - // Implementation note: This function is mostly pure (i.e. it has no side effects) for the sake of testing. - // All the side effects (e.g. querying the terminal for its colors) are performed in the detector. - if let Some(theme) = options.theme { - theme.into_theme(ColorScheme::default()) - } else { - let color_scheme = detect(options.detect_color_scheme, detector).unwrap_or_default(); - choose_theme(options, color_scheme) - .map(|t| t.into_theme(color_scheme)) - .unwrap_or_else(|| default_theme(color_scheme).to_owned()) - } -} - -fn choose_theme(options: ThemeOptions, color_scheme: ColorScheme) -> Option { - match color_scheme { - ColorScheme::Dark => options.theme_dark, - ColorScheme::Light => options.theme_light, - } -} - -fn detect(when: DetectColorScheme, detector: &dyn ColorSchemeDetector) -> Option { - let should_detect = match when { - DetectColorScheme::Auto => detector.should_detect(), - DetectColorScheme::Always => true, - DetectColorScheme::Never => false, - }; - should_detect.then(|| detector.detect()).flatten() +pub fn theme(options: ThemeOptions) -> String { + theme_from_detector(options, &TerminalColorSchemeDetector) } /// The default theme, suitable for the given color scheme. @@ -42,6 +20,7 @@ pub const fn default_theme(color_scheme: ColorScheme) -> &'static str { } /// Options for configuring the theme used for syntax highlighting. +/// Used together with [`theme`]. #[derive(Debug, Default)] pub struct ThemeOptions { /// Always use this theme regardless of the terminal's background color. @@ -50,7 +29,7 @@ pub struct ThemeOptions { pub theme_dark: Option, /// The theme to use in case the terminal uses a light background with dark text. pub theme_light: Option, - /// Detect whether or not the terminal is dark or light by querying for its colors. + /// Whether or not to test if the terminal is dark or light by querying for its colors. pub detect_color_scheme: DetectColorScheme, } @@ -84,7 +63,7 @@ impl ThemeRequest { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum DetectColorScheme { - /// Only query the terminal for its colors when appropriate (e.g. when the the output is not redirected). + /// Only query the terminal for its colors when appropriate (i.e. when the the output is not redirected). #[default] Auto, /// Always query the terminal for its colors. @@ -101,12 +80,80 @@ pub enum ColorScheme { Light, } -pub trait ColorSchemeDetector { +fn theme_from_detector(options: ThemeOptions, detector: &dyn ColorSchemeDetector) -> String { + // Implementation note: This function is mostly pure (i.e. it has no side effects) for the sake of testing. + // All the side effects (e.g. querying the terminal for its colors) are performed in the detector. + if let Some(theme) = options.theme { + theme.into_theme(ColorScheme::default()) + } else { + let color_scheme = detect(options.detect_color_scheme, detector).unwrap_or_default(); + choose_theme(options, color_scheme) + .map(|t| t.into_theme(color_scheme)) + .unwrap_or_else(|| default_theme(color_scheme).to_owned()) + } +} + +fn choose_theme(options: ThemeOptions, color_scheme: ColorScheme) -> Option { + match color_scheme { + ColorScheme::Dark => options.theme_dark, + ColorScheme::Light => options.theme_light, + } +} + +fn detect(when: DetectColorScheme, detector: &dyn ColorSchemeDetector) -> Option { + let should_detect = match when { + DetectColorScheme::Auto => detector.should_detect(), + DetectColorScheme::Always => true, + DetectColorScheme::Never => false, + }; + should_detect.then(|| detector.detect()).flatten() +} + +trait ColorSchemeDetector { fn should_detect(&self) -> bool; fn detect(&self) -> Option; } +struct TerminalColorSchemeDetector; + +#[cfg(feature = "detect-color-scheme")] +impl ColorSchemeDetector for TerminalColorSchemeDetector { + fn should_detect(&self) -> bool { + // Querying the terminal for its colors via OSC 10 / OSC 11 requires "exclusive" access + // since we read/write from the terminal and enable/disable raw mode. + // This causes race conditions with pagers such as less when they are attached to the + // same terminal as us. + // + // This is usually only an issue when the output is manually piped to a pager. + // For example: `bat Cargo.toml | less`. + // Otherwise, if we start the pager ourselves, then there's no race condition + // since the pager is started *after* the color is detected. + std::io::stdout().is_terminal() + } + + fn detect(&self) -> Option { + use terminal_colorsaurus::{color_scheme, QueryOptions}; + let colors = color_scheme(QueryOptions::default()).ok()?; + if colors.is_light_on_dark() { + Some(ColorScheme::Dark) + } else { + Some(ColorScheme::Light) + } + } +} + +#[cfg(not(feature = "detect-color-scheme"))] +impl ColorSchemeDetector for TerminalColorSchemeDetector { + fn should_detect(&self) -> bool { + false + } + + fn detect(&self) -> Option { + None + } +} + #[cfg(test)] impl ColorSchemeDetector for Option { fn should_detect(&self) -> bool { @@ -136,7 +183,7 @@ mod tests { detect_color_scheme: Never, ..Default::default() }; - _ = theme(options, &detector); + _ = theme_from_detector(options, &detector); assert!(!detector.was_called.get()); } @@ -151,7 +198,7 @@ mod tests { detect_color_scheme: Always, ..Default::default() }; - _ = theme(options, &detector); + _ = theme_from_detector(options, &detector); assert!(detector.was_called.get()); } } @@ -159,14 +206,14 @@ mod tests { #[test] fn called_for_auto_if_should_detect() { let detector = DetectorStub::should_detect(Some(Dark)); - _ = theme(ThemeOptions::default(), &detector); + _ = theme_from_detector(ThemeOptions::default(), &detector); assert!(detector.was_called.get()); } #[test] fn not_called_for_auto_if_not_should_detect() { let detector = DetectorStub::should_not_detect(); - _ = theme(ThemeOptions::default(), &detector); + _ = theme_from_detector(ThemeOptions::default(), &detector); assert!(!detector.was_called.get()); } } @@ -190,7 +237,7 @@ mod tests { }, ] { let detector = ConstantDetector(color_scheme); - assert_eq!("Theme", theme(options, &detector)); + assert_eq!("Theme", theme_from_detector(options, &detector)); } } } @@ -202,7 +249,7 @@ mod tests { ..Default::default() }; let detector = DetectorStub::should_detect(Some(Dark)); - _ = theme(options, &detector); + _ = theme_from_detector(options, &detector); assert!(!detector.was_called.get()); } } @@ -215,7 +262,7 @@ mod tests { let detector = ConstantDetector(None); assert_eq!( default_theme(ColorScheme::Dark), - theme(ThemeOptions::default(), &detector) + theme_from_detector(ThemeOptions::default(), &detector) ); } @@ -229,7 +276,10 @@ mod tests { ..Default::default() }; let detector = ConstantDetector(color_scheme); - assert_eq!(default_theme(ColorScheme::Dark), theme(options, &detector)); + assert_eq!( + default_theme(ColorScheme::Dark), + theme_from_detector(options, &detector) + ); } } @@ -245,7 +295,10 @@ mod tests { }, ] { let detector = ConstantDetector(Some(color_scheme)); - assert_eq!(default_theme(color_scheme), theme(options, &detector)); + assert_eq!( + default_theme(color_scheme), + theme_from_detector(options, &detector) + ); } } } @@ -263,7 +316,7 @@ mod tests { ..Default::default() }; let detector = ConstantDetector(color_scheme); - assert_eq!("Dark", theme(options, &detector)); + assert_eq!("Dark", theme_from_detector(options, &detector)); } } @@ -275,7 +328,7 @@ mod tests { ..Default::default() }; let detector = ConstantDetector(Some(ColorScheme::Light)); - assert_eq!("Light", theme(options, &detector)); + assert_eq!("Light", theme_from_detector(options, &detector)); } } From 360f95be616381f0f629826e95a0c0392d9d3488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:33 +0200 Subject: [PATCH 09/17] Improve upon the documentation --- src/pretty_printer.rs | 4 +++- src/theme.rs | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/pretty_printer.rs b/src/pretty_printer.rs index c6203aa9..3cc9cec0 100644 --- a/src/pretty_printer.rs +++ b/src/pretty_printer.rs @@ -236,7 +236,9 @@ impl<'a> PrettyPrinter<'a> { self } - /// Specify the highlighting theme + /// Specify the highlighting theme. + /// You can use [`crate::theme::theme`] to pick a theme based on user preferences + /// and the terminal's background color. pub fn theme(&mut self, theme: impl AsRef) -> &mut Self { self.config.theme = theme.as_ref().to_owned(); self diff --git a/src/theme.rs b/src/theme.rs index ab326049..665d0cb9 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -11,7 +11,7 @@ pub fn theme(options: ThemeOptions) -> String { } /// The default theme, suitable for the given color scheme. -/// Use [`theme`], if you want to automatically detect the color scheme from the terminal. +/// Use [`theme`] if you want to automatically detect the color scheme from the terminal. pub const fn default_theme(color_scheme: ColorScheme) -> &'static str { match color_scheme { ColorScheme::Dark => "Monokai Extended", @@ -21,7 +21,7 @@ pub const fn default_theme(color_scheme: ColorScheme) -> &'static str { /// Options for configuring the theme used for syntax highlighting. /// Used together with [`theme`]. -#[derive(Debug, Default)] +#[derive(Debug, Default, PartialEq, Eq)] pub struct ThemeOptions { /// Always use this theme regardless of the terminal's background color. pub theme: Option, @@ -34,7 +34,14 @@ pub struct ThemeOptions { } /// The name of a theme or the default theme. -#[derive(Debug)] +/// +/// ``` +/// # use bat::theme::ThemeRequest; +/// # use std::str::FromStr as _; +/// assert_eq!(ThemeRequest::Default, ThemeRequest::from_str("default").unwrap()); +/// assert_eq!(ThemeRequest::Named("example".to_string()), ThemeRequest::from_str("example").unwrap()); +/// ``` +#[derive(Debug, PartialEq, Eq, Hash)] pub enum ThemeRequest { Named(String), Default, @@ -61,7 +68,7 @@ impl ThemeRequest { } } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] pub enum DetectColorScheme { /// Only query the terminal for its colors when appropriate (i.e. when the the output is not redirected). #[default] @@ -73,7 +80,7 @@ pub enum DetectColorScheme { } /// The color scheme used to pick a fitting theme. Defaults to [`ColorScheme::Dark`]. -#[derive(Default, Copy, Clone)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] pub enum ColorScheme { #[default] Dark, From 7d31a559ec97524c839fbf948e09631850e424e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:35 +0200 Subject: [PATCH 10/17] Remove cargo feature --- Cargo.toml | 5 ++--- src/theme.rs | 12 ------------ 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bed1eb1a..03877c30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ edition = '2021' rust-version = "1.70" [features] -default = ["application", "detect-color-scheme"] +default = ["application"] # Feature required for bat the application. Should be disabled when depending on # bat as a library. application = [ @@ -35,7 +35,6 @@ git = ["git2"] # Support indicating git modifications paging = ["shell-words", "grep-cli"] # Support applying a pager on the output lessopen = ["run_script", "os_str_bytes"] # Support $LESSOPEN preprocessor build-assets = ["syntect/yaml-load", "syntect/plist-load", "regex", "walkdir"] -detect-color-scheme = ["dep:terminal-colorsaurus"] # You need to use one of these if you depend on bat as a library: regex-onig = ["syntect/regex-onig"] # Use the "oniguruma" regex engine @@ -69,7 +68,7 @@ bytesize = { version = "1.3.0" } encoding_rs = "0.8.33" os_str_bytes = { version = "~6.6", optional = true } run_script = { version = "^0.10.1", optional = true} -terminal-colorsaurus = { version = "0.3.1", optional = true } +terminal-colorsaurus = { version = "0.3.1" } [dependencies.git2] version = "0.18" diff --git a/src/theme.rs b/src/theme.rs index 665d0cb9..89627049 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -124,7 +124,6 @@ trait ColorSchemeDetector { struct TerminalColorSchemeDetector; -#[cfg(feature = "detect-color-scheme")] impl ColorSchemeDetector for TerminalColorSchemeDetector { fn should_detect(&self) -> bool { // Querying the terminal for its colors via OSC 10 / OSC 11 requires "exclusive" access @@ -150,17 +149,6 @@ impl ColorSchemeDetector for TerminalColorSchemeDetector { } } -#[cfg(not(feature = "detect-color-scheme"))] -impl ColorSchemeDetector for TerminalColorSchemeDetector { - fn should_detect(&self) -> bool { - false - } - - fn detect(&self) -> Option { - None - } -} - #[cfg(test)] impl ColorSchemeDetector for Option { fn should_detect(&self) -> bool { From 547f30bec0d0eda88d0d5b1287eb3e57eb45c6a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:37 +0200 Subject: [PATCH 11/17] Update terminal-colorsaurus --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce11b472..1f1e6810 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1323,9 +1323,9 @@ dependencies = [ [[package]] name = "terminal-colorsaurus" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c374383f597b763eb3bd06bc4e18f85510a52d7f1ac762f0c7e413ce696079fc" +checksum = "7973977cea42c1a8ae8f5755caa43f6b555a861a20809acdbed0e73b944801e4" dependencies = [ "libc", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 03877c30..85a3280e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,7 +68,7 @@ bytesize = { version = "1.3.0" } encoding_rs = "0.8.33" os_str_bytes = { version = "~6.6", optional = true } run_script = { version = "^0.10.1", optional = true} -terminal-colorsaurus = { version = "0.3.1" } +terminal-colorsaurus = { version = "0.3.2" } [dependencies.git2] version = "0.18" From e1602d79bcef909bbe50fb9e74650f5de7aca9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:39 +0200 Subject: [PATCH 12/17] Update readme --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 57baf2b0..38e0acd0 100644 --- a/README.md +++ b/README.md @@ -474,8 +474,10 @@ the following command (you need [`fzf`](https://github.com/junegunn/fzf) for thi bat --list-themes | fzf --preview="bat --theme={} --color=always /path/to/file" ``` -`bat` looks good on a dark background by default. However, if your terminal uses a -light background, some themes like `GitHub` or `OneHalfLight` will work better for you. +`bat` automatically picks a fitting theme depending on your terminal's background color. +You can use the `--theme-light` / `--theme-light` options or the `BAT_THEME_DARK` / `BAT_THEME_LIGHT` environment variables +to customize the themes used. This is especially useful if you frequently switch between dark and light mode. + You can also use a custom theme by following the ['Adding new themes' section below](https://github.com/sharkdp/bat#adding-new-themes). From 61132ddf9ed9126c135fa7983251cd8c9cb2b8ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:42 +0200 Subject: [PATCH 13/17] Disable color detection in test --- tests/integration_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 9de8236a..34932994 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -388,6 +388,7 @@ fn no_args_doesnt_break() { // as the slave end of a pseudo terminal. Although both point to the same "file", bat should // not exit, because in this case it is safe to read and write to the same fd, which is why // this test exists. + let OpenptyResult { master, slave } = openpty(None, None).expect("Couldn't open pty."); let mut master = unsafe { File::from_raw_fd(master) }; let stdin_file = unsafe { File::from_raw_fd(slave) }; @@ -398,6 +399,7 @@ fn no_args_doesnt_break() { let mut child = bat_raw_command() .stdin(stdin) .stdout(stdout) + .env("TERM", "dumb") // Suppresses color detection .spawn() .expect("Failed to start."); From c34a350ef0f94f8753efa9d8efa05805b6610ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:44 +0200 Subject: [PATCH 14/17] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa912c7..dd665c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - `bat --squeeze-limit` to set the maximum number of empty consecutive when using `--squeeze-blank`, see #1441 (@eth-p) and #2665 (@einfachIrgendwer0815) - `PrettyPrinter::squeeze_empty_lines` to support line squeezing for bat as a library, see #1441 (@eth-p) and #2665 (@einfachIrgendwer0815) - Syntax highlighting for JavaScript files that start with `#!/usr/bin/env bun` #2913 (@sharunkumar) +- Automatically choose theme based on the terminal's color scheme, see #2896 (@bash) ## Bugfixes From e6afa13ef4b552528371c2fc398d98c0ea781637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:46 +0200 Subject: [PATCH 15/17] Update terminal-colorsaurus (again) --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1f1e6810..be22a1ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1323,9 +1323,9 @@ dependencies = [ [[package]] name = "terminal-colorsaurus" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7973977cea42c1a8ae8f5755caa43f6b555a861a20809acdbed0e73b944801e4" +checksum = "a11d4fd698c3b697b6f712fa75f62f7b15119f41b0bb660741dd02297f534d78" dependencies = [ "libc", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 85a3280e..13a7e0ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,7 +68,7 @@ bytesize = { version = "1.3.0" } encoding_rs = "0.8.33" os_str_bytes = { version = "~6.6", optional = true } run_script = { version = "^0.10.1", optional = true} -terminal-colorsaurus = { version = "0.3.2" } +terminal-colorsaurus = { version = "0.3.3" } [dependencies.git2] version = "0.18" From 1c6d40f562b87150d47b8ffe80b4fd3b1dc4711c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:48 +0200 Subject: [PATCH 16/17] Document breaking change in library --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd665c5c..8a51536d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,9 @@ ## `bat` as a library +- Add `theme::theme` for choosing an appropriate theme based on the + terminal's color scheme, see #2896 (@bash) + - [BREAKING] Remove `HighlightingAssets::default_theme`. Use `theme::default_theme` instead. - Changes to `syntax_mapping::SyntaxMapping` #2755 (@cyqsimon) - `SyntaxMapping::get_syntax_for` is now correctly public - [BREAKING] `SyntaxMapping::{empty,builtin}` are removed; use `SyntaxMapping::new` instead From 4409c567546daba2c12cdcdc1b70e8237064e7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tau=20G=C3=A4rtli?= Date: Tue, 16 Apr 2024 14:43:50 +0200 Subject: [PATCH 17/17] Use new `default_theme` fn for --list-themes --- src/bin/bat/main.rs | 10 +++++++--- src/theme.rs | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/bin/bat/main.rs b/src/bin/bat/main.rs index 78202539..c8e03d7c 100644 --- a/src/bin/bat/main.rs +++ b/src/bin/bat/main.rs @@ -30,13 +30,12 @@ use directories::PROJECT_DIRS; use globset::GlobMatcher; use bat::{ - assets::HighlightingAssets, config::Config, controller::Controller, error::*, input::Input, style::{StyleComponent, StyleComponents}, - MappingTarget, PagingMode, + theme, MappingTarget, PagingMode, }; const THEME_PREVIEW_DATA: &[u8] = include_bytes!("../../../assets/theme_preview.rs"); @@ -201,10 +200,15 @@ pub fn list_themes(cfg: &Config, config_dir: &Path, cache_dir: &Path) -> Result< let mut stdout = stdout.lock(); if config.colored_output { - let default_theme = HighlightingAssets::default_theme(); + use theme::{color_scheme, default_theme, ColorScheme, DetectColorScheme}; + let default_theme = default_theme(color_scheme(DetectColorScheme::Auto)); for theme in assets.themes() { let default_theme_info = if default_theme == theme { " (default)" + } else if theme::default_theme(ColorScheme::Dark) == theme { + " (default dark)" + } else if theme::default_theme(ColorScheme::Light) == theme { + " (default light)" } else { "" }; diff --git a/src/theme.rs b/src/theme.rs index 89627049..9af8e735 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -19,6 +19,11 @@ pub const fn default_theme(color_scheme: ColorScheme) -> &'static str { } } +/// Detects the color scheme from the terminal. +pub fn color_scheme(when: DetectColorScheme) -> ColorScheme { + detect(when, &TerminalColorSchemeDetector).unwrap_or_default() +} + /// Options for configuring the theme used for syntax highlighting. /// Used together with [`theme`]. #[derive(Debug, Default, PartialEq, Eq)]