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 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 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 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 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 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#[derive(Debug)]
419pub struct CommonMarkCache {
420 #[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 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 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 pub fn clear_scrollable(&mut self) {
493 self.scroll.clear();
494 }
495
496 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 #[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 pub fn remove_link_hook(&mut self, name: &str) -> Option<bool> {
532 self.link_hooks.remove(name)
533 }
534
535 pub fn get_link_hook(&self, name: &str) -> Option<bool> {
537 self.link_hooks.get(name).copied()
538 }
539
540 pub fn link_hooks_clear(&mut self) {
542 self.link_hooks.clear();
543 }
544
545 pub fn link_hooks(&self) -> &HashMap<String, bool> {
547 &self.link_hooks
548 }
549
550 pub fn link_hooks_mut(&mut self) -> &mut HashMap<String, bool> {
552 &mut self.link_hooks
553 }
554
555 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 .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
579pub fn prepare_show(cache: &mut CommonMarkCache, ctx: &egui::Context) {
581 if !cache.has_installed_loaders {
582 #[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}