1use std::sync::{Arc, Mutex};
31
32use crate::draw_ctx::DrawCtx;
33use crate::event::{Event, EventResult};
34use crate::geometry::{Point, Rect, Size};
35use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
36use crate::text::Font;
37use crate::widget::Widget;
38
39mod event;
40mod image_context;
41mod image_loader;
42mod layout;
43mod paint;
44mod parse;
45mod rich_html;
46mod selection;
47
48#[derive(Clone, Copy, Debug, PartialEq)]
51enum LineStyle {
52 Body,
53 H1,
54 H2,
55 H3,
56 H4,
57 Code,
58 Rule,
59}
60
61impl LineStyle {
62 fn font_size(self, base: f64) -> f64 {
63 match self {
64 LineStyle::H1 => base * 1.8,
65 LineStyle::H2 => base * 1.5,
66 LineStyle::H3 => base * 1.25,
67 LineStyle::H4 => base * 1.1,
68 LineStyle::Body => base,
69 LineStyle::Code => base * 0.9,
70 LineStyle::Rule => base,
71 }
72 }
73}
74
75#[derive(Clone)]
79enum LayoutItem {
80 Line {
82 runs: Vec<LineRun>,
83 style: LineStyle,
84 indent: f64,
85 quote: bool,
86 y: f64,
87 height: f64,
88 },
89 Table {
90 block_idx: usize,
91 rows: Vec<Vec<String>>,
92 y: f64,
93 height: f64,
94 row_h: f64,
95 col_widths: Vec<f64>,
96 viewport_width: f64,
97 content_width: f64,
98 },
99 CodeBlock {
100 block_idx: usize,
101 lines: Vec<String>,
102 y: f64,
103 height: f64,
104 line_h: f64,
105 viewport_width: f64,
106 content_width: f64,
107 },
108}
109
110#[derive(Clone)]
111enum LineRun {
112 Text {
113 text: String,
114 link: Option<String>,
115 code: bool,
116 x: f64,
117 width: f64,
118 },
119 Image {
120 url: String,
121 alt: String,
122 link: Option<String>,
123 cache_idx: usize,
124 x: f64,
125 y_offset: f64,
126 width: f64,
127 height: f64,
128 },
129}
130
131#[derive(Clone)]
134enum InlineItem {
135 Text {
136 text: String,
137 link: Option<String>,
138 code: bool,
139 },
140 Image {
141 url: String,
142 alt: String,
143 link: Option<String>,
144 },
145}
146
147enum ParagraphItem {
148 Flow {
149 items: Vec<InlineItem>,
150 style: LineStyle,
151 indent: f64,
152 quote: bool,
153 },
154 Table(Vec<Vec<String>>),
155 CodeBlock(Vec<String>),
156 Spacer,
157 Rule,
158}
159
160struct ImageEntry {
163 url: String,
164 state: Arc<Mutex<ImageState>>,
165}
166
167#[derive(Clone, Copy, Debug, Default)]
168struct BlockScroll {
169 offset: f64,
170 dragging: bool,
171 drag_thumb_offset: f64,
172}
173
174#[derive(Clone)]
175struct ImagePixels {
176 data: Arc<Vec<u8>>,
177 width: u32,
178 height: u32,
179}
180
181enum ImageState {
182 RemotePending,
183 Loading,
184 Ready { image: ImagePixels, seen: bool },
185 Failed,
186}
187
188pub struct MarkdownView {
193 bounds: Rect,
194 children: Vec<Box<dyn Widget>>,
195 base: WidgetBase,
196
197 markdown: String,
198 font: Arc<Font>,
199 font_size: f64,
200 padding: f64,
201
202 image_provider: Option<Box<dyn Fn(&str) -> Option<(Vec<u8>, u32, u32)>>>,
205
206 image_cache: Vec<ImageEntry>,
208
209 items: Vec<LayoutItem>,
211 content_h: f64,
213 on_link_click: Option<Box<dyn FnMut(&str)>>,
214 on_image_open: Option<Box<dyn FnMut(&str)>>,
215 block_scrolls: Vec<BlockScroll>,
216 focused: bool,
217 selecting_drag: bool,
218 selection_anchor: Option<usize>,
219 selection_cursor: Option<usize>,
220 selection_drag_start: Option<Point>,
221 selection_dragged: bool,
222 selectable_text: String,
223 selectable_fragments: Vec<selection::SelectableFragment>,
224 context_menu: Option<image_context::MarkdownContextMenuState>,
225 suppress_next_left_mouse_up: bool,
226}
227
228impl MarkdownView {
229 pub fn new(markdown: impl Into<String>, font: Arc<Font>) -> Self {
230 Self {
231 bounds: Rect::default(),
232 children: Vec::new(),
233 base: WidgetBase::new(),
234 markdown: markdown.into(),
235 font,
236 font_size: 14.0,
237 padding: 8.0,
238 image_provider: None,
239 image_cache: Vec::new(),
240 items: Vec::new(),
241 content_h: 0.0,
242 on_link_click: None,
243 on_image_open: None,
244 block_scrolls: Vec::new(),
245 focused: false,
246 selecting_drag: false,
247 selection_anchor: None,
248 selection_cursor: None,
249 selection_drag_start: None,
250 selection_dragged: false,
251 selectable_text: String::new(),
252 selectable_fragments: Vec::new(),
253 context_menu: None,
254 suppress_next_left_mouse_up: false,
255 }
256 }
257
258 pub fn with_font_size(mut self, size: f64) -> Self {
259 self.font_size = size;
260 self
261 }
262 pub fn with_padding(mut self, p: f64) -> Self {
263 self.padding = p;
264 self
265 }
266
267 fn active_font(&self) -> Arc<Font> {
271 crate::font_settings::current_system_font().unwrap_or_else(|| Arc::clone(&self.font))
272 }
273
274 pub fn with_image_provider(
279 mut self,
280 provider: impl Fn(&str) -> Option<(Vec<u8>, u32, u32)> + 'static,
281 ) -> Self {
282 self.image_provider = Some(Box::new(provider));
283 self
284 }
285
286 pub fn on_link_click(mut self, cb: impl FnMut(&str) + 'static) -> Self {
287 self.on_link_click = Some(Box::new(cb));
288 self
289 }
290
291 pub fn on_image_open(mut self, cb: impl FnMut(&str) + 'static) -> Self {
292 self.on_image_open = Some(Box::new(cb));
293 self
294 }
295
296 pub fn with_margin(mut self, m: Insets) -> Self {
297 self.base.margin = m;
298 self
299 }
300 pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
301 self.base.h_anchor = h;
302 self
303 }
304 pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
305 self.base.v_anchor = v;
306 self
307 }
308
309 fn get_or_load_image(&mut self, url: &str) -> usize {
313 if let Some(idx) = self.image_cache.iter().position(|e| e.url == url) {
315 return idx;
316 }
317
318 let state = Arc::new(Mutex::new(
319 if let Some((data, width, height)) = self.image_provider.as_ref().and_then(|p| p(url)) {
320 ImageState::Ready {
321 image: ImagePixels {
322 data: Arc::new(data),
323 width,
324 height,
325 },
326 seen: false,
327 }
328 } else if is_fetchable_url(url) {
329 ImageState::RemotePending
330 } else {
331 ImageState::Failed
332 },
333 ));
334
335 let idx = self.image_cache.len();
336 self.image_cache.push(ImageEntry {
337 url: url.to_string(),
338 state,
339 });
340 idx
341 }
342
343 fn link_at(&self, pos: Point) -> Option<&str> {
344 let pad = self.padding;
345 for item in &self.items {
346 if let LayoutItem::Line {
347 runs,
348 indent,
349 y,
350 height,
351 ..
352 } = item
353 {
354 let tx = pad + indent;
355 for run in runs {
356 match run {
357 LineRun::Text {
358 link: Some(url),
359 x,
360 width,
361 ..
362 } => {
363 if point_in_rect(pos, tx + x, *y, *width, *height) {
364 return Some(url);
365 }
366 }
367 LineRun::Image {
368 url: _,
369 link: Some(url),
370 x,
371 y_offset,
372 width,
373 height,
374 ..
375 } => {
376 if point_in_rect(pos, tx + x, y + y_offset, *width, *height) {
377 return Some(url);
378 }
379 }
380 _ => {}
381 }
382 }
383 }
384 }
385 None
386 }
387
388 fn block_scroll_mut(&mut self, block_idx: usize) -> &mut BlockScroll {
389 if block_idx >= self.block_scrolls.len() {
390 self.block_scrolls
391 .resize(block_idx + 1, BlockScroll::default());
392 }
393 &mut self.block_scrolls[block_idx]
394 }
395
396 fn block_scroll_offset(&self, block_idx: usize) -> f64 {
397 self.block_scrolls
398 .get(block_idx)
399 .map(|s| s.offset)
400 .unwrap_or(0.0)
401 }
402
403 fn hit_scrollbar(&self, pos: Point) -> Option<BlockHit> {
404 for item in &self.items {
405 match item {
406 LayoutItem::Table {
407 block_idx,
408 y,
409 height,
410 viewport_width,
411 content_width,
412 ..
413 }
414 | LayoutItem::CodeBlock {
415 block_idx,
416 y,
417 height,
418 viewport_width,
419 content_width,
420 ..
421 } if *content_width > *viewport_width => {
422 let bar = scrollbar_rect(*y, *viewport_width);
423 if point_in_rect(pos, self.padding + bar.x, bar.y, bar.width, bar.height) {
424 let offset = self.block_scroll_offset(*block_idx);
425 let thumb = scrollbar_thumb(bar, *viewport_width, *content_width, offset);
426 let thumb_hit = point_in_rect(
427 pos,
428 self.padding + thumb.x,
429 thumb.y,
430 thumb.width,
431 thumb.height,
432 );
433 return Some(BlockHit {
434 block_idx: *block_idx,
435 viewport_width: *viewport_width,
436 content_width: *content_width,
437 bar,
438 thumb,
439 on_thumb: thumb_hit,
440 });
441 }
442 let _ = height;
443 }
444 _ => {}
445 }
446 }
447 None
448 }
449
450 fn point_over_scrollable_block(&self, pos: Point) -> Option<(usize, f64, f64)> {
451 for item in &self.items {
452 match item {
453 LayoutItem::Table {
454 block_idx,
455 y,
456 height,
457 viewport_width,
458 content_width,
459 ..
460 }
461 | LayoutItem::CodeBlock {
462 block_idx,
463 y,
464 height,
465 viewport_width,
466 content_width,
467 ..
468 } if *content_width > *viewport_width
469 && point_in_rect(pos, self.padding, *y, *viewport_width, *height) =>
470 {
471 return Some((*block_idx, *viewport_width, *content_width));
472 }
473 _ => {}
474 }
475 }
476 None
477 }
478
479 fn block_metrics(&self, block_idx: usize) -> Option<(Rect, f64, f64)> {
480 self.items.iter().find_map(|item| match item {
481 LayoutItem::Table {
482 block_idx: idx,
483 y,
484 viewport_width,
485 content_width,
486 ..
487 }
488 | LayoutItem::CodeBlock {
489 block_idx: idx,
490 y,
491 viewport_width,
492 content_width,
493 ..
494 } if *idx == block_idx && *content_width > *viewport_width => Some((
495 scrollbar_rect(*y, *viewport_width),
496 *viewport_width,
497 *content_width,
498 )),
499 _ => None,
500 })
501 }
502
503 fn dragging_block(&self) -> Option<usize> {
504 self.block_scrolls
505 .iter()
506 .enumerate()
507 .find_map(|(idx, scroll)| scroll.dragging.then_some(idx))
508 }
509
510 fn scroll_block_to(
511 &mut self,
512 block_idx: usize,
513 offset: f64,
514 viewport: f64,
515 content: f64,
516 ) -> bool {
517 let scroll = self.block_scroll_mut(block_idx);
518 let next = clamp_block_offset(offset, viewport, content);
519 let changed = (next - scroll.offset).abs() > 1e-6;
520 scroll.offset = next;
521 changed
522 }
523
524 fn drag_block_scrollbar(&mut self, block_idx: usize, pos: Point) -> bool {
525 let Some((bar, viewport, content)) = self.block_metrics(block_idx) else {
526 return false;
527 };
528 let offset = self.block_scroll_offset(block_idx);
529 let thumb = scrollbar_thumb(bar, viewport, content, offset);
530 let drag_thumb_offset = self
531 .block_scrolls
532 .get(block_idx)
533 .map(|scroll| scroll.drag_thumb_offset)
534 .unwrap_or(0.0);
535 let travel = (bar.width - thumb.width).max(1.0);
536 let raw_start = pos.x - self.padding - drag_thumb_offset;
537 let frac = ((raw_start - bar.x) / travel).clamp(0.0, 1.0);
538 self.scroll_block_to(
539 block_idx,
540 frac * (content - viewport).max(0.0),
541 viewport,
542 content,
543 )
544 }
545}
546
547fn point_in_rect(pos: Point, x: f64, y: f64, w: f64, h: f64) -> bool {
548 pos.x >= x && pos.x <= x + w && pos.y >= y && pos.y <= y + h
549}
550
551#[derive(Clone, Copy)]
552struct BlockHit {
553 block_idx: usize,
554 viewport_width: f64,
555 content_width: f64,
556 bar: Rect,
557 thumb: Rect,
558 on_thumb: bool,
559}
560
561pub(super) const BLOCK_SCROLLBAR_H: f64 = 10.0;
562pub(super) const BLOCK_SCROLLBAR_GAP: f64 = 4.0;
563const BLOCK_SCROLLBAR_MIN_THUMB: f64 = 24.0;
564
565fn scrollbar_rect(block_y: f64, viewport_width: f64) -> Rect {
566 Rect::new(0.0, block_y + 1.0, viewport_width, BLOCK_SCROLLBAR_H)
567}
568
569fn scrollbar_thumb(bar: Rect, viewport_width: f64, content_width: f64, offset: f64) -> Rect {
570 let ratio = (viewport_width / content_width).clamp(0.0, 1.0);
571 let thumb_w = (bar.width * ratio)
572 .max(BLOCK_SCROLLBAR_MIN_THUMB)
573 .min(bar.width);
574 let travel = (bar.width - thumb_w).max(0.0);
575 let max_scroll = (content_width - viewport_width).max(0.0);
576 let x = if max_scroll > 0.0 {
577 bar.x + travel * (offset / max_scroll).clamp(0.0, 1.0)
578 } else {
579 bar.x
580 };
581 Rect::new(x, bar.y, thumb_w, bar.height)
582}
583
584fn clamp_block_offset(offset: f64, viewport_width: f64, content_width: f64) -> f64 {
585 offset
586 .clamp(0.0, (content_width - viewport_width).max(0.0))
587 .round()
588}
589
590fn is_rect_visible_in_root(ctx: &dyn DrawCtx, x: f64, y: f64, w: f64, h: f64) -> bool {
591 let mut points = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)];
592 let transform = ctx.root_transform();
593 for (px, py) in &mut points {
594 transform.transform(px, py);
595 }
596 let min_x = points.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
597 let max_x = points
598 .iter()
599 .map(|(x, _)| *x)
600 .fold(f64::NEG_INFINITY, f64::max);
601 let min_y = points.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min);
602 let max_y = points
603 .iter()
604 .map(|(_, y)| *y)
605 .fold(f64::NEG_INFINITY, f64::max);
606 let viewport = crate::widget::current_viewport();
607 let root_visible =
608 max_x >= 0.0 && min_x <= viewport.width && max_y >= 0.0 && min_y <= viewport.height;
609 if !root_visible {
610 return false;
611 }
612
613 if let Some(clip) = crate::widget::current_paint_clip() {
614 max_x >= clip.x
615 && min_x <= clip.x + clip.width
616 && max_y >= clip.y
617 && min_y <= clip.y + clip.height
618 } else {
619 true
620 }
621}
622
623fn is_fetchable_url(url: &str) -> bool {
624 !url.is_empty()
625 && !url.starts_with('#')
626 && !url.starts_with("file://")
627 && !url.starts_with("data:")
628}
629
630impl Widget for MarkdownView {
631 fn type_name(&self) -> &'static str {
632 "MarkdownView"
633 }
634 fn bounds(&self) -> Rect {
635 self.bounds
636 }
637 fn set_bounds(&mut self, b: Rect) {
638 self.bounds = b;
639 }
640 fn children(&self) -> &[Box<dyn Widget>] {
641 &self.children
642 }
643 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
644 &mut self.children
645 }
646
647 fn margin(&self) -> Insets {
648 self.base.margin
649 }
650 fn widget_base(&self) -> Option<&WidgetBase> {
651 Some(&self.base)
652 }
653 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
654 Some(&mut self.base)
655 }
656 fn h_anchor(&self) -> HAnchor {
657 self.base.h_anchor
658 }
659 fn v_anchor(&self) -> VAnchor {
660 self.base.v_anchor
661 }
662
663 fn layout(&mut self, available: Size) -> Size {
664 self.layout_markdown(available)
665 }
666
667 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
668 self.paint_markdown(ctx);
669 }
670
671 fn hit_test_global_overlay(&self, local_pos: Point) -> bool {
672 self.context_menu_contains(local_pos)
673 }
674
675 fn has_active_modal(&self) -> bool {
676 self.context_menu.is_some()
677 }
678
679 fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
680 self.paint_context_menu(ctx);
681 }
682
683 fn needs_draw(&self) -> bool {
684 if !self.is_visible() {
685 return false;
686 }
687 self.image_cache.iter().any(|entry| {
688 entry
689 .state
690 .lock()
691 .map(|state| {
692 matches!(
693 *state,
694 ImageState::Loading | ImageState::Ready { seen: false, .. }
695 )
696 })
697 .unwrap_or(false)
698 }) || self.children().iter().any(|c| c.needs_draw())
699 }
700
701 fn on_event(&mut self, event: &Event) -> EventResult {
702 self.handle_markdown_event(event)
703 }
704
705 fn is_focusable(&self) -> bool {
706 true
707 }
708}