diff --git a/src/app/app_context.rs b/src/app/app_context.rs index 2ea683aa..711ad80b 100644 --- a/src/app/app_context.rs +++ b/src/app/app_context.rs @@ -30,6 +30,10 @@ use { verb::*, }, crokey::crossterm::tty::IsTty, + syntect::highlighting::{ + Theme, + ThemeSet, + }, std::{ convert::{ TryFrom, @@ -91,6 +95,10 @@ pub struct AppContext { /// the syntect theme to use for text files previewing pub syntax_theme: Option, + /// a theme loaded from a .tmTheme file, used when syntax_theme is None, + /// which it is until a theme is chosen with :set_syntax_theme + pub custom_syntax_theme: Option, + /// precomputed status to display in standard cases /// (ie when no verb is involved) pub standard_status: StandardStatus, @@ -261,6 +269,16 @@ impl AppContext { } let reset_terminal_title_on_exit = config.reset_terminal_title_on_exit.unwrap_or(false); let preview_transformers = PreviewTransformers::new(&config.preview_transformers)?; + let custom_syntax_theme = config + .syntax_theme_file + .as_ref() + .map(|path| { + ThemeSet::get_theme(path).map_err(|e| ConfError::InvalidSyntaxThemeFile { + path: path.display().to_string(), + details: e.to_string(), + }) + }) + .transpose()?; let layout_instructions = config.layout_instructions.clone().unwrap_or_default(); let kept_kitty_temp_files = config.kept_kitty_temp_files.unwrap_or( #[expect(clippy::missing_panics_doc, reason = "infallible")] @@ -284,6 +302,7 @@ impl AppContext { preview_overflow: config.wrap_previews.map(Overflow::from_wrap_bool).unwrap_or_default(), ext_colors, syntax_theme: config.syntax_theme, + custom_syntax_theme, standard_status, true_colors, icons, diff --git a/src/conf/conf.rs b/src/conf/conf.rs index b4a6b8fe..c08646af 100644 --- a/src/conf/conf.rs +++ b/src/conf/conf.rs @@ -157,6 +157,11 @@ pub struct Conf { #[serde(alias = "syntax-theme")] pub syntax_theme: Option, + /// path to a syntect compatible .tmTheme file, used for previews + /// instead of syntax_theme. An empty path unsets it. + #[serde(alias = "syntax-theme-file")] + pub syntax_theme_file: Option, + #[serde(alias = "terminal-title")] pub terminal_title: Option, @@ -248,7 +253,21 @@ impl Conf { overwrite!(self, default_flags, conf); overwrite!(self, date_time_format, conf); overwrite!(self, icon_theme, conf); - overwrite!(self, syntax_theme, conf); + // syntax_theme and syntax_theme_file replace each other, so that the + // last one read wins; when a file sets both, syntax_theme_file wins + if conf.syntax_theme.is_some() { + self.syntax_theme = conf.syntax_theme.take(); + self.syntax_theme_file = None; + } + if let Some(file) = conf.syntax_theme_file.take() { + let file = file.to_string_lossy(); + // an empty path unsets it + self.syntax_theme_file = (!file.is_empty()).then(|| { + // relative to the conf file declaring it + path_from(&path, PathAnchor::Parent, &file) + }); + self.syntax_theme = None; + } overwrite!(self, disable_mouse_capture, conf); overwrite!(self, capture_mouse, conf); overwrite!(self, true_colors, conf); diff --git a/src/conf/conf_error.rs b/src/conf/conf_error.rs index 0262cacd..f6e578a6 100644 --- a/src/conf/conf_error.rs +++ b/src/conf/conf_error.rs @@ -67,6 +67,8 @@ pub enum ConfError { InvalidDefaultFlags { flags: String }, #[error("invalid syntax theme: `{name}`")] InvalidSyntaxTheme { name: String }, + #[error("invalid syntax theme file `{path}`: {details}")] + InvalidSyntaxThemeFile { path: String, details: String }, #[error("invalid glob pattern: `{pattern}`")] InvalidGlobPattern { pattern: String }, #[error( diff --git a/src/syntactic/syntaxer.rs b/src/syntactic/syntaxer.rs index 806d3ce7..1492fc88 100644 --- a/src/syntactic/syntaxer.rs +++ b/src/syntactic/syntaxer.rs @@ -41,21 +41,23 @@ impl Syntaxer { self.theme_set.themes.keys() } - pub fn highlighter_for( - &self, + pub fn highlighter_for<'s>( + &'s self, path: &Path, - con: &AppContext, - ) -> Option> { + con: &'s AppContext, + ) -> Option> { path.extension() .and_then(|e| e.to_str()) .and_then(|ext| self.syntax_set.find_syntax_by_extension(ext)) .map(|syntax| { - let theme = con.syntax_theme.unwrap_or_default(); - let theme = self - .theme_set - .themes - .get(theme.syntect_name()) - .unwrap_or_else(|| self.theme_set.themes.iter().next().unwrap().1); + let theme = match (con.syntax_theme, &con.custom_syntax_theme) { + (None, Some(theme)) => theme, + (theme, _) => self + .theme_set + .themes + .get(theme.unwrap_or_default().syntect_name()) + .unwrap_or_else(|| self.theme_set.themes.iter().next().unwrap().1), + }; let options = HighlightOptions { ignore_errors: true, }; diff --git a/src/syntactic/text_view.rs b/src/syntactic/text_view.rs index 6c6982f6..a026c981 100644 --- a/src/syntactic/text_view.rs +++ b/src/syntactic/text_view.rs @@ -63,7 +63,8 @@ pub static SEPARATOR_FILLING: Lazy = Lazy::new(|| Filling::from_char(' /// Homogeneously colored piece of a line #[derive(Debug, Clone)] pub struct Region { - pub fg: Color, + /// None when the preview's normal foreground is to be used + pub fg: Option, pub string: String, } @@ -77,10 +78,18 @@ const INITIAL_LOAD: usize = 4_000_000; impl Region { pub fn from_syntect(region: &(Style, &str)) -> Self { - let fg = Color::Rgb { - r: region.0.foreground.r, - g: region.0.foreground.g, - b: region.0.foreground.b, + // As in bat's ansi and base16 themes, an alpha of 0 makes the red + // channel a terminal palette index, and an alpha of 1 means the + // default foreground + let c = region.0.foreground; + let fg = match c.a { + 0 => Some(Color::AnsiValue(c.r)), + 1 => None, + _ => Some(Color::Rgb { + r: c.r, + g: c.g, + b: c.b, + }), }; let string = region.1.to_string(); Self { fg, string } @@ -572,7 +581,7 @@ impl TextView { let regions = if line.regions.is_empty() && line.len > 0 { match rows.read(line) { Some(string) => Cow::Owned(vec![Region { - fg: normal_fg, + fg: None, string, }]), None => { @@ -618,7 +627,7 @@ impl TextView { let pos_list = line.name_match.as_ref().map(|nm| &nm.pos); if overflow == Overflow::NoWrap && pos_list.is_none() { for region in regions { - cw.w.queue(SetForegroundColor(region.fg))?; + cw.w.queue(SetForegroundColor(region.fg.unwrap_or(normal_fg)))?; let s = region.string.trim_end_matches(is_char_end_of_line); if s.contains('\t') { cw.queue_unstyled_str(&s.replace('\t', &" ".repeat(TAB_WIDTH)))?; @@ -634,7 +643,7 @@ impl TextView { let mut run = String::new(); 'regions: for region in regions { flush(&mut cw, &mut run)?; - cw.w.queue(SetForegroundColor(region.fg))?; + cw.w.queue(SetForegroundColor(region.fg.unwrap_or(normal_fg)))?; for c in region.string.chars() { if ci >= to { break 'regions; diff --git a/website/src/conf_file.md b/website/src/conf_file.md index 58fcf4c8..7c7caa69 100644 --- a/website/src/conf_file.md +++ b/website/src/conf_file.md @@ -266,6 +266,24 @@ syntax_theme = "OceanLight" Those themes come from [syntect](https://github.com/trishume/syntect) and are bundled in broot. +You may also use your own theme, in the `.tmTheme` format read by syntect (and by [bat](https://github.com/sharkdp/bat)), by giving its path: + +```Hjson +syntax_theme_file: ~/.config/bat/themes/my-theme.tmTheme +``` +```TOML +syntax_theme_file = "~/.config/bat/themes/my-theme.tmTheme" +``` + +A relative path is resolved from the configuration file declaring it. + +`syntax_theme` and `syntax_theme_file` replace each other: the one read last wins. +As imported files are read after the file importing them, a `syntax_theme` set in your skin replaces a `syntax_theme_file` set in `conf.hjson`, so it's usually best to set `syntax_theme_file` in the skin file your `conf.hjson` imports (for example `skins/dark-blue.hjson`), in place of its `syntax_theme`. +An empty `syntax_theme_file` unsets it. +A theme chosen with `:set_syntax_theme` replaces the theme file until broot is closed. + +Themes made for the terminal's palette, like bat's `ansi` and `base16` themes, are supported. + # Terminal title