From f11a3510ba07ae87747d744d952676476a88c24e Mon Sep 17 00:00:00 2001 From: Matt Keeter Date: Tue, 24 Jun 2025 09:09:29 -0400 Subject: [PATCH] Support custom syntect settings in syntax highlighter (#7084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Closes * [X] I have followed the instructions in the PR template This PR adds support for syntax highlighting with custom `syntect::parsing::SyntaxSet` and `syntect::highlighting::ThemeSet`. It adds a new `egui_extras::highlight_with` function (enabled with `feature = "syntect"`), which takes a new public`struct SyntectSettings` containing the syntax and theme sets. ```rust let mut builder = SyntaxSetBuilder::new(); builder.add_from_folder("syntax", true).unwrap(); let ps = builder.build(); let ts = syntect::highlighting::ThemeSet::load_defaults(); let syntax = egui_extras::syntax_highlighting::SyntectSettings { ps, ts }; // ...elsewhere egui_extras::syntax_highlighting::highlight_with( ui.ctx(), ui.style(), &theme, buf, "rhai", &syntax, ); ``` There's a little bit of architectural complexity, but it all emerges naturally from the problem's constraints. Previously, the `Highlighter` both contained the `syntect` settings _and_ implemented `egui::cache::ComputerMut` to highlight a string; the settings would never change. After this change, the `syntect` settings have become part of the cache key, so we should redo highlighting if they change. The `Highlighter` becomes an empty `struct` which just serves to implement `ComputerMut`. `SyntaxSet` and `ThemeSet` are not hasheable themselves, so can't be used as cache keys direction. Instead, we can use the *address* of the `&SyntectSettings` as the key. This requires an object with a custom `Hash` implementation, so I added a new `HighlightSettings(&'a SyntectSettings)`, implementing `Hash` using `std::ptr::hash` on the reference. I think using the address is reasonable – it would be _weird_ for a user to be constantly moving around their `SyntectSettings`, and there's a warning in the docstring to this effect. To work _without_ custom settings, `SyntectSettings::default` preserves the same behavior as before, using `SyntaxSet::load_defaults_newlines` and `ThemeSet::load_defaults`. If the user doesn't provide custom settings, then we instantiate a singleton `SyntectSettings` in `data` and use it; this will only be constructed once. Finally, in cases where the `syntect` feature is disabled, `SyntectSettings` are replaced with a unit `()`. This adds a _tiny_ amount of overhead – one singleton `Arc<()>` allocation and a lookup in `data` per `highlight` – but I think that's better than dramatically different implementations. If this is an issue, I can refactor to make it zero-cost when the feature is disabled. --- crates/egui_extras/src/syntax_highlighting.rs | 125 +++++++++++++++--- 1 file changed, 104 insertions(+), 21 deletions(-) diff --git a/crates/egui_extras/src/syntax_highlighting.rs b/crates/egui_extras/src/syntax_highlighting.rs index 8d688ae8..2476e783 100644 --- a/crates/egui_extras/src/syntax_highlighting.rs +++ b/crates/egui_extras/src/syntax_highlighting.rs @@ -28,18 +28,65 @@ pub fn highlight( theme: &CodeTheme, code: &str, language: &str, +) -> LayoutJob { + highlight_inner(ctx, style, theme, code, language, None) +} + +/// Add syntax highlighting to a code string, with custom `syntect` settings +/// +/// The results are memoized, so you can call this every frame without performance penalty. +/// +/// The `syntect` settings are memoized by *address*, so a stable reference should +/// be used to avoid unnecessary recomputation. +#[cfg(feature = "syntect")] +pub fn highlight_with( + ctx: &egui::Context, + style: &egui::Style, + theme: &CodeTheme, + code: &str, + language: &str, + settings: &SyntectSettings, +) -> LayoutJob { + highlight_inner( + ctx, + style, + theme, + code, + language, + Some(HighlightSettings(settings)), + ) +} + +fn highlight_inner( + ctx: &egui::Context, + style: &egui::Style, + theme: &CodeTheme, + code: &str, + language: &str, + settings: Option>, ) -> LayoutJob { // We take in both context and style so that in situations where ui is not available such as when // performing it at a separate thread (ctx, ctx.style()) can be used and when ui is available // (ui.ctx(), ui.style()) can be used #[expect(non_local_definitions)] - impl egui::cache::ComputerMut<(&egui::FontId, &CodeTheme, &str, &str), LayoutJob> for Highlighter { + impl + egui::cache::ComputerMut< + (&egui::FontId, &CodeTheme, &str, &str, HighlightSettings<'_>), + LayoutJob, + > for Highlighter + { fn compute( &mut self, - (font_id, theme, code, lang): (&egui::FontId, &CodeTheme, &str, &str), + (font_id, theme, code, lang, settings): ( + &egui::FontId, + &CodeTheme, + &str, + &str, + HighlightSettings<'_>, + ), ) -> LayoutJob { - self.highlight(font_id.clone(), theme, code, lang) + Self::highlight(font_id.clone(), theme, code, lang, settings) } } @@ -50,10 +97,27 @@ pub fn highlight( .clone() .unwrap_or_else(|| TextStyle::Monospace.resolve(style)); + // Private type, so that users can't interfere with it in the `IdTypeMap` + #[cfg(feature = "syntect")] + #[derive(Clone, Default)] + struct PrivateSettings(std::sync::Arc); + + // Dummy private settings, to minimize code changes without `syntect` + #[cfg(not(feature = "syntect"))] + #[derive(Clone, Default)] + struct PrivateSettings(std::sync::Arc<()>); + ctx.memory_mut(|mem| { + let settings = settings.unwrap_or_else(|| { + HighlightSettings( + &mem.data + .get_temp_mut_or_default::(egui::Id::NULL) + .0, + ) + }); mem.caches .cache::() - .get((&font_id, theme, code, language)) + .get((&font_id, theme, code, language, settings)) }) } @@ -396,13 +460,13 @@ impl CodeTheme { // ---------------------------------------------------------------------------- #[cfg(feature = "syntect")] -struct Highlighter { - ps: syntect::parsing::SyntaxSet, - ts: syntect::highlighting::ThemeSet, +pub struct SyntectSettings { + pub ps: syntect::parsing::SyntaxSet, + pub ts: syntect::highlighting::ThemeSet, } #[cfg(feature = "syntect")] -impl Default for Highlighter { +impl Default for SyntectSettings { fn default() -> Self { profiling::function_scope!(); Self { @@ -412,15 +476,33 @@ impl Default for Highlighter { } } +/// Highlight settings are memoized by reference address, rather than value +#[cfg(feature = "syntect")] +#[derive(Copy, Clone)] +struct HighlightSettings<'a>(&'a SyntectSettings); + +#[cfg(not(feature = "syntect"))] +#[derive(Copy, Clone)] +struct HighlightSettings<'a>(&'a ()); + +impl std::hash::Hash for HighlightSettings<'_> { + fn hash(&self, state: &mut H) { + std::ptr::hash(self.0, state); + } +} + +#[derive(Default)] +struct Highlighter; + impl Highlighter { fn highlight( - &self, font_id: egui::FontId, theme: &CodeTheme, code: &str, lang: &str, + settings: HighlightSettings<'_>, ) -> LayoutJob { - self.highlight_impl(theme, code, lang).unwrap_or_else(|| { + Self::highlight_impl(theme, code, lang, settings).unwrap_or_else(|| { // Fallback: LayoutJob::simple( code.into(), @@ -436,19 +518,25 @@ impl Highlighter { } #[cfg(feature = "syntect")] - fn highlight_impl(&self, theme: &CodeTheme, text: &str, language: &str) -> Option { + fn highlight_impl( + theme: &CodeTheme, + text: &str, + language: &str, + highlighter: HighlightSettings<'_>, + ) -> Option { profiling::function_scope!(); use syntect::easy::HighlightLines; use syntect::highlighting::FontStyle; use syntect::util::LinesWithEndings; - let syntax = self + let syntax = highlighter + .0 .ps .find_syntax_by_name(language) - .or_else(|| self.ps.find_syntax_by_extension(language))?; + .or_else(|| highlighter.0.ps.find_syntax_by_extension(language))?; let syn_theme = theme.syntect_theme.syntect_key_name(); - let mut h = HighlightLines::new(syntax, &self.ts.themes[syn_theme]); + let mut h = HighlightLines::new(syntax, &highlighter.0.ts.themes[syn_theme]); use egui::text::{LayoutSection, TextFormat}; @@ -458,7 +546,7 @@ impl Highlighter { }; for line in LinesWithEndings::from(text) { - for (style, range) in h.highlight_line(line, &self.ps).ok()? { + for (style, range) in h.highlight_line(line, &highlighter.0.ps).ok()? { let fg = style.foreground; let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b); let italics = style.font_style.contains(FontStyle::ITALIC); @@ -505,18 +593,13 @@ fn as_byte_range(whole: &str, range: &str) -> std::ops::Range { // ---------------------------------------------------------------------------- -#[cfg(not(feature = "syntect"))] -#[derive(Default)] -struct Highlighter {} - #[cfg(not(feature = "syntect"))] impl Highlighter { - #[expect(clippy::unused_self)] fn highlight_impl( - &self, theme: &CodeTheme, mut text: &str, language: &str, + _settings: HighlightSettings<'_>, ) -> Option { profiling::function_scope!();