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            let max_height = ui
126                .style()
127                .text_styles
128                .get(&TextStyle::Heading)
129                .map_or(32.0, |d| d.size);
130            let min_height = ui
131                .style()
132                .text_styles
133                .get(&TextStyle::Body)
134                .map_or(14.0, |d| d.size);
135            let diff = max_height - min_height;
136
137            match level {
138                0 => {
139                    text = text.strong().heading();
140                }
141                1 => {
142                    let size = min_height + diff * 0.835;
143                    text = text.strong().size(size);
144                }
145                2 => {
146                    let size = min_height + diff * 0.668;
147                    text = text.strong().size(size);
148                }
149                3 => {
150                    let size = min_height + diff * 0.501;
151                    text = text.strong().size(size);
152                }
153                4 => {
154                    let size = min_height + diff * 0.334;
155                    text = text.size(size);
156                }
157                // We only support 6 levels
158                5.. => {
159                    let size = min_height + diff * 0.167;
160                    text = text.size(size);
161                }
162            }
163        }
164
165        if self.quote {
166            text = text.weak();
167        }
168
169        if self.strong {
170            text = text.strong();
171            text = text.family(FontFamily::Name("IosevkaGorbieBold".into()));
172        }
173
174        if self.emphasis {
175            // FIXME: Might want to add some space between the next text
176            text = text.italics();
177        }
178
179        if self.strikethrough {
180            text = text.strikethrough();
181        }
182
183        if self.code {
184            text = text.code();
185        }
186
187        text
188    }
189}
190
191#[derive(Default)]
192pub struct Link {
193    pub destination: String,
194    pub text: Vec<RichText>,
195}
196
197impl Link {
198    pub fn end(self, ui: &mut Ui, cache: &mut CommonMarkCache) {
199        let Self { destination, text } = self;
200
201        let mut layout_job = LayoutJob::default();
202        for t in text {
203            t.append_to(
204                &mut layout_job,
205                ui.style(),
206                egui::FontSelection::Default,
207                egui::Align::LEFT,
208            );
209        }
210        if cache.link_hooks().contains_key(&destination) {
211            let ui_link = ui.link(layout_job);
212            if ui_link.clicked() || ui_link.middle_clicked() {
213                cache.link_hooks_mut().insert(destination, true);
214            }
215        } else {
216            ui.hyperlink_to(layout_job, destination);
217        }
218    }
219}
220
221pub struct Image {
222    pub uri: String,
223    pub alt_text: Vec<RichText>,
224}
225
226impl Image {
227    // FIXME: string conversion
228    pub fn new(uri: &str, options: &CommonMarkOptions) -> Self {
229        let has_scheme = uri.contains("://") || uri.starts_with("data:");
230        let uri = if options.use_explicit_uri_scheme || has_scheme {
231            uri.to_string()
232        } else {
233            // Assume file scheme
234            format!("{}{uri}", options.default_implicit_uri_scheme)
235        };
236
237        Self {
238            uri,
239            alt_text: Vec::new(),
240        }
241    }
242
243    pub fn end(self, ui: &mut Ui, options: &CommonMarkOptions) {
244        let corner_radius = egui::CornerRadius::same(16);
245        let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
246
247        let response = ui.add(
248            egui::Image::from_uri(&self.uri)
249                .fit_to_original_size(1.0)
250                .max_width(options.max_width(ui))
251                .corner_radius(corner_radius),
252        );
253
254        ui.painter().rect_stroke(
255            response.rect,
256            corner_radius,
257            stroke,
258            egui::StrokeKind::Inside,
259        );
260
261        if !self.alt_text.is_empty() && options.show_alt_text_on_hover {
262            response.on_hover_ui_at_pointer(|ui| {
263                for alt in self.alt_text {
264                    ui.label(alt);
265                }
266            });
267        }
268    }
269}
270
271pub struct CodeBlock {
272    pub lang: Option<String>,
273    pub content: String,
274}
275
276impl CodeBlock {
277    pub fn end(
278        &self,
279        ui: &mut Ui,
280        cache: &mut CommonMarkCache,
281        options: &CommonMarkOptions,
282        max_width: f32,
283    ) {
284        ui.scope(|ui| {
285            Self::pre_syntax_highlighting(cache, options, ui);
286
287            let mut layout = |ui: &Ui, string: &dyn TextBuffer, wrap_width: f32| {
288                let mut job = if let Some(lang) = &self.lang {
289                    self.syntax_highlighting(cache, options, lang, ui, string.as_str())
290                } else {
291                    plain_highlighting(ui, string.as_str())
292                };
293
294                job.wrap.max_width = wrap_width;
295                ui.fonts_mut(|f| f.layout_job(job))
296            };
297
298            crate::elements::code_block(ui, max_width, &self.content, &mut layout);
299        });
300    }
301}
302
303#[cfg(not(feature = "better_syntax_highlighting"))]
304impl CodeBlock {
305    fn pre_syntax_highlighting(
306        _cache: &mut CommonMarkCache,
307        _options: &CommonMarkOptions,
308        ui: &mut Ui,
309    ) {
310        ui.style_mut().visuals.extreme_bg_color = ui.visuals().extreme_bg_color;
311    }
312
313    fn syntax_highlighting(
314        &self,
315        _cache: &mut CommonMarkCache,
316        _options: &CommonMarkOptions,
317        extension: &str,
318        ui: &Ui,
319        text: &str,
320    ) -> egui::text::LayoutJob {
321        simple_highlighting(ui, text, extension)
322    }
323}
324
325#[cfg(feature = "better_syntax_highlighting")]
326impl CodeBlock {
327    fn pre_syntax_highlighting(
328        cache: &mut CommonMarkCache,
329        options: &CommonMarkOptions,
330        ui: &mut Ui,
331    ) {
332        let curr_theme = cache.curr_theme(ui, options);
333        let style = ui.style_mut();
334
335        style.visuals.extreme_bg_color = curr_theme
336            .settings
337            .background
338            .map(syntect_color_to_egui)
339            .unwrap_or(style.visuals.extreme_bg_color);
340
341        if let Some(color) = curr_theme.settings.selection_foreground {
342            style.visuals.selection.bg_fill = syntect_color_to_egui(color);
343        }
344    }
345
346    fn syntax_highlighting(
347        &self,
348        cache: &CommonMarkCache,
349        options: &CommonMarkOptions,
350        extension: &str,
351        ui: &Ui,
352        text: &str,
353    ) -> egui::text::LayoutJob {
354        if let Some(syntax) = cache.ps.find_syntax_by_extension(extension) {
355            let mut job = egui::text::LayoutJob::default();
356            let mut h = HighlightLines::new(syntax, cache.curr_theme(ui, options));
357
358            for line in LinesWithEndings::from(text) {
359                let ranges = h.highlight_line(line, &cache.ps).unwrap();
360                for v in ranges {
361                    let front = v.0.foreground;
362                    job.append(
363                        v.1,
364                        0.0,
365                        egui::TextFormat::simple(
366                            TextStyle::Monospace.resolve(ui.style()),
367                            syntect_color_to_egui(front),
368                        ),
369                    );
370                }
371            }
372
373            job
374        } else {
375            simple_highlighting(ui, text, extension)
376        }
377    }
378}
379
380fn simple_highlighting(ui: &Ui, text: &str, extension: &str) -> egui::text::LayoutJob {
381    egui_extras::syntax_highlighting::highlight(
382        ui.ctx(),
383        ui.style(),
384        &egui_extras::syntax_highlighting::CodeTheme::from_style(ui.style()),
385        text,
386        extension,
387    )
388}
389
390fn plain_highlighting(ui: &Ui, text: &str) -> egui::text::LayoutJob {
391    let mut job = egui::text::LayoutJob::default();
392    job.append(
393        text,
394        0.0,
395        egui::TextFormat::simple(
396            TextStyle::Monospace.resolve(ui.style()),
397            ui.style().visuals.text_color(),
398        ),
399    );
400    job
401}
402
403#[cfg(feature = "better_syntax_highlighting")]
404fn syntect_color_to_egui(color: syntect::highlighting::Color) -> egui::Color32 {
405    egui::Color32::from_rgb(color.r, color.g, color.b)
406}
407
408#[cfg(feature = "better_syntax_highlighting")]
409fn default_theme(ui: &Ui) -> &str {
410    if ui.style().visuals.dark_mode {
411        DEFAULT_THEME_DARK
412    } else {
413        DEFAULT_THEME_LIGHT
414    }
415}
416
417/// A cache used for storing content such as images.
418#[derive(Debug)]
419pub struct CommonMarkCache {
420    // Everything stored in `CommonMarkCache` must take into account that
421    // the cache is for multiple `CommonMarkviewer`s with different source_ids.
422    #[cfg(feature = "better_syntax_highlighting")]
423    ps: SyntaxSet,
424
425    #[cfg(feature = "better_syntax_highlighting")]
426    ts: ThemeSet,
427
428    link_hooks: HashMap<String, bool>,
429
430    scroll: HashMap<egui::Id, ScrollableCache>,
431    pub(self) has_installed_loaders: bool,
432}
433
434#[allow(clippy::derivable_impls)]
435impl Default for CommonMarkCache {
436    fn default() -> Self {
437        Self {
438            #[cfg(feature = "better_syntax_highlighting")]
439            ps: SyntaxSet::load_defaults_newlines(),
440            #[cfg(feature = "better_syntax_highlighting")]
441            ts: ThemeSet::load_defaults(),
442            link_hooks: HashMap::new(),
443            scroll: Default::default(),
444            has_installed_loaders: false,
445        }
446    }
447}
448
449impl CommonMarkCache {
450    #[cfg(feature = "better_syntax_highlighting")]
451    pub fn add_syntax_from_folder(&mut self, path: &str) {
452        let mut builder = self.ps.clone().into_builder();
453        let _ = builder.add_from_folder(path, true);
454        self.ps = builder.build();
455    }
456
457    #[cfg(feature = "better_syntax_highlighting")]
458    pub fn add_syntax_from_str(&mut self, s: &str, fallback_name: Option<&str>) {
459        let mut builder = self.ps.clone().into_builder();
460        let _ = SyntaxDefinition::load_from_str(s, true, fallback_name).map(|d| builder.add(d));
461        self.ps = builder.build();
462    }
463
464    #[cfg(feature = "better_syntax_highlighting")]
465    /// Add more color themes for code blocks(.tmTheme files). Set the color theme with
466    /// [`syntax_theme_dark`](CommonMarkViewer::syntax_theme_dark) and
467    /// [`syntax_theme_light`](CommonMarkViewer::syntax_theme_light)
468    pub fn add_syntax_themes_from_folder(
469        &mut self,
470        path: impl AsRef<std::path::Path>,
471    ) -> Result<(), syntect::LoadingError> {
472        self.ts.add_from_folder(path)
473    }
474
475    #[cfg(feature = "better_syntax_highlighting")]
476    /// Add color theme for code blocks(.tmTheme files). Set the color theme with
477    /// [`syntax_theme_dark`](CommonMarkViewer::syntax_theme_dark) and
478    /// [`syntax_theme_light`](CommonMarkViewer::syntax_theme_light)
479    pub fn add_syntax_theme_from_bytes(
480        &mut self,
481        name: impl Into<String>,
482        bytes: &[u8],
483    ) -> Result<(), syntect::LoadingError> {
484        let mut cursor = std::io::Cursor::new(bytes);
485        self.ts
486            .themes
487            .insert(name.into(), ThemeSet::load_from_reader(&mut cursor)?);
488        Ok(())
489    }
490
491    /// Clear the cache for all scrollable elements
492    pub fn clear_scrollable(&mut self) {
493        self.scroll.clear();
494    }
495
496    /// Clear the cache for a specific scrollable viewer. Returns false if the
497    /// id was not in the cache.
498    pub fn clear_scrollable_with_id(&mut self, source_id: impl std::hash::Hash) -> bool {
499        self.scroll.remove(&egui::Id::new(source_id)).is_some()
500    }
501
502    /// If the user clicks on a link in the markdown render that has `name` as a link. The hook
503    /// specified with this method will be set to true. It's status can be acquired
504    /// with [`get_link_hook`](Self::get_link_hook). Be aware that all hook state is reset once
505    /// [`CommonMarkViewer::show`] gets called
506    ///
507    /// # Why use link hooks
508    ///
509    /// egui provides a method for checking links afterwards so why use this instead?
510    ///
511    /// ```rust
512    /// # use egui::__run_test_ctx;
513    /// # __run_test_ctx(|ctx| {
514    /// ctx.output_mut(|o| for command in &o.commands {
515    ///     matches!(command, egui::OutputCommand::OpenUrl(_));
516    /// });
517    /// # });
518    /// ```
519    ///
520    /// The main difference is that link hooks allows gorbie_commonmark to check for link hooks
521    /// while rendering. Normally when hovering over a link, gorbie_commonmark will display the full
522    /// url. With link hooks this feature is disabled, but to do that all hooks must be known.
523    // Works when displayed through gorbie_commonmark
524    #[allow(rustdoc::broken_intra_doc_links)]
525    pub fn add_link_hook<S: Into<String>>(&mut self, name: S) {
526        self.link_hooks.insert(name.into(), false);
527    }
528
529    /// Returns None if the link hook could not be found. Returns the last known status of the
530    /// hook otherwise.
531    pub fn remove_link_hook(&mut self, name: &str) -> Option<bool> {
532        self.link_hooks.remove(name)
533    }
534
535    /// Get status of link. Returns true if it was clicked
536    pub fn get_link_hook(&self, name: &str) -> Option<bool> {
537        self.link_hooks.get(name).copied()
538    }
539
540    /// Remove all link hooks
541    pub fn link_hooks_clear(&mut self) {
542        self.link_hooks.clear();
543    }
544
545    /// All link hooks
546    pub fn link_hooks(&self) -> &HashMap<String, bool> {
547        &self.link_hooks
548    }
549
550    /// Raw access to link hooks
551    pub fn link_hooks_mut(&mut self) -> &mut HashMap<String, bool> {
552        &mut self.link_hooks
553    }
554
555    /// Set all link hooks to false
556    fn deactivate_link_hooks(&mut self) {
557        for v in self.link_hooks.values_mut() {
558            *v = false;
559        }
560    }
561
562    #[cfg(feature = "better_syntax_highlighting")]
563    fn curr_theme(&self, ui: &Ui, options: &CommonMarkOptions) -> &Theme {
564        self.ts
565            .themes
566            .get(options.curr_theme(ui))
567            // Since we have called load_defaults, the default theme *should* always be available..
568            .unwrap_or_else(|| &self.ts.themes[default_theme(ui)])
569    }
570}
571
572pub fn scroll_cache<'a>(cache: &'a mut CommonMarkCache, id: &egui::Id) -> &'a mut ScrollableCache {
573    if !cache.scroll.contains_key(id) {
574        cache.scroll.insert(*id, Default::default());
575    }
576    cache.scroll.get_mut(id).unwrap()
577}
578
579/// Should be called before any rendering
580pub fn prepare_show(cache: &mut CommonMarkCache, ctx: &egui::Context) {
581    if !cache.has_installed_loaders {
582        // Even though the install function can be called multiple times, its not the cheapest
583        // so we ensure that we only call it once.
584        // This could be done at the creation of the cache, however it is better to keep the
585        // cache free from egui's Ui and Context types as this allows it to be created before
586        // any egui instances. It also keeps the API similar to before the introduction of the
587        // image loaders.
588        #[cfg(feature = "embedded_image")]
589        crate::data_url_loader::install_loader(ctx);
590
591        egui_extras::install_image_loaders(ctx);
592        cache.has_installed_loaders = true;
593    }
594
595    cache.deactivate_link_hooks();
596}