1use std::sync::Arc;
31
32use pulldown_cmark::{Event as MdEvent, Options, Parser, Tag, TagEnd};
33
34use crate::color::Color;
35use crate::draw_ctx::DrawCtx;
36use crate::event::{Event, EventResult};
37use crate::geometry::{Rect, Size};
38use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
39use crate::text::{measure_text_metrics, Font};
40use crate::widget::Widget;
41
42#[derive(Clone, Copy, Debug, PartialEq)]
45enum LineStyle {
46 Body,
47 H1,
48 H2,
49 H3,
50 H4,
51 Code,
52 Rule,
53}
54
55impl LineStyle {
56 fn font_size(self, base: f64) -> f64 {
57 match self {
58 LineStyle::H1 => base * 1.8,
59 LineStyle::H2 => base * 1.5,
60 LineStyle::H3 => base * 1.25,
61 LineStyle::H4 => base * 1.1,
62 LineStyle::Body => base,
63 LineStyle::Code => base * 0.9,
64 LineStyle::Rule => base,
65 }
66 }
67}
68
69#[derive(Clone)]
73enum LayoutItem {
74 Line {
76 text: String,
77 style: LineStyle,
78 indent: f64,
79 y: f64,
80 height: f64,
81 },
82 Image {
84 #[allow(dead_code)]
86 url: String,
87 alt: String,
88 cache_idx: usize,
90 x: f64,
92 y: f64,
93 width: f64,
94 height: f64,
95 },
96}
97
98enum ParagraphItem {
101 Text(String, LineStyle, f64),
102 Image { url: String, alt: String },
103}
104
105struct ImageEntry {
108 url: String,
109 data: Option<(Vec<u8>, u32, u32)>,
111}
112
113pub struct MarkdownView {
118 bounds: Rect,
119 children: Vec<Box<dyn Widget>>,
120 base: WidgetBase,
121
122 markdown: String,
123 font: Arc<Font>,
124 font_size: f64,
125 padding: f64,
126
127 image_provider: Option<Box<dyn Fn(&str) -> Option<(Vec<u8>, u32, u32)>>>,
130
131 image_cache: Vec<ImageEntry>,
133
134 items: Vec<LayoutItem>,
136 content_h: f64,
138}
139
140impl MarkdownView {
141 pub fn new(markdown: impl Into<String>, font: Arc<Font>) -> Self {
142 Self {
143 bounds: Rect::default(),
144 children: Vec::new(),
145 base: WidgetBase::new(),
146 markdown: markdown.into(),
147 font,
148 font_size: 14.0,
149 padding: 8.0,
150 image_provider: None,
151 image_cache: Vec::new(),
152 items: Vec::new(),
153 content_h: 0.0,
154 }
155 }
156
157 pub fn with_font_size(mut self, size: f64) -> Self { self.font_size = size; self }
158 pub fn with_padding(mut self, p: f64) -> Self { self.padding = p; self }
159
160 fn active_font(&self) -> Arc<Font> {
164 crate::font_settings::current_system_font()
165 .unwrap_or_else(|| Arc::clone(&self.font))
166 }
167
168 pub fn with_image_provider(
173 mut self,
174 provider: impl Fn(&str) -> Option<(Vec<u8>, u32, u32)> + 'static,
175 ) -> Self {
176 self.image_provider = Some(Box::new(provider));
177 self
178 }
179
180 pub fn with_margin(mut self, m: Insets) -> Self { self.base.margin = m; self }
181 pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
182 pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
183
184 fn parse_paragraphs(&self) -> Vec<ParagraphItem> {
187 let mut out: Vec<ParagraphItem> = Vec::new();
188
189 let opts = Options::ENABLE_STRIKETHROUGH
190 | Options::ENABLE_TASKLISTS
191 | Options::ENABLE_TABLES;
192 let parser = Parser::new_ext(&self.markdown, opts);
193
194 let mut cur_text = String::new();
195 let mut cur_style = LineStyle::Body;
196 let mut cur_indent = 0.0_f64;
197 let mut list_depth = 0u32;
198 let mut list_ordinal: Vec<u64> = Vec::new();
199 let mut in_image: Option<String> = None; let flush = |out: &mut Vec<ParagraphItem>, text: &mut String, style: LineStyle, indent: f64| {
203 let t = text.trim().to_string();
204 if !t.is_empty() {
205 out.push(ParagraphItem::Text(t, style, indent));
206 }
207 text.clear();
208 };
209
210 for ev in parser {
211 match ev {
212 MdEvent::Start(Tag::Image { dest_url, .. }) => {
213 flush(&mut out, &mut cur_text, cur_style, cur_indent);
215 in_image = Some(dest_url.to_string());
216 }
217 MdEvent::End(TagEnd::Image) => {
218 if let Some(url) = in_image.take() {
219 let alt = cur_text.trim().to_string();
220 cur_text.clear();
221 out.push(ParagraphItem::Image { url, alt });
222 out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0)); }
224 }
225 MdEvent::Text(t) if in_image.is_some() => {
227 cur_text.push_str(&t);
228 }
229 MdEvent::Start(Tag::Heading { level, .. }) => {
230 flush(&mut out, &mut cur_text, cur_style, cur_indent);
231 cur_style = match level as u8 { 1 => LineStyle::H1, 2 => LineStyle::H2, 3 => LineStyle::H3, _ => LineStyle::H4 };
232 cur_indent = 0.0;
233 }
234 MdEvent::End(TagEnd::Heading(_)) => {
235 flush(&mut out, &mut cur_text, cur_style, cur_indent);
236 out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
237 cur_style = LineStyle::Body;
238 cur_indent = 0.0;
239 }
240 MdEvent::Start(Tag::Paragraph) => {
241 flush(&mut out, &mut cur_text, cur_style, cur_indent);
242 }
243 MdEvent::End(TagEnd::Paragraph) => {
244 flush(&mut out, &mut cur_text, cur_style, cur_indent);
245 out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
246 }
247 MdEvent::Start(Tag::List(first)) => {
248 list_depth += 1;
249 list_ordinal.push(first.unwrap_or(1));
250 cur_indent = list_depth as f64 * 16.0;
251 }
252 MdEvent::End(TagEnd::List(_)) => {
253 flush(&mut out, &mut cur_text, cur_style, cur_indent);
254 list_depth = list_depth.saturating_sub(1);
255 list_ordinal.pop();
256 cur_indent = list_depth as f64 * 16.0;
257 if list_depth == 0 {
258 out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
259 }
260 }
261 MdEvent::Start(Tag::Item) => {
262 flush(&mut out, &mut cur_text, cur_style, cur_indent);
263 if let Some(n) = list_ordinal.last_mut() {
264 cur_text = format!("{}. ", n);
265 *n += 1;
266 } else {
267 cur_text = "• ".to_string();
268 }
269 }
270 MdEvent::End(TagEnd::Item) => {
271 flush(&mut out, &mut cur_text, cur_style, cur_indent);
272 }
273 MdEvent::Start(Tag::CodeBlock(_)) => {
274 flush(&mut out, &mut cur_text, cur_style, cur_indent);
275 cur_style = LineStyle::Code;
276 }
277 MdEvent::End(TagEnd::CodeBlock) => {
278 flush(&mut out, &mut cur_text, cur_style, cur_indent);
279 out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
280 cur_style = LineStyle::Body;
281 }
282 MdEvent::Rule => {
283 flush(&mut out, &mut cur_text, cur_style, cur_indent);
284 out.push(ParagraphItem::Text("".to_string(), LineStyle::Rule, 0.0));
285 }
286 MdEvent::Text(t) => {
287 if !cur_text.is_empty() && !cur_text.ends_with(' ') && !cur_text.ends_with('\n') {
288 cur_text.push(' ');
289 }
290 cur_text.push_str(&t);
291 }
292 MdEvent::Code(t) => {
293 if !cur_text.is_empty() && !cur_text.ends_with(' ') { cur_text.push(' '); }
294 cur_text.push('`');
295 cur_text.push_str(&t);
296 cur_text.push('`');
297 }
298 MdEvent::SoftBreak | MdEvent::HardBreak => { cur_text.push(' '); }
299 MdEvent::Start(Tag::Link { .. }) | MdEvent::End(TagEnd::Link) => {}
300 _ => {}
301 }
302 }
303 flush(&mut out, &mut cur_text, cur_style, cur_indent);
304 out
305 }
306
307 fn wrap_paragraph(&self, text: &str, style: LineStyle, indent: f64, max_w: f64) -> Vec<(String, f64)> {
310 let font_size = style.font_size(self.font_size);
311 let avail = (max_w - indent).max(1.0);
312 if text.is_empty() { return vec![("".to_string(), indent)]; }
313
314 let font = self.active_font();
315 let mut lines: Vec<(String, f64)> = Vec::new();
316 let mut current = String::new();
317
318 for word in text.split_whitespace() {
319 let candidate = if current.is_empty() { word.to_string() } else { format!("{} {}", current, word) };
320 let w = measure_text_metrics(&font, &candidate, font_size).width;
321 if w <= avail || current.is_empty() {
322 current = candidate;
323 } else {
324 lines.push((current, indent));
325 current = word.to_string();
326 }
327 }
328 if !current.is_empty() { lines.push((current, indent)); }
329 lines
330 }
331
332 fn get_or_load_image(&mut self, url: &str) -> usize {
336 if let Some(idx) = self.image_cache.iter().position(|e| e.url == url) {
338 return idx;
339 }
340 let data = self.image_provider.as_ref().and_then(|p| p(url));
342 let idx = self.image_cache.len();
343 self.image_cache.push(ImageEntry { url: url.to_string(), data });
344 idx
345 }
346}
347
348impl Widget for MarkdownView {
349 fn type_name(&self) -> &'static str { "MarkdownView" }
350 fn bounds(&self) -> Rect { self.bounds }
351 fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
352 fn children(&self) -> &[Box<dyn Widget>] { &self.children }
353 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
354
355 fn margin(&self) -> Insets { self.base.margin }
356 fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
357 fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
358
359 fn layout(&mut self, available: Size) -> Size {
360 let pad = self.padding;
361 let max_w = (available.width - pad * 2.0).max(1.0);
362
363 let paragraphs = self.parse_paragraphs();
364
365 struct RawItem {
367 text: String,
368 style: LineStyle,
369 indent: f64,
370 height: f64,
371 is_image: bool,
373 image_url: String,
374 image_alt: String,
375 cache_idx: usize,
376 img_disp_w: f64,
377 }
378
379 let mut raw: Vec<RawItem> = Vec::new();
380
381 for item in ¶graphs {
382 match item {
383 ParagraphItem::Text(text, style, indent) => {
384 if *style == LineStyle::Rule {
385 raw.push(RawItem { text: String::new(), style: LineStyle::Rule, indent: 0.0,
386 height: 8.0, is_image: false, image_url: String::new(), image_alt: String::new(),
387 cache_idx: 0, img_disp_w: 0.0 });
388 continue;
389 }
390 let font_size = style.font_size(self.font_size);
391 let metrics = measure_text_metrics(&self.active_font(), "", font_size);
392 let line_h = metrics.line_height * 1.3;
393
394 if text.is_empty() {
395 raw.push(RawItem { text: String::new(), style: *style, indent: *indent,
396 height: line_h * 0.5, is_image: false, image_url: String::new(),
397 image_alt: String::new(), cache_idx: 0, img_disp_w: 0.0 });
398 continue;
399 }
400 let wrapped = self.wrap_paragraph(text, *style, *indent, max_w);
401 for (wl, ind) in wrapped {
402 raw.push(RawItem { text: wl, style: *style, indent: ind,
403 height: line_h, is_image: false, image_url: String::new(),
404 image_alt: String::new(), cache_idx: 0, img_disp_w: 0.0 });
405 }
406 }
407 ParagraphItem::Image { url, alt } => {
408 let cache_idx = self.get_or_load_image(url);
409 let (disp_w, disp_h) = if let Some((_, iw, ih)) = self.image_cache[cache_idx].data.as_ref() {
410 let scale = (max_w / *iw as f64).min(1.0); (*iw as f64 * scale, *ih as f64 * scale)
413 } else {
414 (max_w, 60.0)
416 };
417 raw.push(RawItem { text: alt.clone(), style: LineStyle::Body, indent: 0.0,
418 height: disp_h, is_image: true, image_url: url.clone(),
419 image_alt: alt.clone(), cache_idx, img_disp_w: disp_w });
420 }
421 }
422 }
423
424 let total_h: f64 = raw.iter().map(|r| r.height).sum::<f64>() + pad * 2.0;
426 let mut y = total_h - pad;
427
428 self.items.clear();
429 for r in raw {
430 y -= r.height;
431 if r.is_image {
432 self.items.push(LayoutItem::Image {
433 url: r.image_url,
434 alt: r.image_alt,
435 cache_idx: r.cache_idx,
436 x: pad,
437 y,
438 width: r.img_disp_w,
439 height: r.height,
440 });
441 } else {
442 self.items.push(LayoutItem::Line {
443 text: r.text,
444 style: r.style,
445 indent: r.indent,
446 y,
447 height: r.height,
448 });
449 }
450 }
451
452 self.content_h = total_h;
453 self.bounds = Rect::new(0.0, 0.0, available.width, total_h);
454 Size::new(available.width, total_h)
455 }
456
457 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
458 let v = ctx.visuals();
459 let pad = self.padding;
460 let w = self.bounds.width;
461 let font = self.active_font();
462 ctx.set_font(Arc::clone(&font));
463
464 for item in &self.items {
465 match item {
466 LayoutItem::Line { text, style, indent, y, height } => {
467 let fs = style.font_size(self.font_size);
468 ctx.set_font_size(fs);
469
470 let tx = pad + indent;
471 let ty = y + height * 0.5;
472 let metrics = measure_text_metrics(&font, text.as_str(), fs);
473 let text_y = ty - (metrics.ascent - metrics.descent) * 0.5;
474
475 match style {
476 LineStyle::Rule => {
477 ctx.set_fill_color(v.separator);
478 ctx.begin_path();
479 ctx.rect(pad, ty, w - pad * 2.0, 1.0);
480 ctx.fill();
481 }
482 LineStyle::Code => {
483 ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.15));
484 ctx.begin_path();
485 ctx.rounded_rect(pad, *y, w - pad * 2.0, *height, 3.0);
486 ctx.fill();
487 ctx.set_fill_color(v.accent);
488 ctx.fill_text(text, tx + 4.0, text_y);
489 }
490 _ => {
491 ctx.set_fill_color(v.text_color);
492 if !text.is_empty() {
493 ctx.fill_text(text, tx, text_y);
494 }
495 }
496 }
497 }
498 LayoutItem::Image { url: _, alt, cache_idx, x, y, width, height } => {
499 if let Some(entry) = self.image_cache.get(*cache_idx) {
500 if let Some((data, iw, ih)) = &entry.data {
501 ctx.draw_image_rgba(data.as_slice(), *iw, *ih, *x, *y, *width, *height);
502 } else {
503 ctx.set_fill_color(Color::rgba(0.5, 0.5, 0.5, 0.15));
505 ctx.begin_path();
506 ctx.rounded_rect(*x, *y, *width, *height, 4.0);
507 ctx.fill();
508 ctx.set_fill_color(v.text_dim);
509 ctx.set_font_size(self.font_size * 0.85);
510 let label = format!("[image: {}]", alt);
511 ctx.fill_text(&label, x + 8.0, y + height * 0.5);
512 }
513 }
514 }
515 }
516 }
517 }
518
519 fn on_event(&mut self, _: &Event) -> EventResult { EventResult::Ignored }
520}