328 lines
11 KiB
Rust
Executable File
328 lines
11 KiB
Rust
Executable File
use std::ops::RangeBounds;
|
|
use std::path::Path;
|
|
|
|
use druid::{Color, Env, FontFamily, FontStyle, FontWeight, ImageBuf, Widget, WidgetExt};
|
|
use druid::piet::ImageFormat;
|
|
use druid::text::RichTextBuilder;
|
|
use druid::widget::{FillStrat, Flex};
|
|
use druid_widget_nursery::table::{ComplexTableColumnWidth, FlexTable, TableCellVerticalAlignment, TableColumnWidth, TableRow};
|
|
use image::GenericImageView;
|
|
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag};
|
|
|
|
use crate::app::App;
|
|
use crate::NotesState;
|
|
use crate::widget::rich_text_display::RichTextDisplay;
|
|
|
|
pub struct MarkdownRenderer<'e, 'p> {
|
|
src: &'e str,
|
|
path: &'p str,
|
|
pos: (usize, usize),
|
|
add_newline: bool,
|
|
current_pos: usize,
|
|
builder: RichTextBuilder,
|
|
tag_stack: Vec<(usize, Tag<'e>)>,
|
|
list_depth: usize,
|
|
in_table: Option<usize>,
|
|
in_image: Option<(String, String)>,
|
|
rendered: Vec<Box<dyn Widget<App>>>,
|
|
}
|
|
|
|
impl<'e, 'p> MarkdownRenderer<'e, 'p> {
|
|
const BULLET: &'static str = "• ";
|
|
|
|
pub fn new(src: &'e str, path: &'p str, pos: (usize, usize)) -> Self {
|
|
Self {
|
|
src,
|
|
path,
|
|
pos,
|
|
add_newline: false,
|
|
current_pos: 0,
|
|
builder: RichTextBuilder::new(),
|
|
tag_stack: Vec::new(),
|
|
list_depth: 0,
|
|
in_table: None,
|
|
in_image: None,
|
|
rendered: Vec::with_capacity(1),
|
|
}
|
|
}
|
|
|
|
pub fn render(mut self) -> Vec<Box<dyn Widget<App>>> {
|
|
let mut events = Parser::new_ext(self.src, Options::ENABLE_TABLES);
|
|
while let Some(event) = events.next() {
|
|
self.event(event);
|
|
self.image(&mut events);
|
|
self.table(&mut events);
|
|
}
|
|
|
|
self.render_builder();
|
|
self.rendered
|
|
}
|
|
|
|
fn render_builder(&mut self) {
|
|
let pos = self.pos.clone();
|
|
|
|
let mut builder = RichTextBuilder::new();
|
|
std::mem::swap(&mut builder, &mut self.builder);
|
|
self.current_pos = 0;
|
|
|
|
let built = builder.build();
|
|
if built.is_empty() {
|
|
return;
|
|
}
|
|
|
|
self.rendered.push(RichTextDisplay::new(built)
|
|
.disabled_if(move |data: &Option<NotesState>, _env: &Env| {
|
|
let highlight_pos = data
|
|
.as_ref()
|
|
.and_then(|state| state.step_idx.map(|step_idx| (step_idx, state.area_idx)));
|
|
highlight_pos != Some(pos)
|
|
})
|
|
.lens(App::notes_state)
|
|
.boxed());
|
|
}
|
|
|
|
fn builder_push(&mut self, s: &str) {
|
|
self.builder.push(s);
|
|
self.current_pos += s.len();
|
|
}
|
|
|
|
fn add_attribute_for_tag(&mut self, tag: &Tag, range: impl RangeBounds<usize>) {
|
|
let mut attrs = self.builder.add_attributes_for_range(range);
|
|
match tag {
|
|
Tag::Heading(lvl, _, _) => {
|
|
let font_size = match lvl {
|
|
HeadingLevel::H1 => 38.0,
|
|
HeadingLevel::H2 => 32.0,
|
|
HeadingLevel::H3 => 26.0,
|
|
HeadingLevel::H4 => 20.0,
|
|
HeadingLevel::H5 => 16.0,
|
|
HeadingLevel::H6 => 12.0,
|
|
_ => 12.0,
|
|
};
|
|
attrs.size(font_size).weight(FontWeight::BOLD);
|
|
}
|
|
Tag::BlockQuote => {
|
|
attrs.style(FontStyle::Italic).text_color(Color::GRAY);
|
|
}
|
|
Tag::CodeBlock(_) => {
|
|
attrs.font_family(FontFamily::MONOSPACE);
|
|
}
|
|
Tag::Emphasis => {
|
|
attrs.style(FontStyle::Italic);
|
|
}
|
|
Tag::Strong => {
|
|
attrs.weight(FontWeight::BOLD);
|
|
}
|
|
Tag::Strikethrough => {
|
|
attrs.strikethrough(true);
|
|
}
|
|
Tag::Link(_link_ty, _target, _title) => {
|
|
attrs
|
|
.underline(true)
|
|
.text_color(Color::AQUA);
|
|
// .link(OPEN_LINK.with(target.to_string()));
|
|
}
|
|
// ignore other tags for now
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
fn add_newline_after_tag(tag: &Tag) -> bool {
|
|
!matches!(
|
|
tag,
|
|
Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Link(..),
|
|
)
|
|
}
|
|
|
|
fn event(&mut self, event: Event<'e>) {
|
|
match event {
|
|
Event::Start(tag) => {
|
|
if let Tag::Table(aligns) = &tag {
|
|
self.in_table = Some(aligns.len());
|
|
self.add_newline = false;
|
|
}
|
|
|
|
if let Tag::Image(_, url, title) = &tag {
|
|
self.in_image = Some((url.to_string(), title.to_string()));
|
|
}
|
|
|
|
if matches!(&tag, Tag::List(..)) {
|
|
if self.list_depth > 0 {
|
|
self.add_newline = true;
|
|
}
|
|
|
|
self.list_depth += 1;
|
|
}
|
|
|
|
if self.add_newline {
|
|
self.builder_push("\n");
|
|
self.add_newline = false;
|
|
}
|
|
|
|
if matches!(&tag, Tag::Item) {
|
|
for _ in 0..self.list_depth {
|
|
self.builder_push(" ");
|
|
}
|
|
|
|
self.builder_push(Self::BULLET);
|
|
}
|
|
|
|
self.tag_stack.push((self.current_pos, tag));
|
|
}
|
|
Event::Text(text) => {
|
|
self.builder_push(&text);
|
|
}
|
|
Event::End(end_tag) => {
|
|
if matches!(&end_tag, Tag::Table(..)) {
|
|
self.in_table = None;
|
|
}
|
|
|
|
if matches!(&end_tag, Tag::Image(..)) {
|
|
self.in_image = None;
|
|
}
|
|
|
|
let (start_off, tag) = self.tag_stack
|
|
.pop()
|
|
.expect("parser does not return unbalanced tags");
|
|
assert_eq!(end_tag, tag, "mismatched tags");
|
|
self.add_attribute_for_tag(&tag, start_off..self.current_pos);
|
|
if Self::add_newline_after_tag(&tag) {
|
|
self.add_newline = true;
|
|
}
|
|
if matches!(&tag, Tag::List(..)) {
|
|
self.list_depth -= 1;
|
|
}
|
|
}
|
|
Event::Code(text) => {
|
|
self.builder.push(&text).font_family(FontFamily::MONOSPACE);
|
|
self.current_pos += text.len();
|
|
}
|
|
Event::Html(text) => {
|
|
self.builder
|
|
.push(&text)
|
|
.font_family(FontFamily::MONOSPACE)
|
|
.text_color(Color::RED);
|
|
self.current_pos += text.len();
|
|
}
|
|
Event::HardBreak => {
|
|
self.add_newline = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn table(&mut self, mut events: impl Iterator<Item=Event<'e>>) {
|
|
let cols = match self.in_table {
|
|
Some(cols) => cols,
|
|
None => return,
|
|
};
|
|
|
|
// handle any text yet to render
|
|
self.render_builder();
|
|
let mut start = self.rendered.len();
|
|
let mut table = FlexTable::new()
|
|
.default_vertical_alignment(TableCellVerticalAlignment::Top);
|
|
let widths: Vec<ComplexTableColumnWidth> = (0..cols)
|
|
.map(|i| if i == cols - 1 {
|
|
TableColumnWidth::Flex(1.0).into()
|
|
} else {
|
|
TableColumnWidth::Intrinsic.into()
|
|
})
|
|
.collect();
|
|
table.set_column_widths(&widths);
|
|
let mut row = None;
|
|
|
|
while self.in_table.is_some() {
|
|
if let Some(event) = events.next() {
|
|
match &event {
|
|
Event::Start(Tag::TableRow | Tag::TableHead) => {
|
|
row = Some(TableRow::new());
|
|
}
|
|
Event::Start(Tag::TableCell) => {
|
|
start = self.rendered.len();
|
|
}
|
|
Event::End(Tag::TableRow | Tag::TableHead) => {
|
|
if let Some(row) = row.take() {
|
|
table.add_row(row);
|
|
}
|
|
|
|
row = None;
|
|
}
|
|
Event::End(Tag::TableCell) => {
|
|
self.render_builder();
|
|
let mut widgets: Vec<_> = self.rendered.drain(start..).collect();
|
|
if let Some(row) = &mut row {
|
|
if widgets.len() == 1 {
|
|
row.add_child(widgets.remove(0).padding(2.0));
|
|
} else if widgets.len() > 1 {
|
|
let mut flex = Flex::column();
|
|
for widget in widgets {
|
|
flex.add_child(widget.padding(2.0));
|
|
}
|
|
|
|
row.add_child(flex);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
self.event(event);
|
|
self.add_newline = false; // never add newlines in a table
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
self.rendered.push(table.boxed());
|
|
}
|
|
|
|
fn image(&mut self, mut events: impl Iterator<Item=Event<'e>>) {
|
|
let url = match &self.in_image {
|
|
Some((url, _)) => url.clone(),
|
|
None => return,
|
|
};
|
|
|
|
self.render_builder();
|
|
let start = self.rendered.len();
|
|
|
|
while self.in_image.is_some() {
|
|
if let Some(event) = events.next() {
|
|
self.event(event);
|
|
self.add_newline = false; // never add newlines in an image
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
self.render_builder();
|
|
let alt: Vec<_> = self.rendered.drain(start..).collect();
|
|
|
|
let image_path = Path::new(self.path)
|
|
.parent()
|
|
.map(|parent| parent.join(&url))
|
|
.unwrap_or_else(|| Path::new(&url).to_owned());
|
|
match image::open(image_path) {
|
|
Ok(image) => {
|
|
let image = image.into_rgba8();
|
|
let width = image.width() as usize;
|
|
let height = image.height() as usize;
|
|
let image_buf = ImageBuf::from_raw(
|
|
image.into_flat_samples().samples,
|
|
ImageFormat::RgbaSeparate,
|
|
width,
|
|
height,
|
|
);
|
|
let widget = druid::widget::Image::new(image_buf)
|
|
.fill_mode(FillStrat::ScaleDown)
|
|
.fix_width(width as f64);
|
|
self.rendered.push(widget.boxed());
|
|
}
|
|
Err(err) => {
|
|
eprintln!("error loading image: {:?}", err);
|
|
self.rendered.extend(alt);
|
|
}
|
|
}
|
|
}
|
|
}
|