From d62b1ee81328ffb987378506dbd7329da2628aea Mon Sep 17 00:00:00 2001 From: matthewhadley Date: Sat, 10 Oct 2026 20:53:46 -0500 Subject: [PATCH 1/2] syntax_theme_file: preview with a syntect .tmTheme file A path to a .tmTheme file, the format syntect and bat read, gives code previews a theme of the user's own rather than one of the seven bundled ones. A relative path is resolved from the conf file declaring it, like imports. The file takes precedence over a syntax_theme set in conf, as broot's own skins set one; :set_syntax_theme still replaces it at runtime. A file which can't be read or parsed is a conf error. Fixes #318 Co-Authored-By: Claude Opus 5.5 --- src/app/app_context.rs | 22 +++++++++++++++++++++- src/conf/conf.rs | 13 +++++++++++++ src/errors.rs | 1 + src/syntactic/syntaxer.rs | 22 ++++++++++++---------- website/src/conf_file.md | 12 ++++++++++++ 5 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/app/app_context.rs b/src/app/app_context.rs index 11e4d37e..4d60e71f 100644 --- a/src/app/app_context.rs +++ b/src/app/app_context.rs @@ -29,6 +29,10 @@ use { verb::*, }, crokey::crossterm::tty::IsTty, + syntect::highlighting::{ + Theme, + ThemeSet, + }, std::{ convert::{ TryFrom, @@ -90,6 +94,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 unless a syntax_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, @@ -246,6 +254,16 @@ impl AppContext { let terminal_title_pattern = config.terminal_title.clone(); 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")] @@ -268,7 +286,9 @@ impl AppContext { show_selection_mark: config.show_selection_mark.unwrap_or(false), preview_overflow: config.wrap_previews.map(Overflow::from_wrap_bool).unwrap_or_default(), ext_colors, - syntax_theme: config.syntax_theme, + // a theme file is more specific than the syntax_theme of a skin + syntax_theme: config.syntax_theme.filter(|_| custom_syntax_theme.is_none()), + custom_syntax_theme, standard_status, true_colors, icons, diff --git a/src/conf/conf.rs b/src/conf/conf.rs index 10e69992..6a8967aa 100644 --- a/src/conf/conf.rs +++ b/src/conf/conf.rs @@ -155,6 +155,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 + #[serde(alias = "syntax-theme-file")] + pub syntax_theme_file: Option, + #[serde(alias = "terminal-title")] pub terminal_title: Option, @@ -247,6 +252,14 @@ impl Conf { overwrite!(self, date_time_format, conf); overwrite!(self, icon_theme, conf); overwrite!(self, syntax_theme, conf); + if let Some(file) = conf.syntax_theme_file.take() { + // relative to the conf file declaring it + self.syntax_theme_file = Some(path_from( + &path, + PathAnchor::Parent, + &file.to_string_lossy(), + )); + } overwrite!(self, disable_mouse_capture, conf); overwrite!(self, capture_mouse, conf); overwrite!(self, true_colors, conf); diff --git a/src/errors.rs b/src/errors.rs index 97baf7d6..13c834af 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -118,6 +118,7 @@ custom_error! {pub ConfError InvalidThreadsCount { count: usize } = "invalid threads count: {count}", InvalidDefaultFlags { flags: String } = "invalid default flags: {flags:?}", InvalidSyntaxTheme { name: String } = "invalid syntax theme: {name:?}", + InvalidSyntaxThemeFile { path: String, details: String } = "invalid syntax theme file {path:?}: {details}", InvalidGlobPattern { pattern: String } = "invalid glob pattern: {pattern:?}", InvalidVerbName { name: String } = "invalid verb name: {name:?} (must either not start with a special character or be only made of special characters)", UnknownVerbArgFlag { name: String } = "Unknown verb argument flag: {name:?}", 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/website/src/conf_file.md b/website/src/conf_file.md index 58fcf4c8..d984dc1e 100644 --- a/website/src/conf_file.md +++ b/website/src/conf_file.md @@ -266,6 +266,18 @@ 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. +The theme file takes precedence over a `syntax_theme` set in the configuration (for example by a skin), but a theme chosen with `:set_syntax_theme` replaces it until broot is closed. + # Terminal title From 541b1bb17fbbfa738115c94e03e033239d858518 Mon Sep 17 00:00:00 2001 From: matthewhadley Date: Thu, 22 Oct 2026 13:16:20 -0500 Subject: [PATCH 2/2] syntax_theme_file: support palette themes, unsetting, last read wins Colours with an alpha of 0 or 1, as in bat's ansi and base16 themes, are read as a terminal palette index and as the default foreground. syntax_theme and syntax_theme_file now replace each other as conf files are read, so the last one read wins, and an empty syntax_theme_file unsets it. --- src/app/app_context.rs | 7 +++---- src/conf/conf.rs | 22 ++++++++++++++-------- src/syntactic/text_view.rs | 25 +++++++++++++++++-------- website/src/conf_file.md | 8 +++++++- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/app/app_context.rs b/src/app/app_context.rs index 4d60e71f..8dcb0c8d 100644 --- a/src/app/app_context.rs +++ b/src/app/app_context.rs @@ -94,8 +94,8 @@ pub struct AppContext { /// the syntect theme to use for text files previewing pub syntax_theme: Option, - /// a theme loaded from a .tmTheme file, used unless a syntax_theme - /// is chosen with :set_syntax_theme + /// 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 @@ -286,8 +286,7 @@ impl AppContext { show_selection_mark: config.show_selection_mark.unwrap_or(false), preview_overflow: config.wrap_previews.map(Overflow::from_wrap_bool).unwrap_or_default(), ext_colors, - // a theme file is more specific than the syntax_theme of a skin - syntax_theme: config.syntax_theme.filter(|_| custom_syntax_theme.is_none()), + syntax_theme: config.syntax_theme, custom_syntax_theme, standard_status, true_colors, diff --git a/src/conf/conf.rs b/src/conf/conf.rs index 6a8967aa..fe10e961 100644 --- a/src/conf/conf.rs +++ b/src/conf/conf.rs @@ -156,7 +156,7 @@ pub struct Conf { pub syntax_theme: Option, /// path to a syntect compatible .tmTheme file, used for previews - /// instead of syntax_theme + /// instead of syntax_theme. An empty path unsets it. #[serde(alias = "syntax-theme-file")] pub syntax_theme_file: Option, @@ -251,14 +251,20 @@ 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() { - // relative to the conf file declaring it - self.syntax_theme_file = Some(path_from( - &path, - PathAnchor::Parent, - &file.to_string_lossy(), - )); + 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); diff --git a/src/syntactic/text_view.rs b/src/syntactic/text_view.rs index c5bdabe5..06ebab2c 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 d984dc1e..7c7caa69 100644 --- a/website/src/conf_file.md +++ b/website/src/conf_file.md @@ -276,7 +276,13 @@ syntax_theme_file = "~/.config/bat/themes/my-theme.tmTheme" ``` A relative path is resolved from the configuration file declaring it. -The theme file takes precedence over a `syntax_theme` set in the configuration (for example by a skin), but a theme chosen with `:set_syntax_theme` replaces it until broot is closed. + +`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