use std::sync::Arc;
use pulldown_cmark::{Event as MdEvent, Options, Parser, Tag, TagEnd};
use crate::color::Color;
use crate::draw_ctx::DrawCtx;
use crate::event::{Event, EventResult};
use crate::geometry::{Rect, Size};
use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
use crate::text::{measure_text_metrics, Font};
use crate::widget::Widget;
#[derive(Clone, Copy, Debug, PartialEq)]
enum LineStyle {
Body,
H1,
H2,
H3,
H4,
Code,
Rule,
}
impl LineStyle {
fn font_size(self, base: f64) -> f64 {
match self {
LineStyle::H1 => base * 1.8,
LineStyle::H2 => base * 1.5,
LineStyle::H3 => base * 1.25,
LineStyle::H4 => base * 1.1,
LineStyle::Body => base,
LineStyle::Code => base * 0.9,
LineStyle::Rule => base,
}
}
}
#[derive(Clone)]
enum LayoutItem {
Line {
text: String,
style: LineStyle,
indent: f64,
y: f64,
height: f64,
},
Image {
#[allow(dead_code)]
url: String,
alt: String,
cache_idx: usize,
x: f64,
y: f64,
width: f64,
height: f64,
},
}
enum ParagraphItem {
Text(String, LineStyle, f64),
Image { url: String, alt: String },
}
struct ImageEntry {
url: String,
data: Option<(Vec<u8>, u32, u32)>,
}
pub struct MarkdownView {
bounds: Rect,
children: Vec<Box<dyn Widget>>,
base: WidgetBase,
markdown: String,
font: Arc<Font>,
font_size: f64,
padding: f64,
image_provider: Option<Box<dyn Fn(&str) -> Option<(Vec<u8>, u32, u32)>>>,
image_cache: Vec<ImageEntry>,
items: Vec<LayoutItem>,
content_h: f64,
}
impl MarkdownView {
pub fn new(markdown: impl Into<String>, font: Arc<Font>) -> Self {
Self {
bounds: Rect::default(),
children: Vec::new(),
base: WidgetBase::new(),
markdown: markdown.into(),
font,
font_size: 14.0,
padding: 8.0,
image_provider: None,
image_cache: Vec::new(),
items: Vec::new(),
content_h: 0.0,
}
}
pub fn with_font_size(mut self, size: f64) -> Self { self.font_size = size; self }
pub fn with_padding(mut self, p: f64) -> Self { self.padding = p; self }
fn active_font(&self) -> Arc<Font> {
crate::font_settings::current_system_font()
.unwrap_or_else(|| Arc::clone(&self.font))
}
pub fn with_image_provider(
mut self,
provider: impl Fn(&str) -> Option<(Vec<u8>, u32, u32)> + 'static,
) -> Self {
self.image_provider = Some(Box::new(provider));
self
}
pub fn with_margin(mut self, m: Insets) -> Self { self.base.margin = m; self }
pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
fn parse_paragraphs(&self) -> Vec<ParagraphItem> {
let mut out: Vec<ParagraphItem> = Vec::new();
let opts = Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_TABLES;
let parser = Parser::new_ext(&self.markdown, opts);
let mut cur_text = String::new();
let mut cur_style = LineStyle::Body;
let mut cur_indent = 0.0_f64;
let mut list_depth = 0u32;
let mut list_ordinal: Vec<u64> = Vec::new();
let mut in_image: Option<String> = None;
let flush = |out: &mut Vec<ParagraphItem>, text: &mut String, style: LineStyle, indent: f64| {
let t = text.trim().to_string();
if !t.is_empty() {
out.push(ParagraphItem::Text(t, style, indent));
}
text.clear();
};
for ev in parser {
match ev {
MdEvent::Start(Tag::Image { dest_url, .. }) => {
flush(&mut out, &mut cur_text, cur_style, cur_indent);
in_image = Some(dest_url.to_string());
}
MdEvent::End(TagEnd::Image) => {
if let Some(url) = in_image.take() {
let alt = cur_text.trim().to_string();
cur_text.clear();
out.push(ParagraphItem::Image { url, alt });
out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0)); }
}
MdEvent::Text(t) if in_image.is_some() => {
cur_text.push_str(&t);
}
MdEvent::Start(Tag::Heading { level, .. }) => {
flush(&mut out, &mut cur_text, cur_style, cur_indent);
cur_style = match level as u8 { 1 => LineStyle::H1, 2 => LineStyle::H2, 3 => LineStyle::H3, _ => LineStyle::H4 };
cur_indent = 0.0;
}
MdEvent::End(TagEnd::Heading(_)) => {
flush(&mut out, &mut cur_text, cur_style, cur_indent);
out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
cur_style = LineStyle::Body;
cur_indent = 0.0;
}
MdEvent::Start(Tag::Paragraph) => {
flush(&mut out, &mut cur_text, cur_style, cur_indent);
}
MdEvent::End(TagEnd::Paragraph) => {
flush(&mut out, &mut cur_text, cur_style, cur_indent);
out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
}
MdEvent::Start(Tag::List(first)) => {
list_depth += 1;
list_ordinal.push(first.unwrap_or(1));
cur_indent = list_depth as f64 * 16.0;
}
MdEvent::End(TagEnd::List(_)) => {
flush(&mut out, &mut cur_text, cur_style, cur_indent);
list_depth = list_depth.saturating_sub(1);
list_ordinal.pop();
cur_indent = list_depth as f64 * 16.0;
if list_depth == 0 {
out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
}
}
MdEvent::Start(Tag::Item) => {
flush(&mut out, &mut cur_text, cur_style, cur_indent);
if let Some(n) = list_ordinal.last_mut() {
cur_text = format!("{}. ", n);
*n += 1;
} else {
cur_text = "• ".to_string();
}
}
MdEvent::End(TagEnd::Item) => {
flush(&mut out, &mut cur_text, cur_style, cur_indent);
}
MdEvent::Start(Tag::CodeBlock(_)) => {
flush(&mut out, &mut cur_text, cur_style, cur_indent);
cur_style = LineStyle::Code;
}
MdEvent::End(TagEnd::CodeBlock) => {
flush(&mut out, &mut cur_text, cur_style, cur_indent);
out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
cur_style = LineStyle::Body;
}
MdEvent::Rule => {
flush(&mut out, &mut cur_text, cur_style, cur_indent);
out.push(ParagraphItem::Text("".to_string(), LineStyle::Rule, 0.0));
}
MdEvent::Text(t) => {
if !cur_text.is_empty() && !cur_text.ends_with(' ') && !cur_text.ends_with('\n') {
cur_text.push(' ');
}
cur_text.push_str(&t);
}
MdEvent::Code(t) => {
if !cur_text.is_empty() && !cur_text.ends_with(' ') { cur_text.push(' '); }
cur_text.push('`');
cur_text.push_str(&t);
cur_text.push('`');
}
MdEvent::SoftBreak | MdEvent::HardBreak => { cur_text.push(' '); }
MdEvent::Start(Tag::Link { .. }) | MdEvent::End(TagEnd::Link) => {}
_ => {}
}
}
flush(&mut out, &mut cur_text, cur_style, cur_indent);
out
}
fn wrap_paragraph(&self, text: &str, style: LineStyle, indent: f64, max_w: f64) -> Vec<(String, f64)> {
let font_size = style.font_size(self.font_size);
let avail = (max_w - indent).max(1.0);
if text.is_empty() { return vec![("".to_string(), indent)]; }
let font = self.active_font();
let mut lines: Vec<(String, f64)> = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
let candidate = if current.is_empty() { word.to_string() } else { format!("{} {}", current, word) };
let w = measure_text_metrics(&font, &candidate, font_size).width;
if w <= avail || current.is_empty() {
current = candidate;
} else {
lines.push((current, indent));
current = word.to_string();
}
}
if !current.is_empty() { lines.push((current, indent)); }
lines
}
fn get_or_load_image(&mut self, url: &str) -> usize {
if let Some(idx) = self.image_cache.iter().position(|e| e.url == url) {
return idx;
}
let data = self.image_provider.as_ref().and_then(|p| p(url));
let idx = self.image_cache.len();
self.image_cache.push(ImageEntry { url: url.to_string(), data });
idx
}
}
impl Widget for MarkdownView {
fn type_name(&self) -> &'static str { "MarkdownView" }
fn bounds(&self) -> Rect { self.bounds }
fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
fn children(&self) -> &[Box<dyn Widget>] { &self.children }
fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
fn margin(&self) -> Insets { self.base.margin }
fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
fn layout(&mut self, available: Size) -> Size {
let pad = self.padding;
let max_w = (available.width - pad * 2.0).max(1.0);
let paragraphs = self.parse_paragraphs();
struct RawItem {
text: String,
style: LineStyle,
indent: f64,
height: f64,
is_image: bool,
image_url: String,
image_alt: String,
cache_idx: usize,
img_disp_w: f64,
}
let mut raw: Vec<RawItem> = Vec::new();
for item in ¶graphs {
match item {
ParagraphItem::Text(text, style, indent) => {
if *style == LineStyle::Rule {
raw.push(RawItem { text: String::new(), style: LineStyle::Rule, indent: 0.0,
height: 8.0, is_image: false, image_url: String::new(), image_alt: String::new(),
cache_idx: 0, img_disp_w: 0.0 });
continue;
}
let font_size = style.font_size(self.font_size);
let metrics = measure_text_metrics(&self.active_font(), "", font_size);
let line_h = metrics.line_height * 1.3;
if text.is_empty() {
raw.push(RawItem { text: String::new(), style: *style, indent: *indent,
height: line_h * 0.5, is_image: false, image_url: String::new(),
image_alt: String::new(), cache_idx: 0, img_disp_w: 0.0 });
continue;
}
let wrapped = self.wrap_paragraph(text, *style, *indent, max_w);
for (wl, ind) in wrapped {
raw.push(RawItem { text: wl, style: *style, indent: ind,
height: line_h, is_image: false, image_url: String::new(),
image_alt: String::new(), cache_idx: 0, img_disp_w: 0.0 });
}
}
ParagraphItem::Image { url, alt } => {
let cache_idx = self.get_or_load_image(url);
let (disp_w, disp_h) = if let Some((_, iw, ih)) = self.image_cache[cache_idx].data.as_ref() {
let scale = (max_w / *iw as f64).min(1.0); (*iw as f64 * scale, *ih as f64 * scale)
} else {
(max_w, 60.0)
};
raw.push(RawItem { text: alt.clone(), style: LineStyle::Body, indent: 0.0,
height: disp_h, is_image: true, image_url: url.clone(),
image_alt: alt.clone(), cache_idx, img_disp_w: disp_w });
}
}
}
let total_h: f64 = raw.iter().map(|r| r.height).sum::<f64>() + pad * 2.0;
let mut y = total_h - pad;
self.items.clear();
for r in raw {
y -= r.height;
if r.is_image {
self.items.push(LayoutItem::Image {
url: r.image_url,
alt: r.image_alt,
cache_idx: r.cache_idx,
x: pad,
y,
width: r.img_disp_w,
height: r.height,
});
} else {
self.items.push(LayoutItem::Line {
text: r.text,
style: r.style,
indent: r.indent,
y,
height: r.height,
});
}
}
self.content_h = total_h;
self.bounds = Rect::new(0.0, 0.0, available.width, total_h);
Size::new(available.width, total_h)
}
fn paint(&mut self, ctx: &mut dyn DrawCtx) {
let v = ctx.visuals();
let pad = self.padding;
let w = self.bounds.width;
let font = self.active_font();
ctx.set_font(Arc::clone(&font));
for item in &self.items {
match item {
LayoutItem::Line { text, style, indent, y, height } => {
let fs = style.font_size(self.font_size);
ctx.set_font_size(fs);
let tx = pad + indent;
let ty = y + height * 0.5;
let metrics = measure_text_metrics(&font, text.as_str(), fs);
let text_y = ty - (metrics.ascent - metrics.descent) * 0.5;
match style {
LineStyle::Rule => {
ctx.set_fill_color(v.separator);
ctx.begin_path();
ctx.rect(pad, ty, w - pad * 2.0, 1.0);
ctx.fill();
}
LineStyle::Code => {
ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.15));
ctx.begin_path();
ctx.rounded_rect(pad, *y, w - pad * 2.0, *height, 3.0);
ctx.fill();
ctx.set_fill_color(v.accent);
ctx.fill_text(text, tx + 4.0, text_y);
}
_ => {
ctx.set_fill_color(v.text_color);
if !text.is_empty() {
ctx.fill_text(text, tx, text_y);
}
}
}
}
LayoutItem::Image { url: _, alt, cache_idx, x, y, width, height } => {
if let Some(entry) = self.image_cache.get(*cache_idx) {
if let Some((data, iw, ih)) = &entry.data {
ctx.draw_image_rgba(data.as_slice(), *iw, *ih, *x, *y, *width, *height);
} else {
ctx.set_fill_color(Color::rgba(0.5, 0.5, 0.5, 0.15));
ctx.begin_path();
ctx.rounded_rect(*x, *y, *width, *height, 4.0);
ctx.fill();
ctx.set_fill_color(v.text_dim);
ctx.set_font_size(self.font_size * 0.85);
let label = format!("[image: {}]", alt);
ctx.fill_text(&label, x + 8.0, y + height * 0.5);
}
}
}
}
}
}
fn on_event(&mut self, _: &Event) -> EventResult { EventResult::Ignored }
}