Skip to main content

gorbie_commonmark_backend/
misc.rs

1use crate::alerts::AlertBundle;
2use egui::{FontFamily, RichText, TextBuffer, TextStyle, Ui, text::LayoutJob};
3use std::collections::HashMap;
4
5use crate::pulldown::ScrollableCache;
6
7#[cfg(feature = "better_syntax_highlighting")]
8use syntect::{
9    easy::HighlightLines,
10    highlighting::{Theme, ThemeSet},
11    parsing::{SyntaxDefinition, SyntaxSet},
12    util::LinesWithEndings,
13};
14
15#[cfg(feature = "better_syntax_highlighting")]
16const DEFAULT_THEME_LIGHT: &str = "base16-ocean.light";
17#[cfg(feature = "better_syntax_highlighting")]
18const DEFAULT_THEME_DARK: &str = "base16-ocean.dark";
19
20pub struct CommonMarkOptions<'f> {
21    pub indentation_spaces: usize,
22    pub max_image_width: Option<usize>,
23    pub show_alt_text_on_hover: bool,
24    pub default_width: Option<usize>,
25    #[cfg(feature = "better_syntax_highlighting")]
26    pub theme_light: String,
27    #[cfg(feature = "better_syntax_highlighting")]
28    pub theme_dark: String,
29    pub use_explicit_uri_scheme: bool,
30    pub default_implicit_uri_scheme: String,
31    pub alerts: AlertBundle,
32    /// Whether to present a mutable ui for things like checkboxes
33    pub mutable: bool,
34    pub math_fn: Option<&'f crate::RenderMathFn>,
35    pub html_fn: Option<&'f crate::RenderHtmlFn>,
36}
37
38impl std::fmt::Debug for CommonMarkOptions<'_> {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        let mut s = f.debug_struct("CommonMarkOptions");
41
42        s.field("indentation_spaces", &self.indentation_spaces)
43            .field("max_image_width", &self.max_image_width)
44            .field("show_alt_text_on_hover", &self.show_alt_text_on_hover)
45            .field("default_width", &self.default_width);
46
47        #[cfg(feature = "better_syntax_highlighting")]
48        s.field("theme_light", &self.theme_light)
49            .field("theme_dark", &self.theme_dark);
50
51        s.field("use_explicit_uri_scheme", &self.use_explicit_uri_scheme)
52            .field(
53                "default_implicit_uri_scheme",
54                &self.default_implicit_uri_scheme,
55            )
56            .field("alerts", &self.alerts)
57            .field("mutable", &self.mutable)
58            .finish()
59    }
60}
61
62impl Default for CommonMarkOptions<'_> {
63    fn default() -> Self {
64        Self {
65            indentation_spaces: 4,
66            max_image_width: None,
67            show_alt_text_on_hover: true,
68            default_width: None,
69            #[cfg(feature = "better_syntax_highlighting")]
70            theme_light: DEFAULT_THEME_LIGHT.to_owned(),
71            #[cfg(feature = "better_syntax_highlighting")]
72            theme_dark: DEFAULT_THEME_DARK.to_owned(),
73            use_explicit_uri_scheme: false,
74            default_implicit_uri_scheme: "file://".to_owned(),
75            alerts: AlertBundle::gfm(),
76            mutable: false,
77            math_fn: None,
78            html_fn: None,
79        }
80    }
81}
82
83impl CommonMarkOptions<'_> {
84    #[cfg(feature = "better_syntax_highlighting")]
85    pub fn curr_theme(&self, ui: &Ui) -> &str {
86        if ui.style().visuals.dark_mode {
87            &self.theme_dark
88        } else {
89            &self.theme_light
90        }
91    }
92
93    pub fn max_width(&self, ui: &Ui) -> f32 {
94        let max_image_width = self.max_image_width.unwrap_or(0) as f32;
95        let available_width = ui.available_width();
96
97        let max_width = max_image_width.max(available_width);
98        if let Some(default_width) = self.default_width {
99            if default_width as f32 > max_width {
100                default_width as f32
101            } else {
102                max_width
103            }
104        } else {
105            max_width
106        }
107    }
108}
109
110#[derive(Default, Clone)]
111pub struct Style {
112    pub heading: Option<u8>,
113    pub strong: bool,
114    pub emphasis: bool,
115    pub strikethrough: bool,
116    pub quote: bool,
117    pub code: bool,
118}
119
120impl Style {
121    pub fn to_richtext(&self, ui: &Ui, text: &str) -> RichText {
122        let mut text = RichText::new(text);
123
124        if let Some(level) = self.heading {
125            // Grid-aligned heading sizes for IosevkaGorbie
126            // (hhea ascent=965, descent=-215, line_gap=70, upm=1000):
127            //
128            //   29px font → 36px row (3 modules)
129            //   20px font → 24px row (2 modules)
130            //   15px body → 18px row (1.5 modules)
131            //
132            // H1–H2: 3-module tier. H3–H4: 2-module tier.
133            // H5–H6: body size, differentiated by weight.
134            let body_size = ui
135                .style()
136                .text_styles
137                .get(&TextStyle::Body)
138                .map_or(15.0, |d| d.size);
139
140            match level {
141                // H1: large heading, bold
142                0 => {
143                    text = text.strong().size(29.0);
144                }
145                // H2: large heading, bold (same size as H1, distinguished contextually)
146                1 => {
147                    text = text.strong().size(29.0);
148                }
149                // H3: section heading, bold
150                2 => {
151                    text = text.strong().size(20.0);
152                }
153                // H4: section heading, regular weight
154                3 => {
155                    text = text.size(20.0);
156                }
157                // H5: body-size heading, bold
158                4 => {
159                    text = text.strong().size(body_size);
160                }
161                // H6: body-size heading, regular
162                5.. => {
163                    text = text.size(body_size);
164                }
165            }
166        }
167
168        if self.quote {
169            text = text.weak();
170        }
171
172        if self.strong {
173            text = text.strong();
174            text = text.family(FontFamily::Name("IosevkaGorbieBold".into()));
175        }
176
177        if self.emphasis {
178            // FIXME: Might want to add some space between the next text
179            text = text.italics();
180        }
181
182        if self.strikethrough {
183            text = text.strikethrough();
184        }
185
186        if self.code {
187            text = text.code();
188        }
189
190        text
191    }
192}
193
194#[derive(Default)]
195pub struct Link {
196    pub destination: String,
197    pub text: Vec<RichText>,
198}
199
200impl Link {
201    pub fn end(self, ui: &mut Ui, cache: &mut CommonMarkCache) {
202        let Self { destination, text } = self;
203
204        let mut layout_job = LayoutJob::default();
205        for t in text {
206            t.append_to(
207                &mut layout_job,
208                ui.style(),
209                egui::FontSelection::Default,
210                egui::Align::LEFT,
211            );
212        }
213        if cache.link_hooks().contains_key(&destination) {
214            let ui_link = ui.link(layout_job);
215            if ui_link.clicked() || ui_link.middle_clicked() {
216                cache.link_hooks_mut().insert(destination, true);
217            }
218        } else {
219            ui.hyperlink_to(layout_job, destination);
220        }
221    }
222}
223
224pub struct Image {
225    pub uri: String,
226    pub alt_text: Vec<RichText>,
227}
228
229impl Image {
230    // FIXME: string conversion
231    pub fn new(uri: &str, options: &CommonMarkOptions) -> Self {
232        let has_scheme = uri.contains("://") || uri.starts_with("data:");
233        let uri = if options.use_explicit_uri_scheme || has_scheme {
234            uri.to_string()
235        } else {
236            // Assume file scheme
237            format!("{}{uri}", options.default_implicit_uri_scheme)
238        };
239
240        Self {
241            uri,
242            alt_text: Vec::new(),
243        }
244    }
245
246    pub fn end(self, ui: &mut Ui, options: &CommonMarkOptions) {
247        let corner_radius = egui::CornerRadius::same(16);
248        let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
249
250        let image = egui::Image::from_uri(&self.uri)
251            .fit_to_original_size(1.0)
252            .max_width(options.max_width(ui))
253            .corner_radius(corner_radius)
254            .show_loading_spinner(false);
255        let tlr = image.load_for_size(ui.ctx(), ui.available_size());
256        let image_source_size = tlr.as_ref().ok().and_then(|t| t.size());
257        let placeholder_size = image_placeholder_size(ui, options);
258        let ui_size = match &tlr {
259            Ok(egui::load::TexturePoll::Pending { .. }) => placeholder_size,
260            Ok(_) => image.calc_size(ui.available_size(), image_source_size),
261            Err(_) => placeholder_size,
262        };
263
264        let (rect, response) = ui.allocate_exact_size(ui_size, egui::Sense::hover());
265        if ui.is_rect_visible(rect) {
266            match &tlr {
267                Ok(egui::load::TexturePoll::Ready { texture }) => {
268                    egui::paint_texture_at(ui.painter(), rect, image.image_options(), texture);
269                }
270                Ok(egui::load::TexturePoll::Pending { .. }) => {
271                    paint_image_placeholder(ui, rect, corner_radius, stroke);
272                }
273                Err(_) => {
274                    image.paint_at(ui, rect);
275                }
276            }
277
278            ui.painter().rect_stroke(
279                rect,
280                corner_radius,
281                stroke,
282                egui::StrokeKind::Inside,
283            );
284        }
285
286        if !self.alt_text.is_empty() && options.show_alt_text_on_hover {
287            response.on_hover_ui_at_pointer(|ui| {
288                for alt in self.alt_text {
289                    ui.label(alt);
290                }
291            });
292        }
293    }
294}
295
296fn image_placeholder_size(ui: &Ui, options: &CommonMarkOptions) -> egui::Vec2 {
297    let mut width = ui.max_rect().width();
298    if let Some(max_width) = options.max_image_width {
299        width = width.min(max_width as f32);
300    }
301    if let Some(default_width) = options.default_width {
302        width = width.max(default_width as f32);
303    }
304    if !width.is_finite() || width <= 0.0 {
305        width = 256.0;
306    }
307    let height = (width * 0.5).max(1.0);
308    egui::vec2(width, height)
309}
310
311fn paint_image_placeholder(
312    ui: &Ui,
313    rect: egui::Rect,
314    corner_radius: egui::CornerRadius,
315    stroke: egui::Stroke,
316) {
317    let fill = ui.visuals().widgets.noninteractive.bg_fill;
318    ui.painter().rect_filled(rect, corner_radius, fill);
319
320    let hatch_color = egui::Color32::from_rgba_unmultiplied(
321        stroke.color.r(),
322        stroke.color.g(),
323        stroke.color.b(),
324        80,
325    );
326    let hatch_stroke = egui::Stroke::new(1.0, hatch_color);
327    let inset = (corner_radius.average() * 0.5).max(2.0);
328    let hatch_rect = rect.shrink(inset);
329    if hatch_rect.width() <= 0.0 || hatch_rect.height() <= 0.0 {
330        return;
331    }
332
333    let painter = ui.painter().with_clip_rect(hatch_rect);
334    let spacing = 12.0;
335    let mut x = hatch_rect.left() - hatch_rect.height();
336    while x < hatch_rect.right() {
337        let start = egui::pos2(x, hatch_rect.top());
338        let end = egui::pos2(x + hatch_rect.height(), hatch_rect.bottom());
339        painter.line_segment([start, end], hatch_stroke);
340        x += spacing;
341    }
342}
343
344pub struct CodeBlock {
345    pub lang: Option<String>,
346    pub content: String,
347}
348
349impl CodeBlock {
350    pub fn end(
351        &self,
352        ui: &mut Ui,
353        cache: &mut CommonMarkCache,
354        options: &CommonMarkOptions,
355        max_width: f32,
356    ) {
357        ui.scope(|ui| {
358            Self::pre_syntax_highlighting(cache, options, ui);
359
360            let mut layout = |ui: &Ui, string: &dyn TextBuffer, wrap_width: f32| {
361                let mut job = if let Some(lang) = &self.lang {
362                    self.syntax_highlighting(cache, options, lang, ui, string.as_str())
363                } else {
364                    plain_highlighting(ui, string.as_str())
365                };
366
367                job.wrap.max_width = wrap_width;
368                ui.fonts_mut(|f| f.layout_job(job))
369            };
370
371            crate::elements::code_block(ui, max_width, &self.content, &mut layout);
372        });
373    }
374}
375
376#[cfg(not(feature = "better_syntax_highlighting"))]
377impl CodeBlock {
378    fn pre_syntax_highlighting(
379        _cache: &mut CommonMarkCache,
380        _options: &CommonMarkOptions,
381        ui: &mut Ui,
382    ) {
383        ui.style_mut().visuals.extreme_bg_color = ui.visuals().extreme_bg_color;
384    }
385
386    fn syntax_highlighting(
387        &self,
388        _cache: &mut CommonMarkCache,
389        _options: &CommonMarkOptions,
390        extension: &str,
391        ui: &Ui,
392        text: &str,
393    ) -> egui::text::LayoutJob {
394        simple_highlighting(ui, text, extension)
395    }
396}
397
398#[cfg(feature = "better_syntax_highlighting")]
399impl CodeBlock {
400    fn pre_syntax_highlighting(
401        cache: &mut CommonMarkCache,
402        options: &CommonMarkOptions,
403        ui: &mut Ui,
404    ) {
405        let curr_theme = cache.curr_theme(ui, options);
406        let style = ui.style_mut();
407
408        style.visuals.extreme_bg_color = curr_theme
409            .settings
410            .background
411            .map(syntect_color_to_egui)
412            .unwrap_or(style.visuals.extreme_bg_color);
413
414        if let Some(color) = curr_theme.settings.selection_foreground {
415            style.visuals.selection.bg_fill = syntect_color_to_egui(color);
416        }
417    }
418
419    fn syntax_highlighting(
420        &self,
421        cache: &CommonMarkCache,
422        options: &CommonMarkOptions,
423        extension: &str,
424        ui: &Ui,
425        text: &str,
426    ) -> egui::text::LayoutJob {
427        if let Some(syntax) = cache.ps.find_syntax_by_extension(extension) {
428            let mut job = egui::text::LayoutJob::default();
429            let mut h = HighlightLines::new(syntax, cache.curr_theme(ui, options));
430
431            for line in LinesWithEndings::from(text) {
432                let ranges = h.highlight_line(line, &cache.ps).unwrap();
433                for v in ranges {
434                    let front = v.0.foreground;
435                    job.append(
436                        v.1,
437                        0.0,
438                        egui::TextFormat::simple(
439                            TextStyle::Monospace.resolve(ui.style()),
440                            syntect_color_to_egui(front),
441                        ),
442                    );
443                }
444            }
445
446            job
447        } else {
448            simple_highlighting(ui, text, extension)
449        }
450    }
451}
452
453fn simple_highlighting(ui: &Ui, text: &str, extension: &str) -> egui::text::LayoutJob {
454    egui_extras::syntax_highlighting::highlight(
455        ui.ctx(),
456        ui.style(),
457        &egui_extras::syntax_highlighting::CodeTheme::from_style(ui.style()),
458        text,
459        extension,
460    )
461}
462
463fn plain_highlighting(ui: &Ui, text: &str) -> egui::text::LayoutJob {
464    let mut job = egui::text::LayoutJob::default();
465    job.append(
466        text,
467        0.0,
468        egui::TextFormat::simple(
469            TextStyle::Monospace.resolve(ui.style()),
470            ui.style().visuals.text_color(),
471        ),
472    );
473    job
474}
475
476#[cfg(feature = "better_syntax_highlighting")]
477fn syntect_color_to_egui(color: syntect::highlighting::Color) -> egui::Color32 {
478    egui::Color32::from_rgb(color.r, color.g, color.b)
479}
480
481#[cfg(feature = "better_syntax_highlighting")]
482fn default_theme(ui: &Ui) -> &str {
483    if ui.style().visuals.dark_mode {
484        DEFAULT_THEME_DARK
485    } else {
486        DEFAULT_THEME_LIGHT
487    }
488}
489
490/// A cache used for storing content such as images.
491#[derive(Debug)]
492pub struct CommonMarkCache {
493    // Everything stored in `CommonMarkCache` must take into account that
494    // the cache is for multiple `CommonMarkviewer`s with different source_ids.
495    #[cfg(feature = "better_syntax_highlighting")]
496    ps: SyntaxSet,
497
498    #[cfg(feature = "better_syntax_highlighting")]
499    ts: ThemeSet,
500
501    link_hooks: HashMap<String, bool>,
502
503    scroll: HashMap<egui::Id, ScrollableCache>,
504    pub(self) has_installed_loaders: bool,
505}
506
507#[allow(clippy::derivable_impls)]
508impl Default for CommonMarkCache {
509    fn default() -> Self {
510        Self {
511            #[cfg(feature = "better_syntax_highlighting")]
512            ps: SyntaxSet::load_defaults_newlines(),
513            #[cfg(feature = "better_syntax_highlighting")]
514            ts: ThemeSet::load_defaults(),
515            link_hooks: HashMap::new(),
516            scroll: Default::default(),
517            has_installed_loaders: false,
518        }
519    }
520}
521
522impl CommonMarkCache {
523    #[cfg(feature = "better_syntax_highlighting")]
524    pub fn add_syntax_from_folder(&mut self, path: &str) {
525        let mut builder = self.ps.clone().into_builder();
526        let _ = builder.add_from_folder(path, true);
527        self.ps = builder.build();
528    }
529
530    #[cfg(feature = "better_syntax_highlighting")]
531    pub fn add_syntax_from_str(&mut self, s: &str, fallback_name: Option<&str>) {
532        let mut builder = self.ps.clone().into_builder();
533        let _ = SyntaxDefinition::load_from_str(s, true, fallback_name).map(|d| builder.add(d));
534        self.ps = builder.build();
535    }
536
537    #[cfg(feature = "better_syntax_highlighting")]
538    /// Add more color themes for code blocks(.tmTheme files). Set the color theme with
539    /// [`syntax_theme_dark`](CommonMarkViewer::syntax_theme_dark) and
540    /// [`syntax_theme_light`](CommonMarkViewer::syntax_theme_light)
541    pub fn add_syntax_themes_from_folder(
542        &mut self,
543        path: impl AsRef<std::path::Path>,
544    ) -> Result<(), syntect::LoadingError> {
545        self.ts.add_from_folder(path)
546    }
547
548    #[cfg(feature = "better_syntax_highlighting")]
549    /// Add color theme for code blocks(.tmTheme files). Set the color theme with
550    /// [`syntax_theme_dark`](CommonMarkViewer::syntax_theme_dark) and
551    /// [`syntax_theme_light`](CommonMarkViewer::syntax_theme_light)
552    pub fn add_syntax_theme_from_bytes(
553        &mut self,
554        name: impl Into<String>,
555        bytes: &[u8],
556    ) -> Result<(), syntect::LoadingError> {
557        let mut cursor = std::io::Cursor::new(bytes);
558        self.ts
559            .themes
560            .insert(name.into(), ThemeSet::load_from_reader(&mut cursor)?);
561        Ok(())
562    }
563
564    /// Clear the cache for all scrollable elements
565    pub fn clear_scrollable(&mut self) {
566        self.scroll.clear();
567    }
568
569    /// Clear the cache for a specific scrollable viewer. Returns false if the
570    /// id was not in the cache.
571    pub fn clear_scrollable_with_id(&mut self, source_id: impl std::hash::Hash) -> bool {
572        self.scroll.remove(&egui::Id::new(source_id)).is_some()
573    }
574
575    /// If the user clicks on a link in the markdown render that has `name` as a link. The hook
576    /// specified with this method will be set to true. It's status can be acquired
577    /// with [`get_link_hook`](Self::get_link_hook). Be aware that all hook state is reset once
578    /// [`CommonMarkViewer::show`] gets called
579    ///
580    /// # Why use link hooks
581    ///
582    /// egui provides a method for checking links afterwards so why use this instead?
583    ///
584    /// ```rust
585    /// # use egui::__run_test_ctx;
586    /// # __run_test_ctx(|ctx| {
587    /// ctx.output_mut(|o| for command in &o.commands {
588    ///     matches!(command, egui::OutputCommand::OpenUrl(_));
589    /// });
590    /// # });
591    /// ```
592    ///
593    /// The main difference is that link hooks allows gorbie_commonmark to check for link hooks
594    /// while rendering. Normally when hovering over a link, gorbie_commonmark will display the full
595    /// url. With link hooks this feature is disabled, but to do that all hooks must be known.
596    // Works when displayed through gorbie_commonmark
597    #[allow(rustdoc::broken_intra_doc_links)]
598    pub fn add_link_hook<S: Into<String>>(&mut self, name: S) {
599        self.link_hooks.insert(name.into(), false);
600    }
601
602    /// Returns None if the link hook could not be found. Returns the last known status of the
603    /// hook otherwise.
604    pub fn remove_link_hook(&mut self, name: &str) -> Option<bool> {
605        self.link_hooks.remove(name)
606    }
607
608    /// Get status of link. Returns true if it was clicked
609    pub fn get_link_hook(&self, name: &str) -> Option<bool> {
610        self.link_hooks.get(name).copied()
611    }
612
613    /// Remove all link hooks
614    pub fn link_hooks_clear(&mut self) {
615        self.link_hooks.clear();
616    }
617
618    /// All link hooks
619    pub fn link_hooks(&self) -> &HashMap<String, bool> {
620        &self.link_hooks
621    }
622
623    /// Raw access to link hooks
624    pub fn link_hooks_mut(&mut self) -> &mut HashMap<String, bool> {
625        &mut self.link_hooks
626    }
627
628    /// Set all link hooks to false
629    fn deactivate_link_hooks(&mut self) {
630        for v in self.link_hooks.values_mut() {
631            *v = false;
632        }
633    }
634
635    #[cfg(feature = "better_syntax_highlighting")]
636    fn curr_theme(&self, ui: &Ui, options: &CommonMarkOptions) -> &Theme {
637        self.ts
638            .themes
639            .get(options.curr_theme(ui))
640            // Since we have called load_defaults, the default theme *should* always be available..
641            .unwrap_or_else(|| &self.ts.themes[default_theme(ui)])
642    }
643}
644
645pub fn scroll_cache<'a>(cache: &'a mut CommonMarkCache, id: &egui::Id) -> &'a mut ScrollableCache {
646    if !cache.scroll.contains_key(id) {
647        cache.scroll.insert(*id, Default::default());
648    }
649    cache.scroll.get_mut(id).unwrap()
650}
651
652/// Should be called before any rendering
653pub fn prepare_show(cache: &mut CommonMarkCache, ctx: &egui::Context) {
654    if !cache.has_installed_loaders {
655        // Even though the install function can be called multiple times, its not the cheapest
656        // so we ensure that we only call it once.
657        // This could be done at the creation of the cache, however it is better to keep the
658        // cache free from egui's Ui and Context types as this allows it to be created before
659        // any egui instances. It also keeps the API similar to before the introduction of the
660        // image loaders.
661        #[cfg(feature = "embedded_image")]
662        crate::data_url_loader::install_loader(ctx);
663
664        egui_extras::install_image_loaders(ctx);
665        cache.has_installed_loaders = true;
666    }
667
668    cache.deactivate_link_hooks();
669}