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::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<Item>),
/// A horizontal separator.
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.
@ -462,6 +492,12 @@ fn parse_with<'a>(
enum Scope {
List(List),
Quote(Vec<Item>),
Table {
alignment: Vec<pulldown_cmark::Alignment>,
columns: Vec<Column>,
rows: Vec<Row>,
current: Vec<Item>,
},
}
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<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`].
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>() -> <Self as container::Catalog>::Class<'a>;

View file

@ -10,8 +10,8 @@ use crate::core::{
Widget,
};
pub fn table<'a, T, Message, Theme, Renderer>(
columns: impl IntoIterator<Item = Column<'a, T, Message, Theme, Renderer>>,
pub fn table<'a, 'b, T, Message, Theme, Renderer>(
columns: impl IntoIterator<Item = Column<'a, 'b, T, Message, Theme, Renderer>>,
rows: impl IntoIterator<Item = T>,
) -> 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<Element<'a, Message, Theme, Renderer>>,
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<Element<'a, Message, Theme, Renderer>>,
@ -65,8 +65,10 @@ where
Theme: Catalog,
Renderer: core::Renderer,
{
pub fn new<T>(
columns: impl IntoIterator<Item = Column<'a, T, Message, Theme, Renderer>>,
pub fn new<'b, T>(
columns: impl IntoIterator<
Item = Column<'a, 'b, T, Message, Theme, Renderer>,
>,
rows: impl IntoIterator<Item = T>,
) -> 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<dyn Fn(T) -> Element<'a, Message, Theme, Renderer> + 'a>,
view: Box<dyn Fn(T) -> 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<Length>) -> Self {
self.width = width.into();
self