From dcea10f7071e904d4f6343cf31164de825ad5cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 17 Jul 2025 03:48:45 +0200 Subject: [PATCH] Add `table` support to `markdown` widget --- widget/src/markdown.rs | 267 ++++++++++++++++++++++++++++++++++------- widget/src/table.rs | 25 ++-- 2 files changed, 240 insertions(+), 52 deletions(-) diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index be88c5f9..fe456360 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -43,6 +43,7 @@ //! } //! } //! ``` +use crate::core::alignment; use crate::core::border; use crate::core::font::{self, Font}; use crate::core::padding; @@ -107,13 +108,17 @@ impl Content { let mut leftover = std::mem::take(&mut self.state.leftover); leftover.push_str(markdown); + let input = if leftover.trim_end().ends_with('|') { + leftover.trim_end().trim_end_matches('|') + } else { + leftover.as_str() + }; + // Pop the last item let _ = self.items.pop(); // Re-parse last item and new text - for (item, source, broken_links) in - parse_with(&mut self.state, &leftover) - { + for (item, source, broken_links) in parse_with(&mut self.state, input) { if !broken_links.is_empty() { let _ = self.incomplete.insert( self.items.len(), @@ -127,6 +132,8 @@ impl Content { self.items.push(item); } + self.state.leftover.push_str(&leftover[input.len()..]); + // Re-parse incomplete sections if new references are available if !self.incomplete.is_empty() { self.incomplete.retain(|index, section| { @@ -215,6 +222,29 @@ pub enum Item { Quote(Vec), /// A horizontal separator. Rule, + /// A table. + Table { + /// The columns of the table. + columns: Vec, + /// The rows of the table. + rows: Vec, + }, +} + +/// The column of a table. +#[derive(Debug, Clone)] +pub struct Column { + /// The header of the column. + pub header: Vec, + /// The alignment of the column. + pub alignment: pulldown_cmark::Alignment, +} + +/// The row of a table. +#[derive(Debug, Clone)] +pub struct Row { + /// The cells of the row. + cells: Vec>, } /// A bunch of parsed Markdown text. @@ -462,6 +492,12 @@ fn parse_with<'a>( enum Scope { List(List), Quote(Vec), + Table { + alignment: Vec, + columns: Vec, + rows: Vec, + current: Vec, + }, } struct List { @@ -479,7 +515,6 @@ fn parse_with<'a>( let mut emphasis = false; let mut strikethrough = false; let mut metadata = false; - let mut table = false; let mut code_block = false; let mut link = None; let mut image = None; @@ -535,6 +570,9 @@ fn parse_with<'a>( Scope::Quote(items) => { items.push(item); } + Scope::Table { current, .. } => { + current.push(item); + } } None @@ -555,21 +593,19 @@ fn parse_with<'a>( #[allow(clippy::drain_collect)] parser.filter_map(move |(event, source)| match event { pulldown_cmark::Event::Start(tag) => match tag { - pulldown_cmark::Tag::Strong if !metadata && !table => { + pulldown_cmark::Tag::Strong if !metadata => { strong = true; None } - pulldown_cmark::Tag::Emphasis if !metadata && !table => { + pulldown_cmark::Tag::Emphasis if !metadata => { emphasis = true; None } - pulldown_cmark::Tag::Strikethrough if !metadata && !table => { + pulldown_cmark::Tag::Strikethrough if !metadata => { strikethrough = true; None } - pulldown_cmark::Tag::Link { dest_url, .. } - if !metadata && !table => - { + pulldown_cmark::Tag::Link { dest_url, .. } if !metadata => { match Url::parse(&dest_url) { Ok(url) if url.scheme() == "http" @@ -584,13 +620,13 @@ fn parse_with<'a>( } pulldown_cmark::Tag::Image { dest_url, title, .. - } if !metadata && !table => { + } if !metadata => { image = Url::parse(&dest_url) .ok() .map(|url| (url, title.into_string())); None } - pulldown_cmark::Tag::List(first_item) if !metadata && !table => { + pulldown_cmark::Tag::List(first_item) if !metadata => { let prev = if spans.is_empty() { None } else { @@ -616,7 +652,7 @@ fn parse_with<'a>( None } - pulldown_cmark::Tag::BlockQuote(_kind) if !metadata && !table => { + pulldown_cmark::Tag::BlockQuote(_kind) if !metadata => { let prev = if spans.is_empty() { None } else { @@ -634,7 +670,7 @@ fn parse_with<'a>( } pulldown_cmark::Tag::CodeBlock( pulldown_cmark::CodeBlockKind::Fenced(language), - ) if !metadata && !table => { + ) if !metadata => { #[cfg(feature = "highlighter")] { highlighter = Some({ @@ -672,38 +708,54 @@ fn parse_with<'a>( metadata = true; None } - pulldown_cmark::Tag::Table(_) => { - table = true; + pulldown_cmark::Tag::Table(alignment) => { + stack.push(Scope::Table { + columns: Vec::with_capacity(alignment.len()), + alignment, + current: Vec::new(), + rows: Vec::new(), + }); + + None + } + pulldown_cmark::Tag::TableHead => { + strong = true; + None + } + pulldown_cmark::Tag::TableRow => { + let Scope::Table { rows, .. } = stack.last_mut()? else { + return None; + }; + + rows.push(Row { cells: Vec::new() }); None } _ => None, }, pulldown_cmark::Event::End(tag) => match tag { - pulldown_cmark::TagEnd::Heading(level) if !metadata && !table => { - produce( - state.borrow_mut(), - &mut stack, - Item::Heading(level, Text::new(spans.drain(..).collect())), - source, - ) - } - pulldown_cmark::TagEnd::Strong if !metadata && !table => { + pulldown_cmark::TagEnd::Heading(level) if !metadata => produce( + state.borrow_mut(), + &mut stack, + Item::Heading(level, Text::new(spans.drain(..).collect())), + source, + ), + pulldown_cmark::TagEnd::Strong if !metadata => { strong = false; None } - pulldown_cmark::TagEnd::Emphasis if !metadata && !table => { + pulldown_cmark::TagEnd::Emphasis if !metadata => { emphasis = false; None } - pulldown_cmark::TagEnd::Strikethrough if !metadata && !table => { + pulldown_cmark::TagEnd::Strikethrough if !metadata => { strikethrough = false; None } - pulldown_cmark::TagEnd::Link if !metadata && !table => { + pulldown_cmark::TagEnd::Link if !metadata => { link = None; None } - pulldown_cmark::TagEnd::Paragraph if !metadata && !table => { + pulldown_cmark::TagEnd::Paragraph if !metadata => { if spans.is_empty() { None } else { @@ -715,7 +767,7 @@ fn parse_with<'a>( ) } } - pulldown_cmark::TagEnd::Item if !metadata && !table => { + pulldown_cmark::TagEnd::Item if !metadata => { if spans.is_empty() { None } else { @@ -727,7 +779,7 @@ fn parse_with<'a>( ) } } - pulldown_cmark::TagEnd::List(_) if !metadata && !table => { + pulldown_cmark::TagEnd::List(_) if !metadata => { let scope = stack.pop()?; let Scope::List(list) = scope else { @@ -744,9 +796,7 @@ fn parse_with<'a>( source, ) } - pulldown_cmark::TagEnd::BlockQuote(_kind) - if !metadata && !table => - { + pulldown_cmark::TagEnd::BlockQuote(_kind) if !metadata => { let scope = stack.pop()?; let Scope::Quote(quote) = scope else { @@ -760,7 +810,7 @@ fn parse_with<'a>( source, ) } - pulldown_cmark::TagEnd::Image if !metadata && !table => { + pulldown_cmark::TagEnd::Image if !metadata => { let (url, title) = image.take()?; let alt = Text::new(spans.drain(..).collect()); @@ -774,7 +824,7 @@ fn parse_with<'a>( source, ) } - pulldown_cmark::TagEnd::CodeBlock if !metadata && !table => { + pulldown_cmark::TagEnd::CodeBlock if !metadata => { code_block = false; #[cfg(feature = "highlighter")] @@ -798,12 +848,60 @@ fn parse_with<'a>( None } pulldown_cmark::TagEnd::Table => { - table = false; + let scope = stack.pop()?; + + let Scope::Table { columns, rows, .. } = scope else { + return None; + }; + + produce( + state.borrow_mut(), + &mut stack, + Item::Table { columns, rows }, + source, + ) + } + pulldown_cmark::TagEnd::TableHead => { + strong = false; + None + } + pulldown_cmark::TagEnd::TableCell => { + if !spans.is_empty() { + let _ = produce( + state.borrow_mut(), + &mut stack, + Item::Paragraph(Text::new(spans.drain(..).collect())), + source, + ); + } + + let Scope::Table { + alignment, + columns, + rows, + current, + } = stack.last_mut()? + else { + return None; + }; + + if columns.len() < alignment.len() { + columns.push(Column { + header: std::mem::take(current), + alignment: alignment[columns.len()], + }); + } else { + rows.last_mut() + .expect("table row") + .cells + .push(std::mem::take(current)); + } + None } _ => None, }, - pulldown_cmark::Event::Text(text) if !metadata && !table => { + pulldown_cmark::Event::Text(text) if !metadata => { if code_block { code.push_str(&text); @@ -844,7 +942,7 @@ fn parse_with<'a>( None } - pulldown_cmark::Event::Code(code) if !metadata && !table => { + pulldown_cmark::Event::Code(code) if !metadata => { let span = Span::Standard { text: code.into_string(), strong, @@ -857,7 +955,7 @@ fn parse_with<'a>( spans.push(span); None } - pulldown_cmark::Event::SoftBreak if !metadata && !table => { + pulldown_cmark::Event::SoftBreak if !metadata => { spans.push(Span::Standard { text: String::from(" "), strikethrough, @@ -868,7 +966,7 @@ fn parse_with<'a>( }); None } - pulldown_cmark::Event::HardBreak if !metadata && !table => { + pulldown_cmark::Event::HardBreak if !metadata => { spans.push(Span::Standard { text: String::from("\n"), strikethrough, @@ -1113,6 +1211,7 @@ where } => viewer.ordered_list(settings, *start, items), Item::Quote(quote) => viewer.quote(settings, quote), Item::Rule => viewer.rule(settings), + Item::Table { columns, rows } => viewer.table(settings, columns, rows), } } @@ -1313,6 +1412,74 @@ where horizontal_rule(2).into() } +/// Displays a table using the default look. +pub fn table<'a, Message, Theme, Renderer>( + viewer: &impl Viewer<'a, Message, Theme, Renderer>, + settings: Settings, + columns: &'a [Column], + rows: &'a [Row], +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer + 'a, +{ + use crate::table; + + table( + columns.iter().enumerate().map(move |(i, column)| { + table::column( + items(viewer, settings, &column.header), + move |row: &Row| { + if let Some(cells) = row.cells.get(i) { + items(viewer, settings, cells) + } else { + text("").into() + } + }, + ) + .align_x(match column.alignment { + pulldown_cmark::Alignment::None + | pulldown_cmark::Alignment::Left => { + alignment::Horizontal::Left + } + pulldown_cmark::Alignment::Center => { + alignment::Horizontal::Center + } + pulldown_cmark::Alignment::Right => { + alignment::Horizontal::Right + } + }) + }), + rows, + ) + .padding_x(settings.spacing.0) + .padding_y(settings.spacing.0 / 2.0) + .separator_x(0) + .into() +} + +/// Displays a column of items with the default look. +pub fn items<'a, Message, Theme, Renderer>( + viewer: &impl Viewer<'a, Message, Theme, Renderer>, + settings: Settings, + items: &'a [Item], +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer + 'a, +{ + column( + items + .iter() + .enumerate() + .map(|(i, content)| item(viewer, settings, content, i)), + ) + .spacing(settings.spacing.0) + .into() +} + /// A view strategy to display a Markdown [`Item`]. pub trait Viewer<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> where @@ -1429,6 +1596,18 @@ where ) -> Element<'a, Message, Theme, Renderer> { rule() } + + /// Displays a table. + /// + /// By default, it calls [`table`]. + fn table( + &self, + settings: Settings, + columns: &'a [Column], + rows: &'a [Row], + ) -> Element<'a, Message, Theme, Renderer> { + table(self, settings, columns, rows) + } } #[derive(Debug, Clone, Copy)] @@ -1446,7 +1625,11 @@ where /// The theme catalog of Markdown items. pub trait Catalog: - container::Catalog + scrollable::Catalog + rule::Catalog + text::Catalog + container::Catalog + + scrollable::Catalog + + rule::Catalog + + text::Catalog + + crate::table::Catalog { /// The styling class of a Markdown code block. fn code_block<'a>() -> ::Class<'a>; diff --git a/widget/src/table.rs b/widget/src/table.rs index 32c94956..e03f31cf 100644 --- a/widget/src/table.rs +++ b/widget/src/table.rs @@ -10,8 +10,8 @@ use crate::core::{ Widget, }; -pub fn table<'a, T, Message, Theme, Renderer>( - columns: impl IntoIterator>, +pub fn table<'a, 'b, T, Message, Theme, Renderer>( + columns: impl IntoIterator>, rows: impl IntoIterator, ) -> Table<'a, Message, Theme, Renderer> where @@ -22,10 +22,10 @@ where Table::new(columns, rows) } -pub fn column<'a, T, E, Message, Theme, Renderer>( +pub fn column<'a, 'b, T, E, Message, Theme, Renderer>( header: impl Into>, - view: impl Fn(T) -> E + 'a, -) -> Column<'a, T, Message, Theme, Renderer> + view: impl Fn(T) -> E + 'b, +) -> Column<'a, 'b, T, Message, Theme, Renderer> where T: 'a, E: Into>, @@ -65,8 +65,10 @@ where Theme: Catalog, Renderer: core::Renderer, { - pub fn new( - columns: impl IntoIterator>, + pub fn new<'b, T>( + columns: impl IntoIterator< + Item = Column<'a, 'b, T, Message, Theme, Renderer>, + >, rows: impl IntoIterator, ) -> Self where @@ -122,7 +124,7 @@ where width, height, padding_x: 10.0, - padding_y: 10.0, + padding_y: 5.0, separator_x: 1.0, separator_y: 1.0, class: Theme::default(), @@ -526,19 +528,22 @@ where pub struct Column< 'a, + 'b, T, Message, Theme = crate::Theme, Renderer = crate::Renderer, > { header: Element<'a, Message, Theme, Renderer>, - view: Box Element<'a, Message, Theme, Renderer> + 'a>, + view: Box Element<'a, Message, Theme, Renderer> + 'b>, width: Length, align_x: alignment::Horizontal, align_y: alignment::Vertical, } -impl<'a, T, Message, Theme, Renderer> Column<'a, T, Message, Theme, Renderer> { +impl<'a, 'b, T, Message, Theme, Renderer> + Column<'a, 'b, T, Message, Theme, Renderer> +{ pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self