Support custom syntect settings in syntax highlighter (#7084)

<!--
Please read the "Making a PR" section of
[`CONTRIBUTING.md`](https://github.com/emilk/egui/blob/main/CONTRIBUTING.md)
before opening a Pull Request!

* Keep your PR:s small and focused.
* The PR title is what ends up in the changelog, so make it descriptive!
* If applicable, add a screenshot or gif.
* If it is a non-trivial addition, consider adding a demo for it to
`egui_demo_lib`, or a new example.
* Do NOT open PR:s from your `master` branch, as that makes it hard for
maintainers to test and add commits to your PR.
* Remember to run `cargo fmt` and `cargo clippy`.
* Open the PR as a draft until you have self-reviewed it and run
`./scripts/check.sh`.
* When you have addressed a PR comment, mark it as resolved.

Please be patient! I will review your PR, but my time is limited!
-->

* Closes <https://github.com/emilk/egui/issues/3964>
* [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.
This commit is contained in:
Matt Keeter 2025-06-24 09:09:29 -04:00 committed by GitHub
parent 853feea464
commit f11a3510ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 104 additions and 21 deletions

View File

@ -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<HighlightSettings<'_>>,
) -> 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<SyntectSettings>);
// 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::<PrivateSettings>(egui::Id::NULL)
.0,
)
});
mem.caches
.cache::<HighlightCache>()
.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<H: std::hash::Hasher>(&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<LayoutJob> {
fn highlight_impl(
theme: &CodeTheme,
text: &str,
language: &str,
highlighter: HighlightSettings<'_>,
) -> Option<LayoutJob> {
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<usize> {
// ----------------------------------------------------------------------------
#[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<LayoutJob> {
profiling::function_scope!();