Add table support to markdown widget

This commit is contained in:
Héctor Ramón Jiménez 2025-07-17 03:48:45 +02:00
parent 291e615c0b
commit dcea10f707
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
2 changed files with 240 additions and 52 deletions

View file

@ -43,6 +43,7 @@
//! } //! }
//! } //! }
//! ``` //! ```
use crate::core::alignment;
use crate::core::border; use crate::core::border;
use crate::core::font::{self, Font}; use crate::core::font::{self, Font};
use crate::core::padding; use crate::core::padding;
@ -107,13 +108,17 @@ impl Content {
let mut leftover = std::mem::take(&mut self.state.leftover); let mut leftover = std::mem::take(&mut self.state.leftover);
leftover.push_str(markdown); 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 // Pop the last item
let _ = self.items.pop(); let _ = self.items.pop();
// Re-parse last item and new text // Re-parse last item and new text
for (item, source, broken_links) in for (item, source, broken_links) in parse_with(&mut self.state, input) {
parse_with(&mut self.state, &leftover)
{
if !broken_links.is_empty() { if !broken_links.is_empty() {
let _ = self.incomplete.insert( let _ = self.incomplete.insert(
self.items.len(), self.items.len(),
@ -127,6 +132,8 @@ impl Content {
self.items.push(item); self.items.push(item);
} }
self.state.leftover.push_str(&leftover[input.len()..]);
// Re-parse incomplete sections if new references are available // Re-parse incomplete sections if new references are available
if !self.incomplete.is_empty() { if !self.incomplete.is_empty() {
self.incomplete.retain(|index, section| { self.incomplete.retain(|index, section| {
@ -215,6 +222,29 @@ pub enum Item {
Quote(Vec<Item>), Quote(Vec<Item>),
/// A horizontal separator. /// A horizontal separator.
Rule, Rule,
/// A table.
Table {
/// The columns of the table.
columns: Vec<Column>,
/// The rows of the table.
rows: Vec<Row>,
},
}
/// The column of a table.
#[derive(Debug, Clone)]
pub struct Column {
/// The header of the column.
pub header: Vec<Item>,
/// 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<Vec<Item>>,
} }
/// A bunch of parsed Markdown text. /// A bunch of parsed Markdown text.
@ -462,6 +492,12 @@ fn parse_with<'a>(
enum Scope { enum Scope {
List(List), List(List),
Quote(Vec<Item>), Quote(Vec<Item>),
Table {
alignment: Vec<pulldown_cmark::Alignment>,
columns: Vec<Column>,
rows: Vec<Row>,
current: Vec<Item>,
},
} }
struct List { struct List {
@ -479,7 +515,6 @@ fn parse_with<'a>(
let mut emphasis = false; let mut emphasis = false;
let mut strikethrough = false; let mut strikethrough = false;
let mut metadata = false; let mut metadata = false;
let mut table = false;
let mut code_block = false; let mut code_block = false;
let mut link = None; let mut link = None;
let mut image = None; let mut image = None;
@ -535,6 +570,9 @@ fn parse_with<'a>(
Scope::Quote(items) => { Scope::Quote(items) => {
items.push(item); items.push(item);
} }
Scope::Table { current, .. } => {
current.push(item);
}
} }
None None
@ -555,21 +593,19 @@ fn parse_with<'a>(
#[allow(clippy::drain_collect)] #[allow(clippy::drain_collect)]
parser.filter_map(move |(event, source)| match event { parser.filter_map(move |(event, source)| match event {
pulldown_cmark::Event::Start(tag) => match tag { pulldown_cmark::Event::Start(tag) => match tag {
pulldown_cmark::Tag::Strong if !metadata && !table => { pulldown_cmark::Tag::Strong if !metadata => {
strong = true; strong = true;
None None
} }
pulldown_cmark::Tag::Emphasis if !metadata && !table => { pulldown_cmark::Tag::Emphasis if !metadata => {
emphasis = true; emphasis = true;
None None
} }
pulldown_cmark::Tag::Strikethrough if !metadata && !table => { pulldown_cmark::Tag::Strikethrough if !metadata => {
strikethrough = true; strikethrough = true;
None None
} }
pulldown_cmark::Tag::Link { dest_url, .. } pulldown_cmark::Tag::Link { dest_url, .. } if !metadata => {
if !metadata && !table =>
{
match Url::parse(&dest_url) { match Url::parse(&dest_url) {
Ok(url) Ok(url)
if url.scheme() == "http" if url.scheme() == "http"
@ -584,13 +620,13 @@ fn parse_with<'a>(
} }
pulldown_cmark::Tag::Image { pulldown_cmark::Tag::Image {
dest_url, title, .. dest_url, title, ..
} if !metadata && !table => { } if !metadata => {
image = Url::parse(&dest_url) image = Url::parse(&dest_url)
.ok() .ok()
.map(|url| (url, title.into_string())); .map(|url| (url, title.into_string()));
None None
} }
pulldown_cmark::Tag::List(first_item) if !metadata && !table => { pulldown_cmark::Tag::List(first_item) if !metadata => {
let prev = if spans.is_empty() { let prev = if spans.is_empty() {
None None
} else { } else {
@ -616,7 +652,7 @@ fn parse_with<'a>(
None None
} }
pulldown_cmark::Tag::BlockQuote(_kind) if !metadata && !table => { pulldown_cmark::Tag::BlockQuote(_kind) if !metadata => {
let prev = if spans.is_empty() { let prev = if spans.is_empty() {
None None
} else { } else {
@ -634,7 +670,7 @@ fn parse_with<'a>(
} }
pulldown_cmark::Tag::CodeBlock( pulldown_cmark::Tag::CodeBlock(
pulldown_cmark::CodeBlockKind::Fenced(language), pulldown_cmark::CodeBlockKind::Fenced(language),
) if !metadata && !table => { ) if !metadata => {
#[cfg(feature = "highlighter")] #[cfg(feature = "highlighter")]
{ {
highlighter = Some({ highlighter = Some({
@ -672,38 +708,54 @@ fn parse_with<'a>(
metadata = true; metadata = true;
None None
} }
pulldown_cmark::Tag::Table(_) => { pulldown_cmark::Tag::Table(alignment) => {
table = true; 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
} }
_ => None, _ => None,
}, },
pulldown_cmark::Event::End(tag) => match tag { pulldown_cmark::Event::End(tag) => match tag {
pulldown_cmark::TagEnd::Heading(level) if !metadata && !table => { pulldown_cmark::TagEnd::Heading(level) if !metadata => produce(
produce( state.borrow_mut(),
state.borrow_mut(), &mut stack,
&mut stack, Item::Heading(level, Text::new(spans.drain(..).collect())),
Item::Heading(level, Text::new(spans.drain(..).collect())), source,
source, ),
) pulldown_cmark::TagEnd::Strong if !metadata => {
}
pulldown_cmark::TagEnd::Strong if !metadata && !table => {
strong = false; strong = false;
None None
} }
pulldown_cmark::TagEnd::Emphasis if !metadata && !table => { pulldown_cmark::TagEnd::Emphasis if !metadata => {
emphasis = false; emphasis = false;
None None
} }
pulldown_cmark::TagEnd::Strikethrough if !metadata && !table => { pulldown_cmark::TagEnd::Strikethrough if !metadata => {
strikethrough = false; strikethrough = false;
None None
} }
pulldown_cmark::TagEnd::Link if !metadata && !table => { pulldown_cmark::TagEnd::Link if !metadata => {
link = None; link = None;
None None
} }
pulldown_cmark::TagEnd::Paragraph if !metadata && !table => { pulldown_cmark::TagEnd::Paragraph if !metadata => {
if spans.is_empty() { if spans.is_empty() {
None None
} else { } 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() { if spans.is_empty() {
None None
} else { } 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 = stack.pop()?;
let Scope::List(list) = scope else { let Scope::List(list) = scope else {
@ -744,9 +796,7 @@ fn parse_with<'a>(
source, source,
) )
} }
pulldown_cmark::TagEnd::BlockQuote(_kind) pulldown_cmark::TagEnd::BlockQuote(_kind) if !metadata => {
if !metadata && !table =>
{
let scope = stack.pop()?; let scope = stack.pop()?;
let Scope::Quote(quote) = scope else { let Scope::Quote(quote) = scope else {
@ -760,7 +810,7 @@ fn parse_with<'a>(
source, source,
) )
} }
pulldown_cmark::TagEnd::Image if !metadata && !table => { pulldown_cmark::TagEnd::Image if !metadata => {
let (url, title) = image.take()?; let (url, title) = image.take()?;
let alt = Text::new(spans.drain(..).collect()); let alt = Text::new(spans.drain(..).collect());
@ -774,7 +824,7 @@ fn parse_with<'a>(
source, source,
) )
} }
pulldown_cmark::TagEnd::CodeBlock if !metadata && !table => { pulldown_cmark::TagEnd::CodeBlock if !metadata => {
code_block = false; code_block = false;
#[cfg(feature = "highlighter")] #[cfg(feature = "highlighter")]
@ -798,12 +848,60 @@ fn parse_with<'a>(
None None
} }
pulldown_cmark::TagEnd::Table => { 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
} }
_ => None, _ => None,
}, },
pulldown_cmark::Event::Text(text) if !metadata && !table => { pulldown_cmark::Event::Text(text) if !metadata => {
if code_block { if code_block {
code.push_str(&text); code.push_str(&text);
@ -844,7 +942,7 @@ fn parse_with<'a>(
None None
} }
pulldown_cmark::Event::Code(code) if !metadata && !table => { pulldown_cmark::Event::Code(code) if !metadata => {
let span = Span::Standard { let span = Span::Standard {
text: code.into_string(), text: code.into_string(),
strong, strong,
@ -857,7 +955,7 @@ fn parse_with<'a>(
spans.push(span); spans.push(span);
None None
} }
pulldown_cmark::Event::SoftBreak if !metadata && !table => { pulldown_cmark::Event::SoftBreak if !metadata => {
spans.push(Span::Standard { spans.push(Span::Standard {
text: String::from(" "), text: String::from(" "),
strikethrough, strikethrough,
@ -868,7 +966,7 @@ fn parse_with<'a>(
}); });
None None
} }
pulldown_cmark::Event::HardBreak if !metadata && !table => { pulldown_cmark::Event::HardBreak if !metadata => {
spans.push(Span::Standard { spans.push(Span::Standard {
text: String::from("\n"), text: String::from("\n"),
strikethrough, strikethrough,
@ -1113,6 +1211,7 @@ where
} => viewer.ordered_list(settings, *start, items), } => viewer.ordered_list(settings, *start, items),
Item::Quote(quote) => viewer.quote(settings, quote), Item::Quote(quote) => viewer.quote(settings, quote),
Item::Rule => viewer.rule(settings), Item::Rule => viewer.rule(settings),
Item::Table { columns, rows } => viewer.table(settings, columns, rows),
} }
} }
@ -1313,6 +1412,74 @@ where
horizontal_rule(2).into() 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<Font = Font> + '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<Font = Font> + '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`]. /// A view strategy to display a Markdown [`Item`].
pub trait Viewer<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> pub trait Viewer<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
where where
@ -1429,6 +1596,18 @@ where
) -> Element<'a, Message, Theme, Renderer> { ) -> Element<'a, Message, Theme, Renderer> {
rule() 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)] #[derive(Debug, Clone, Copy)]
@ -1446,7 +1625,11 @@ where
/// The theme catalog of Markdown items. /// The theme catalog of Markdown items.
pub trait Catalog: 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. /// The styling class of a Markdown code block.
fn code_block<'a>() -> <Self as container::Catalog>::Class<'a>; fn code_block<'a>() -> <Self as container::Catalog>::Class<'a>;

View file

@ -10,8 +10,8 @@ use crate::core::{
Widget, Widget,
}; };
pub fn table<'a, T, Message, Theme, Renderer>( pub fn table<'a, 'b, T, Message, Theme, Renderer>(
columns: impl IntoIterator<Item = Column<'a, T, Message, Theme, Renderer>>, columns: impl IntoIterator<Item = Column<'a, 'b, T, Message, Theme, Renderer>>,
rows: impl IntoIterator<Item = T>, rows: impl IntoIterator<Item = T>,
) -> Table<'a, Message, Theme, Renderer> ) -> Table<'a, Message, Theme, Renderer>
where where
@ -22,10 +22,10 @@ where
Table::new(columns, rows) 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<Element<'a, Message, Theme, Renderer>>, header: impl Into<Element<'a, Message, Theme, Renderer>>,
view: impl Fn(T) -> E + 'a, view: impl Fn(T) -> E + 'b,
) -> Column<'a, T, Message, Theme, Renderer> ) -> Column<'a, 'b, T, Message, Theme, Renderer>
where where
T: 'a, T: 'a,
E: Into<Element<'a, Message, Theme, Renderer>>, E: Into<Element<'a, Message, Theme, Renderer>>,
@ -65,8 +65,10 @@ where
Theme: Catalog, Theme: Catalog,
Renderer: core::Renderer, Renderer: core::Renderer,
{ {
pub fn new<T>( pub fn new<'b, T>(
columns: impl IntoIterator<Item = Column<'a, T, Message, Theme, Renderer>>, columns: impl IntoIterator<
Item = Column<'a, 'b, T, Message, Theme, Renderer>,
>,
rows: impl IntoIterator<Item = T>, rows: impl IntoIterator<Item = T>,
) -> Self ) -> Self
where where
@ -122,7 +124,7 @@ where
width, width,
height, height,
padding_x: 10.0, padding_x: 10.0,
padding_y: 10.0, padding_y: 5.0,
separator_x: 1.0, separator_x: 1.0,
separator_y: 1.0, separator_y: 1.0,
class: Theme::default(), class: Theme::default(),
@ -526,19 +528,22 @@ where
pub struct Column< pub struct Column<
'a, 'a,
'b,
T, T,
Message, Message,
Theme = crate::Theme, Theme = crate::Theme,
Renderer = crate::Renderer, Renderer = crate::Renderer,
> { > {
header: Element<'a, Message, Theme, Renderer>, header: Element<'a, Message, Theme, Renderer>,
view: Box<dyn Fn(T) -> Element<'a, Message, Theme, Renderer> + 'a>, view: Box<dyn Fn(T) -> Element<'a, Message, Theme, Renderer> + 'b>,
width: Length, width: Length,
align_x: alignment::Horizontal, align_x: alignment::Horizontal,
align_y: alignment::Vertical, 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<Length>) -> Self { pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into(); self.width = width.into();
self self