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 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 0 => {
143 text = text.strong().size(29.0);
144 }
145 1 => {
147 text = text.strong().size(29.0);
148 }
149 2 => {
151 text = text.strong().size(20.0);
152 }
153 3 => {
155 text = text.size(20.0);
156 }
157 4 => {
159 text = text.strong().size(body_size);
160 }
161 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 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 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 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#[derive(Debug)]
492pub struct CommonMarkCache {
493 #[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 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 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 pub fn clear_scrollable(&mut self) {
566 self.scroll.clear();
567 }
568
569 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 #[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 pub fn remove_link_hook(&mut self, name: &str) -> Option<bool> {
605 self.link_hooks.remove(name)
606 }
607
608 pub fn get_link_hook(&self, name: &str) -> Option<bool> {
610 self.link_hooks.get(name).copied()
611 }
612
613 pub fn link_hooks_clear(&mut self) {
615 self.link_hooks.clear();
616 }
617
618 pub fn link_hooks(&self) -> &HashMap<String, bool> {
620 &self.link_hooks
621 }
622
623 pub fn link_hooks_mut(&mut self) -> &mut HashMap<String, bool> {
625 &mut self.link_hooks
626 }
627
628 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 .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
652pub fn prepare_show(cache: &mut CommonMarkCache, ctx: &egui::Context) {
654 if !cache.has_installed_loaders {
655 #[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}