1#![allow(clippy::mem_forget)] use egui::text::LayoutJob;
9use egui::TextStyle;
10
11pub 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
22pub fn highlight(
26 ctx: &egui::Context,
27 style: &egui::Style,
28 theme: &CodeTheme,
29 code: &str,
30 language: &str,
31) -> LayoutJob {
32 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#[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#[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 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 pub fn dark(font_size: f32) -> Self {
191 Self::dark_with_font_id(egui::FontId::monospace(font_size))
192 }
193
194 pub fn light(font_size: f32) -> Self {
203 Self::light_with_font_id(egui::FontId::monospace(font_size))
204 }
205
206 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 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 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 #[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 #[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 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 ] {
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.style_mut().override_text_style = Some(egui::TextStyle::Small);
384 ui.spacing_mut().slider_width = 128.0; 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#[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 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#[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 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 double_slash_comments: bool,
584
585 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 }
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}