egui_extras/
syntax_highlighting.rs

1//! Syntax highlighting for code.
2//!
3//! Turn on the `syntect` feature for great syntax highlighting of any language.
4//! Otherwise, a very simple fallback will be used, that works okish for C, C++, Rust, and Python.
5
6#![allow(clippy::mem_forget)] // False positive from enum_map macro
7
8use egui::text::LayoutJob;
9use egui::TextStyle;
10
11/// View some code with syntax highlighting and selection.
12pub fn code_view_ui(
13    ui: &mut egui::Ui,
14    theme: &CodeTheme,
15    code: &str,
16    language: &str,
17) -> egui::Response {
18    let layout_job = highlight(ui.ctx(), ui.style(), theme, code, language);
19    ui.add(egui::Label::new(layout_job).selectable(true))
20}
21
22/// Add syntax highlighting to a code string.
23///
24/// The results are memoized, so you can call this every frame without performance penalty.
25pub fn highlight(
26    ctx: &egui::Context,
27    style: &egui::Style,
28    theme: &CodeTheme,
29    code: &str,
30    language: &str,
31) -> LayoutJob {
32    // We take in both context and style so that in situations where ui is not available such as when
33    // performing it at a separate thread (ctx, ctx.style()) can be used and when ui is available
34    // (ui.ctx(), ui.style()) can be used
35
36    impl egui::cache::ComputerMut<(&egui::FontId, &CodeTheme, &str, &str), LayoutJob> for Highlighter {
37        fn compute(
38            &mut self,
39            (font_id, theme, code, lang): (&egui::FontId, &CodeTheme, &str, &str),
40        ) -> LayoutJob {
41            self.highlight(font_id.clone(), theme, code, lang)
42        }
43    }
44
45    type HighlightCache = egui::cache::FrameCache<LayoutJob, Highlighter>;
46
47    let font_id = style
48        .override_font_id
49        .clone()
50        .unwrap_or_else(|| TextStyle::Monospace.resolve(style));
51
52    ctx.memory_mut(|mem| {
53        mem.caches
54            .cache::<HighlightCache>()
55            .get((&font_id, theme, code, language))
56    })
57}
58
59fn monospace_font_size(style: &egui::Style) -> f32 {
60    TextStyle::Monospace.resolve(style).size
61}
62
63// ----------------------------------------------------------------------------
64
65#[cfg(not(feature = "syntect"))]
66#[derive(Clone, Copy, PartialEq, enum_map::Enum)]
67#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
68enum TokenType {
69    Comment,
70    Keyword,
71    Literal,
72    StringLiteral,
73    Punctuation,
74    Whitespace,
75}
76
77#[cfg(feature = "syntect")]
78#[derive(Clone, Copy, Hash, PartialEq)]
79#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
80enum SyntectTheme {
81    Base16EightiesDark,
82    Base16MochaDark,
83    Base16OceanDark,
84    Base16OceanLight,
85    InspiredGitHub,
86    SolarizedDark,
87    SolarizedLight,
88}
89
90#[cfg(feature = "syntect")]
91impl SyntectTheme {
92    fn all() -> impl ExactSizeIterator<Item = Self> {
93        [
94            Self::Base16EightiesDark,
95            Self::Base16MochaDark,
96            Self::Base16OceanDark,
97            Self::Base16OceanLight,
98            Self::InspiredGitHub,
99            Self::SolarizedDark,
100            Self::SolarizedLight,
101        ]
102        .iter()
103        .copied()
104    }
105
106    fn name(&self) -> &'static str {
107        match self {
108            Self::Base16EightiesDark => "Base16 Eighties (dark)",
109            Self::Base16MochaDark => "Base16 Mocha (dark)",
110            Self::Base16OceanDark => "Base16 Ocean (dark)",
111            Self::Base16OceanLight => "Base16 Ocean (light)",
112            Self::InspiredGitHub => "InspiredGitHub (light)",
113            Self::SolarizedDark => "Solarized (dark)",
114            Self::SolarizedLight => "Solarized (light)",
115        }
116    }
117
118    fn syntect_key_name(&self) -> &'static str {
119        match self {
120            Self::Base16EightiesDark => "base16-eighties.dark",
121            Self::Base16MochaDark => "base16-mocha.dark",
122            Self::Base16OceanDark => "base16-ocean.dark",
123            Self::Base16OceanLight => "base16-ocean.light",
124            Self::InspiredGitHub => "InspiredGitHub",
125            Self::SolarizedDark => "Solarized (dark)",
126            Self::SolarizedLight => "Solarized (light)",
127        }
128    }
129
130    pub fn is_dark(&self) -> bool {
131        match self {
132            Self::Base16EightiesDark
133            | Self::Base16MochaDark
134            | Self::Base16OceanDark
135            | Self::SolarizedDark => true,
136
137            Self::Base16OceanLight | Self::InspiredGitHub | Self::SolarizedLight => false,
138        }
139    }
140}
141
142/// A selected color theme.
143#[derive(Clone, Hash, PartialEq)]
144#[cfg_attr(
145    feature = "serde",
146    derive(serde::Deserialize, serde::Serialize),
147    serde(default)
148)]
149pub struct CodeTheme {
150    dark_mode: bool,
151
152    #[cfg(feature = "syntect")]
153    syntect_theme: SyntectTheme,
154    #[cfg(feature = "syntect")]
155    font_id: egui::FontId,
156
157    #[cfg(not(feature = "syntect"))]
158    formats: enum_map::EnumMap<TokenType, egui::TextFormat>,
159}
160
161impl Default for CodeTheme {
162    fn default() -> Self {
163        Self::dark(12.0)
164    }
165}
166
167impl CodeTheme {
168    /// Selects either dark or light theme based on the given style.
169    pub fn from_style(style: &egui::Style) -> Self {
170        let font_id = style
171            .override_font_id
172            .clone()
173            .unwrap_or_else(|| TextStyle::Monospace.resolve(style));
174
175        if style.visuals.dark_mode {
176            Self::dark_with_font_id(font_id)
177        } else {
178            Self::light_with_font_id(font_id)
179        }
180    }
181
182    /// ### Example
183    ///
184    /// ```
185    /// # egui::__run_test_ui(|ui| {
186    /// use egui_extras::syntax_highlighting::CodeTheme;
187    /// let theme = CodeTheme::dark(12.0);
188    /// # });
189    /// ```
190    pub fn dark(font_size: f32) -> Self {
191        Self::dark_with_font_id(egui::FontId::monospace(font_size))
192    }
193
194    /// ### Example
195    ///
196    /// ```
197    /// # egui::__run_test_ui(|ui| {
198    /// use egui_extras::syntax_highlighting::CodeTheme;
199    /// let theme = CodeTheme::light(12.0);
200    /// # });
201    /// ```
202    pub fn light(font_size: f32) -> Self {
203        Self::light_with_font_id(egui::FontId::monospace(font_size))
204    }
205
206    /// Load code theme from egui memory.
207    ///
208    /// There is one dark and one light theme stored at any one time.
209    pub fn from_memory(ctx: &egui::Context, style: &egui::Style) -> Self {
210        #![allow(clippy::needless_return)]
211
212        let (id, default) = if style.visuals.dark_mode {
213            (egui::Id::new("dark"), Self::dark as fn(f32) -> Self)
214        } else {
215            (egui::Id::new("light"), Self::light as fn(f32) -> Self)
216        };
217
218        #[cfg(feature = "serde")]
219        {
220            return ctx.data_mut(|d| {
221                d.get_persisted(id)
222                    .unwrap_or_else(|| default(monospace_font_size(style)))
223            });
224        }
225
226        #[cfg(not(feature = "serde"))]
227        {
228            return ctx.data_mut(|d| {
229                d.get_temp(id)
230                    .unwrap_or_else(|| default(monospace_font_size(style)))
231            });
232        }
233    }
234
235    /// Store theme to egui memory.
236    ///
237    /// There is one dark and one light theme stored at any one time.
238    pub fn store_in_memory(self, ctx: &egui::Context) {
239        let id = if ctx.style().visuals.dark_mode {
240            egui::Id::new("dark")
241        } else {
242            egui::Id::new("light")
243        };
244
245        #[cfg(feature = "serde")]
246        ctx.data_mut(|d| d.insert_persisted(id, self));
247
248        #[cfg(not(feature = "serde"))]
249        ctx.data_mut(|d| d.insert_temp(id, self));
250    }
251}
252
253#[cfg(feature = "syntect")]
254impl CodeTheme {
255    fn dark_with_font_id(font_id: egui::FontId) -> Self {
256        Self {
257            dark_mode: true,
258            syntect_theme: SyntectTheme::Base16MochaDark,
259            font_id,
260        }
261    }
262
263    fn light_with_font_id(font_id: egui::FontId) -> Self {
264        Self {
265            dark_mode: false,
266            syntect_theme: SyntectTheme::SolarizedLight,
267            font_id,
268        }
269    }
270
271    /// Show UI for changing the color theme.
272    pub fn ui(&mut self, ui: &mut egui::Ui) {
273        egui::widgets::global_theme_preference_buttons(ui);
274
275        for theme in SyntectTheme::all() {
276            if theme.is_dark() == self.dark_mode {
277                ui.radio_value(&mut self.syntect_theme, theme, theme.name());
278            }
279        }
280    }
281}
282
283#[cfg(not(feature = "syntect"))]
284impl CodeTheme {
285    // The syntect version takes it by value. This could be avoided by specializing the from_style
286    // function, but at the cost of more code duplication.
287    #[allow(clippy::needless_pass_by_value)]
288    fn dark_with_font_id(font_id: egui::FontId) -> Self {
289        use egui::{Color32, TextFormat};
290        Self {
291            dark_mode: true,
292            formats: enum_map::enum_map![
293                TokenType::Comment => TextFormat::simple(font_id.clone(), Color32::from_gray(120)),
294                TokenType::Keyword => TextFormat::simple(font_id.clone(), Color32::from_rgb(255, 100, 100)),
295                TokenType::Literal => TextFormat::simple(font_id.clone(), Color32::from_rgb(87, 165, 171)),
296                TokenType::StringLiteral => TextFormat::simple(font_id.clone(), Color32::from_rgb(109, 147, 226)),
297                TokenType::Punctuation => TextFormat::simple(font_id.clone(), Color32::LIGHT_GRAY),
298                TokenType::Whitespace => TextFormat::simple(font_id.clone(), Color32::TRANSPARENT),
299            ],
300        }
301    }
302
303    // The syntect version takes it by value
304    #[allow(clippy::needless_pass_by_value)]
305    fn light_with_font_id(font_id: egui::FontId) -> Self {
306        use egui::{Color32, TextFormat};
307        Self {
308            dark_mode: false,
309            #[cfg(not(feature = "syntect"))]
310            formats: enum_map::enum_map![
311                TokenType::Comment => TextFormat::simple(font_id.clone(), Color32::GRAY),
312                TokenType::Keyword => TextFormat::simple(font_id.clone(), Color32::from_rgb(235, 0, 0)),
313                TokenType::Literal => TextFormat::simple(font_id.clone(), Color32::from_rgb(153, 134, 255)),
314                TokenType::StringLiteral => TextFormat::simple(font_id.clone(), Color32::from_rgb(37, 203, 105)),
315                TokenType::Punctuation => TextFormat::simple(font_id.clone(), Color32::DARK_GRAY),
316                TokenType::Whitespace => TextFormat::simple(font_id.clone(), Color32::TRANSPARENT),
317            ],
318        }
319    }
320
321    /// Show UI for changing the color theme.
322    pub fn ui(&mut self, ui: &mut egui::Ui) {
323        ui.horizontal_top(|ui| {
324            let selected_id = egui::Id::NULL;
325
326            #[cfg(feature = "serde")]
327            let mut selected_tt: TokenType =
328                ui.data_mut(|d| *d.get_persisted_mut_or(selected_id, TokenType::Comment));
329
330            #[cfg(not(feature = "serde"))]
331            let mut selected_tt: TokenType =
332                ui.data_mut(|d| *d.get_temp_mut_or(selected_id, TokenType::Comment));
333
334            ui.vertical(|ui| {
335                ui.set_width(150.0);
336                egui::widgets::global_theme_preference_buttons(ui);
337
338                ui.add_space(8.0);
339                ui.separator();
340                ui.add_space(8.0);
341
342                ui.scope(|ui| {
343                    for (tt, tt_name) in [
344                        (TokenType::Comment, "// comment"),
345                        (TokenType::Keyword, "keyword"),
346                        (TokenType::Literal, "literal"),
347                        (TokenType::StringLiteral, "\"string literal\""),
348                        (TokenType::Punctuation, "punctuation ;"),
349                        // (TokenType::Whitespace, "whitespace"),
350                    ] {
351                        let format = &mut self.formats[tt];
352                        ui.style_mut().override_font_id = Some(format.font_id.clone());
353                        ui.visuals_mut().override_text_color = Some(format.color);
354                        ui.radio_value(&mut selected_tt, tt, tt_name);
355                    }
356                });
357
358                let reset_value = if self.dark_mode {
359                    Self::dark(monospace_font_size(ui.style()))
360                } else {
361                    Self::light(monospace_font_size(ui.style()))
362                };
363
364                if ui
365                    .add_enabled(*self != reset_value, egui::Button::new("Reset theme"))
366                    .clicked()
367                {
368                    *self = reset_value;
369                }
370            });
371
372            ui.add_space(16.0);
373
374            #[cfg(feature = "serde")]
375            ui.data_mut(|d| d.insert_persisted(selected_id, selected_tt));
376            #[cfg(not(feature = "serde"))]
377            ui.data_mut(|d| d.insert_temp(selected_id, selected_tt));
378
379            egui::Frame::group(ui.style())
380                .inner_margin(egui::Vec2::splat(2.0))
381                .show(ui, |ui| {
382                    // ui.group(|ui| {
383                    ui.style_mut().override_text_style = Some(egui::TextStyle::Small);
384                    ui.spacing_mut().slider_width = 128.0; // Controls color picker size
385                    egui::widgets::color_picker::color_picker_color32(
386                        ui,
387                        &mut self.formats[selected_tt].color,
388                        egui::color_picker::Alpha::Opaque,
389                    );
390                });
391        });
392    }
393}
394
395// ----------------------------------------------------------------------------
396
397#[cfg(feature = "syntect")]
398struct Highlighter {
399    ps: syntect::parsing::SyntaxSet,
400    ts: syntect::highlighting::ThemeSet,
401}
402
403#[cfg(feature = "syntect")]
404impl Default for Highlighter {
405    fn default() -> Self {
406        profiling::function_scope!();
407        Self {
408            ps: syntect::parsing::SyntaxSet::load_defaults_newlines(),
409            ts: syntect::highlighting::ThemeSet::load_defaults(),
410        }
411    }
412}
413
414impl Highlighter {
415    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
416    fn highlight(
417        &self,
418        font_id: egui::FontId,
419        theme: &CodeTheme,
420        code: &str,
421        lang: &str,
422    ) -> LayoutJob {
423        self.highlight_impl(theme, code, lang).unwrap_or_else(|| {
424            // Fallback:
425            LayoutJob::simple(
426                code.into(),
427                font_id,
428                if theme.dark_mode {
429                    egui::Color32::LIGHT_GRAY
430                } else {
431                    egui::Color32::DARK_GRAY
432                },
433                f32::INFINITY,
434            )
435        })
436    }
437
438    #[cfg(feature = "syntect")]
439    fn highlight_impl(&self, theme: &CodeTheme, text: &str, language: &str) -> Option<LayoutJob> {
440        profiling::function_scope!();
441        use syntect::easy::HighlightLines;
442        use syntect::highlighting::FontStyle;
443        use syntect::util::LinesWithEndings;
444
445        let syntax = self
446            .ps
447            .find_syntax_by_name(language)
448            .or_else(|| self.ps.find_syntax_by_extension(language))?;
449
450        let syn_theme = theme.syntect_theme.syntect_key_name();
451        let mut h = HighlightLines::new(syntax, &self.ts.themes[syn_theme]);
452
453        use egui::text::{LayoutSection, TextFormat};
454
455        let mut job = LayoutJob {
456            text: text.into(),
457            ..Default::default()
458        };
459
460        for line in LinesWithEndings::from(text) {
461            for (style, range) in h.highlight_line(line, &self.ps).ok()? {
462                let fg = style.foreground;
463                let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b);
464                let italics = style.font_style.contains(FontStyle::ITALIC);
465                let underline = style.font_style.contains(FontStyle::ITALIC);
466                let underline = if underline {
467                    egui::Stroke::new(1.0, text_color)
468                } else {
469                    egui::Stroke::NONE
470                };
471                job.sections.push(LayoutSection {
472                    leading_space: 0.0,
473                    byte_range: as_byte_range(text, range),
474                    format: TextFormat {
475                        font_id: theme.font_id.clone(),
476                        color: text_color,
477                        italics,
478                        underline,
479                        ..Default::default()
480                    },
481                });
482            }
483        }
484
485        Some(job)
486    }
487}
488
489#[cfg(feature = "syntect")]
490fn as_byte_range(whole: &str, range: &str) -> std::ops::Range<usize> {
491    let whole_start = whole.as_ptr() as usize;
492    let range_start = range.as_ptr() as usize;
493    assert!(whole_start <= range_start);
494    assert!(range_start + range.len() <= whole_start + whole.len());
495    let offset = range_start - whole_start;
496    offset..(offset + range.len())
497}
498
499// ----------------------------------------------------------------------------
500
501#[cfg(not(feature = "syntect"))]
502#[derive(Default)]
503struct Highlighter {}
504
505#[cfg(not(feature = "syntect"))]
506impl Highlighter {
507    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
508    fn highlight_impl(
509        &self,
510        theme: &CodeTheme,
511        mut text: &str,
512        language: &str,
513    ) -> Option<LayoutJob> {
514        profiling::function_scope!();
515
516        let language = Language::new(language)?;
517
518        // Extremely simple syntax highlighter for when we compile without syntect
519
520        let mut job = LayoutJob::default();
521
522        while !text.is_empty() {
523            if language.double_slash_comments && text.starts_with("//")
524                || language.hash_comments && text.starts_with('#')
525            {
526                let end = text.find('\n').unwrap_or(text.len());
527                job.append(&text[..end], 0.0, theme.formats[TokenType::Comment].clone());
528                text = &text[end..];
529            } else if text.starts_with('"') {
530                let end = text[1..]
531                    .find('"')
532                    .map(|i| i + 2)
533                    .or_else(|| text.find('\n'))
534                    .unwrap_or(text.len());
535                job.append(
536                    &text[..end],
537                    0.0,
538                    theme.formats[TokenType::StringLiteral].clone(),
539                );
540                text = &text[end..];
541            } else if text.starts_with(|c: char| c.is_ascii_alphanumeric()) {
542                let end = text[1..]
543                    .find(|c: char| !c.is_ascii_alphanumeric())
544                    .map_or_else(|| text.len(), |i| i + 1);
545                let word = &text[..end];
546                let tt = if language.is_keyword(word) {
547                    TokenType::Keyword
548                } else {
549                    TokenType::Literal
550                };
551                job.append(word, 0.0, theme.formats[tt].clone());
552                text = &text[end..];
553            } else if text.starts_with(|c: char| c.is_ascii_whitespace()) {
554                let end = text[1..]
555                    .find(|c: char| !c.is_ascii_whitespace())
556                    .map_or_else(|| text.len(), |i| i + 1);
557                job.append(
558                    &text[..end],
559                    0.0,
560                    theme.formats[TokenType::Whitespace].clone(),
561                );
562                text = &text[end..];
563            } else {
564                let mut it = text.char_indices();
565                it.next();
566                let end = it.next().map_or(text.len(), |(idx, _chr)| idx);
567                job.append(
568                    &text[..end],
569                    0.0,
570                    theme.formats[TokenType::Punctuation].clone(),
571                );
572                text = &text[end..];
573            }
574        }
575
576        Some(job)
577    }
578}
579
580#[cfg(not(feature = "syntect"))]
581struct Language {
582    /// `// comment`
583    double_slash_comments: bool,
584
585    /// `# comment`
586    hash_comments: bool,
587
588    keywords: std::collections::BTreeSet<&'static str>,
589}
590
591#[cfg(not(feature = "syntect"))]
592impl Language {
593    fn new(language: &str) -> Option<Self> {
594        match language.to_lowercase().as_str() {
595            "c" | "h" | "hpp" | "cpp" | "c++" => Some(Self::cpp()),
596            "py" | "python" => Some(Self::python()),
597            "rs" | "rust" => Some(Self::rust()),
598            "toml" => Some(Self::toml()),
599            _ => {
600                None // unsupported language
601            }
602        }
603    }
604
605    fn is_keyword(&self, word: &str) -> bool {
606        self.keywords.contains(word)
607    }
608
609    fn cpp() -> Self {
610        Self {
611            double_slash_comments: true,
612            hash_comments: false,
613            keywords: [
614                "alignas",
615                "alignof",
616                "and_eq",
617                "and",
618                "asm",
619                "atomic_cancel",
620                "atomic_commit",
621                "atomic_noexcept",
622                "auto",
623                "bitand",
624                "bitor",
625                "bool",
626                "break",
627                "case",
628                "catch",
629                "char",
630                "char16_t",
631                "char32_t",
632                "char8_t",
633                "class",
634                "co_await",
635                "co_return",
636                "co_yield",
637                "compl",
638                "concept",
639                "const_cast",
640                "const",
641                "consteval",
642                "constexpr",
643                "constinit",
644                "continue",
645                "decltype",
646                "default",
647                "delete",
648                "do",
649                "double",
650                "dynamic_cast",
651                "else",
652                "enum",
653                "explicit",
654                "export",
655                "extern",
656                "false",
657                "float",
658                "for",
659                "friend",
660                "goto",
661                "if",
662                "inline",
663                "int",
664                "long",
665                "mutable",
666                "namespace",
667                "new",
668                "noexcept",
669                "not_eq",
670                "not",
671                "nullptr",
672                "operator",
673                "or_eq",
674                "or",
675                "private",
676                "protected",
677                "public",
678                "reflexpr",
679                "register",
680                "reinterpret_cast",
681                "requires",
682                "return",
683                "short",
684                "signed",
685                "sizeof",
686                "static_assert",
687                "static_cast",
688                "static",
689                "struct",
690                "switch",
691                "synchronized",
692                "template",
693                "this",
694                "thread_local",
695                "throw",
696                "true",
697                "try",
698                "typedef",
699                "typeid",
700                "typename",
701                "union",
702                "unsigned",
703                "using",
704                "virtual",
705                "void",
706                "volatile",
707                "wchar_t",
708                "while",
709                "xor_eq",
710                "xor",
711            ]
712            .into_iter()
713            .collect(),
714        }
715    }
716
717    fn python() -> Self {
718        Self {
719            double_slash_comments: false,
720            hash_comments: true,
721            keywords: [
722                "and", "as", "assert", "break", "class", "continue", "def", "del", "elif", "else",
723                "except", "False", "finally", "for", "from", "global", "if", "import", "in", "is",
724                "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "return", "True",
725                "try", "while", "with", "yield",
726            ]
727            .into_iter()
728            .collect(),
729        }
730    }
731
732    fn rust() -> Self {
733        Self {
734            double_slash_comments: true,
735            hash_comments: false,
736            keywords: [
737                "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else",
738                "enum", "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match",
739                "mod", "move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct",
740                "super", "trait", "true", "type", "unsafe", "use", "where", "while",
741            ]
742            .into_iter()
743            .collect(),
744        }
745    }
746
747    fn toml() -> Self {
748        Self {
749            double_slash_comments: false,
750            hash_comments: true,
751            keywords: Default::default(),
752        }
753    }
754}