diff --git a/Cargo.lock b/Cargo.lock index 3f517be1..88e4ef2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5633,6 +5633,13 @@ dependencies = [ "iced", ] +[[package]] +name = "table" +version = "0.1.0" +dependencies = [ + "iced", +] + [[package]] name = "target-lexicon" version = "0.12.16" diff --git a/core/src/element.rs b/core/src/element.rs index 9d083d79..a3f60127 100644 --- a/core/src/element.rs +++ b/core/src/element.rs @@ -532,3 +532,49 @@ where ) } } + +impl<'a, T, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + T: Into, + Renderer: crate::Renderer, +{ + fn from(element: Option) -> Self { + struct Void; + + impl Widget for Void + where + Renderer: crate::Renderer, + { + fn size(&self) -> Size { + Size { + width: Length::Fixed(0.0), + height: Length::Fixed(0.0), + } + } + + fn layout( + &self, + _tree: &mut Tree, + _renderer: &Renderer, + _limits: &layout::Limits, + ) -> layout::Node { + layout::Node::new(Size::ZERO) + } + + fn draw( + &self, + _tree: &Tree, + _renderer: &mut Renderer, + _theme: &Theme, + _style: &renderer::Style, + _layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + } + } + + element.map(T::into).unwrap_or_else(|| Element::new(Void)) + } +} diff --git a/core/src/layout/node.rs b/core/src/layout/node.rs index 0c0f90fb..6fb5d395 100644 --- a/core/src/layout/node.rs +++ b/core/src/layout/node.rs @@ -52,22 +52,22 @@ impl Node { /// Aligns the [`Node`] in the given space. pub fn align( mut self, - horizontal_alignment: Alignment, - vertical_alignment: Alignment, + align_x: Alignment, + align_y: Alignment, space: Size, ) -> Self { - self.align_mut(horizontal_alignment, vertical_alignment, space); + self.align_mut(align_x, align_y, space); self } /// Mutable reference version of [`Self::align`]. pub fn align_mut( &mut self, - horizontal_alignment: Alignment, - vertical_alignment: Alignment, + align_x: Alignment, + align_y: Alignment, space: Size, ) { - match horizontal_alignment { + match align_x { Alignment::Start => {} Alignment::Center => { self.bounds.x += (space.width - self.bounds.width) / 2.0; @@ -77,7 +77,7 @@ impl Node { } } - match vertical_alignment { + match align_y { Alignment::Start => {} Alignment::Center => { self.bounds.y += (space.height - self.bounds.height) / 2.0; diff --git a/core/src/length.rs b/core/src/length.rs index 363833c4..073465ae 100644 --- a/core/src/length.rs +++ b/core/src/length.rs @@ -57,6 +57,7 @@ impl Length { /// Adapts the [`Length`] so it can contain the other [`Length`] and /// match its fluidity. + #[inline] pub fn enclose(self, other: Length) -> Self { match (self, other) { (Length::Shrink, Length::Fill | Length::FillPortion(_)) => other, diff --git a/core/src/size.rs b/core/src/size.rs index 95089236..9503cf59 100644 --- a/core/src/size.rs +++ b/core/src/size.rs @@ -1,4 +1,4 @@ -use crate::{Radians, Vector}; +use crate::{Length, Radians, Vector}; /// An amount of space in 2 dimensions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] @@ -66,6 +66,15 @@ impl Size { } } +impl Size { + /// Returns true if either `width` or `height` are 0-sized. + #[inline] + pub fn is_void(&self) -> bool { + matches!(self.width, Length::Fixed(0.0)) + || matches!(self.height, Length::Fixed(0.0)) + } +} + impl From<[T; 2]> for Size { fn from([width, height]: [T; 2]) -> Self { Size { width, height } diff --git a/core/src/widget/text.rs b/core/src/widget/text.rs index b5ed9bf0..972ab731 100644 --- a/core/src/widget/text.rs +++ b/core/src/widget/text.rs @@ -452,6 +452,13 @@ pub fn success(theme: &Theme) -> Style { } } +/// Text conveying some mildly negative information, like a warning. +pub fn warning(theme: &Theme) -> Style { + Style { + color: Some(theme.palette().warning), + } +} + /// Text conveying some negative information, like an error. pub fn danger(theme: &Theme) -> Style { Style { diff --git a/examples/pane_grid/src/main.rs b/examples/pane_grid/src/main.rs index c44fc1f1..2d2b92d9 100644 --- a/examples/pane_grid/src/main.rs +++ b/examples/pane_grid/src/main.rs @@ -276,13 +276,13 @@ fn view_content<'a>( button( "Split vertically", Message::Split(pane_grid::Axis::Vertical, pane), - ) + ), + if total_panes > 1 && !is_pinned { + Some(button("Close", Message::Close(pane)).style(button::danger)) + } else { + None + } ] - .push_maybe(if total_panes > 1 && !is_pinned { - Some(button("Close", Message::Close(pane)).style(button::danger)) - } else { - None - }) .spacing(5) .max_width(160); @@ -300,7 +300,7 @@ fn view_controls<'a>( is_pinned: bool, is_maximized: bool, ) -> Element<'a, Message> { - let row = row![].spacing(5).push_maybe(if total_panes > 1 { + let maximize = if total_panes > 1 { let (content, message) = if is_maximized { ("Restore", Message::Restore) } else { @@ -315,7 +315,7 @@ fn view_controls<'a>( ) } else { None - }); + }; let close = button(text("Close").size(14)) .style(button::danger) @@ -326,7 +326,7 @@ fn view_controls<'a>( None }); - row.push(close).into() + row![maximize, close].spacing(5).into() } mod style { diff --git a/examples/qr_code/src/main.rs b/examples/qr_code/src/main.rs index 20217615..6b6989d0 100644 --- a/examples/qr_code/src/main.rs +++ b/examples/qr_code/src/main.rs @@ -88,18 +88,18 @@ impl QRGenerator { input, row![toggle_total_size, choose_theme] .spacing(20) - .align_y(Center) + .align_y(Center), + self.total_size.map(|total_size| { + slider(Self::SIZE_RANGE, total_size, Message::TotalSizeChanged) + }), + self.qr_code.as_ref().map(|data| { + if let Some(total_size) = self.total_size { + qr_code(data).total_size(total_size) + } else { + qr_code(data).cell_size(10.0) + } + }) ] - .push_maybe(self.total_size.map(|total_size| { - slider(Self::SIZE_RANGE, total_size, Message::TotalSizeChanged) - })) - .push_maybe(self.qr_code.as_ref().map(|data| { - if let Some(total_size) = self.total_size { - qr_code(data).total_size(total_size) - } else { - qr_code(data).cell_size(10.0) - } - })) .width(700) .spacing(20) .align_x(Center); diff --git a/examples/screenshot/src/main.rs b/examples/screenshot/src/main.rs index 1f18e285..61285b72 100644 --- a/examples/screenshot/src/main.rs +++ b/examples/screenshot/src/main.rs @@ -158,15 +158,15 @@ impl Example { .spacing(10) .align_y(Center); - let crop_controls = - column![crop_origin_controls, crop_dimension_controls] - .push_maybe( - self.crop_error - .as_ref() - .map(|error| text!("Crop error! \n{error}")), - ) - .spacing(10) - .align_x(Center); + let crop_controls = column![ + crop_origin_controls, + crop_dimension_controls, + self.crop_error + .as_ref() + .map(|error| text!("Crop error! \n{error}")), + ] + .spacing(10) + .align_x(Center); let controls = { let save_result = @@ -208,8 +208,8 @@ impl Example { ] .spacing(10) .align_x(Center), + save_result.map(text) ] - .push_maybe(save_result.map(text)) .spacing(40) }; diff --git a/examples/table/Cargo.toml b/examples/table/Cargo.toml new file mode 100644 index 00000000..339c2891 --- /dev/null +++ b/examples/table/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "table" +version = "0.1.0" +authors = ["Héctor Ramón Jiménez "] +edition = "2024" +publish = false + +[dependencies] +iced.workspace = true +iced.features = ["debug"] diff --git a/examples/table/src/main.rs b/examples/table/src/main.rs new file mode 100644 index 00000000..26a8e621 --- /dev/null +++ b/examples/table/src/main.rs @@ -0,0 +1,252 @@ +use iced::font; +use iced::time::{Duration, hours, minutes}; +use iced::widget::{ + center_x, center_y, column, container, row, scrollable, slider, table, + text, tooltip, +}; +use iced::{Center, Element, Fill, Font, Right, Theme}; + +pub fn main() -> iced::Result { + iced::application(Table::new, Table::update, Table::view) + .theme(|_| Theme::CatppuccinMocha) + .run() +} + +struct Table { + events: Vec, + padding: (f32, f32), + separator: (f32, f32), +} + +#[derive(Debug, Clone)] +enum Message { + PaddingChanged(f32, f32), + SeparatorChanged(f32, f32), +} + +impl Table { + fn new() -> Self { + Self { + events: Event::list(), + padding: (10.0, 5.0), + separator: (1.0, 1.0), + } + } + + fn update(&mut self, message: Message) { + match message { + Message::PaddingChanged(x, y) => self.padding = (x, y), + Message::SeparatorChanged(x, y) => self.separator = (x, y), + } + } + + fn view(&self) -> Element<'_, Message> { + let table = { + let bold = |header| { + text(header).font(Font { + weight: font::Weight::Bold, + ..Font::DEFAULT + }) + }; + + let columns = [ + table::column(bold("Name"), |event: &Event| text(&event.name)), + table::column(bold("Time"), |event: &Event| { + let minutes = event.duration.as_secs() / 60; + + text!("{minutes} min").style(if minutes > 90 { + text::warning + } else { + text::default + }) + }) + .align_x(Right) + .align_y(Center), + table::column(bold("Price"), |event: &Event| { + if event.price > 0.0 { + text!("${:.2}", event.price).style( + if event.price > 100.0 { + text::warning + } else { + text::default + }, + ) + } else { + text("Free").style(text::success).width(Fill).center() + } + }) + .align_x(Right) + .align_y(Center), + table::column(bold("Rating"), |event: &Event| { + text!("{:.2}", event.rating).style(if event.rating > 4.7 { + text::success + } else if event.rating < 2.0 { + text::danger + } else { + text::default + }) + }) + .align_x(Right) + .align_y(Center), + ]; + + table(columns, &self.events) + .padding_x(self.padding.0) + .padding_y(self.padding.1) + .separator_x(self.separator.0) + .separator_y(self.separator.1) + }; + + let controls = { + let labeled_slider = + |label, + range: std::ops::RangeInclusive, + (x, y), + on_change: fn(f32, f32) -> Message| { + row![ + text(label).font(Font::MONOSPACE).size(14).width(100), + tooltip( + slider(range.clone(), x, move |x| on_change(x, y)), + text!("{x:.0}px").font(Font::MONOSPACE).size(10), + tooltip::Position::Left + ), + tooltip( + slider(range, y, move |y| on_change(x, y)), + text!("{y:.0}px").font(Font::MONOSPACE).size(10), + tooltip::Position::Right + ), + ] + .spacing(10) + .align_y(Center) + }; + + column![ + labeled_slider( + "Padding", + 0.0..=30.0, + self.padding, + Message::PaddingChanged + ), + labeled_slider( + "Separator", + 0.0..=5.0, + self.separator, + Message::SeparatorChanged + ) + ] + .spacing(10) + .width(400) + }; + + column![ + center_y(scrollable(center_x(table)).spacing(10)).padding(10), + center_x(controls).padding(10).style(container::dark) + ] + .into() + } +} + +struct Event { + name: String, + duration: Duration, + price: f32, + rating: f32, +} + +impl Event { + fn list() -> Vec { + vec![ + Event { + name: "Get lost in a hacker bookstore".to_owned(), + duration: hours(2), + price: 0.0, + rating: 4.9, + }, + Event { + name: "Buy vintage synth at Noisebridge flea market".to_owned(), + duration: hours(1), + price: 150.0, + rating: 4.8, + }, + Event { + name: "Eat a questionable hot dog at 2AM".to_owned(), + duration: minutes(20), + price: 5.0, + rating: 1.7, + }, + Event { + name: "Ride the MUNI for the story".to_owned(), + duration: minutes(60), + price: 3.0, + rating: 4.1, + }, + Event { + name: "Scream into the void from Twin Peaks".to_owned(), + duration: minutes(40), + price: 0.0, + rating: 4.9, + }, + Event { + name: "Buy overpriced coffee and feel things".to_owned(), + duration: minutes(25), + price: 6.5, + rating: 4.5, + }, + Event { + name: "Attend an underground robot poetry slam".to_owned(), + duration: hours(1), + price: 12.0, + rating: 4.8, + }, + Event { + name: "Browse cursed tech at a retro computer fair".to_owned(), + duration: hours(2), + price: 10.0, + rating: 4.7, + }, + Event { + name: "Try to order at a secret ramen place with no sign" + .to_owned(), + duration: minutes(50), + price: 14.0, + rating: 4.6, + }, + Event { + name: "Join a spontaneous rooftop drone rave".to_owned(), + duration: hours(3), + price: 0.0, + rating: 4.9, + }, + Event { + name: "Sketch a stranger at Dolores Park".to_owned(), + duration: minutes(45), + price: 0.0, + rating: 4.4, + }, + Event { + name: "Visit the Museum of Obsolete APIs".to_owned(), + duration: hours(1), + price: 9.99, + rating: 4.2, + }, + Event { + name: "Chase the last working payphone".to_owned(), + duration: minutes(35), + price: 0.25, + rating: 4.0, + }, + Event { + name: "Trade zines with a punk on BART".to_owned(), + duration: minutes(30), + price: 3.5, + rating: 4.7, + }, + Event { + name: "Get a tattoo of the Git logo".to_owned(), + duration: hours(1), + price: 200.0, + rating: 4.6, + }, + ] + } +} diff --git a/examples/tour/src/main.rs b/examples/tour/src/main.rs index 984cf272..5d009da6 100644 --- a/examples/tour/src/main.rs +++ b/examples/tour/src/main.rs @@ -142,17 +142,17 @@ impl Tour { } fn view(&self) -> Element<'_, Message> { - let controls = - row![] - .push_maybe(self.screen.previous().is_some().then(|| { - padded_button("Back") - .on_press(Message::BackPressed) - .style(button::secondary) - })) - .push(horizontal_space()) - .push_maybe(self.can_continue().then(|| { - padded_button("Next").on_press(Message::NextPressed) - })); + let controls = row![ + self.screen.previous().is_some().then(|| { + padded_button("Back") + .on_press(Message::BackPressed) + .style(button::secondary) + }), + horizontal_space(), + self.can_continue().then(|| { + padded_button("Next").on_press(Message::NextPressed) + }) + ]; let screen = match self.screen { Screen::Welcome => self.welcome(), diff --git a/widget/src/column.rs b/widget/src/column.rs index 4ab56c89..6c126048 100644 --- a/widget/src/column.rs +++ b/widget/src/column.rs @@ -145,23 +145,13 @@ where let child = child.into(); let child_size = child.as_widget().size_hint(); - self.width = self.width.enclose(child_size.width); - self.height = self.height.enclose(child_size.height); - - self.children.push(child); - self - } - - /// Adds an element to the [`Column`], if `Some`. - pub fn push_maybe( - self, - child: Option>>, - ) -> Self { - if let Some(child) = child { - self.push(child) - } else { - self + if !child_size.is_void() { + self.width = self.width.enclose(child_size.width); + self.height = self.height.enclose(child_size.height); + self.children.push(child); } + + self } /// Extends the [`Column`] with the given children. diff --git a/widget/src/helpers.rs b/widget/src/helpers.rs index 5e7b30d7..232cecd8 100644 --- a/widget/src/helpers.rs +++ b/widget/src/helpers.rs @@ -30,6 +30,8 @@ use crate::{Column, Grid, MouseArea, Pin, Pop, Row, Space, Stack, Themer}; use std::borrow::Borrow; use std::ops::RangeInclusive; +pub use crate::table::table; + /// Creates a [`Column`] with the given children. /// /// Columns distribute their children vertically. diff --git a/widget/src/lib.rs b/widget/src/lib.rs index f39f081a..7c144ce2 100644 --- a/widget/src/lib.rs +++ b/widget/src/lib.rs @@ -33,6 +33,7 @@ pub mod row; pub mod rule; pub mod scrollable; pub mod slider; +pub mod table; pub mod text; pub mod text_editor; pub mod text_input; 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/row.rs b/widget/src/row.rs index a51106d1..7bd8799f 100644 --- a/widget/src/row.rs +++ b/widget/src/row.rs @@ -136,23 +136,13 @@ where let child = child.into(); let child_size = child.as_widget().size_hint(); - self.width = self.width.enclose(child_size.width); - self.height = self.height.enclose(child_size.height); - - self.children.push(child); - self - } - - /// Adds an element to the [`Row`], if `Some`. - pub fn push_maybe( - self, - child: Option>>, - ) -> Self { - if let Some(child) = child { - self.push(child) - } else { - self + if !child_size.is_void() { + self.width = self.width.enclose(child_size.width); + self.height = self.height.enclose(child_size.height); + self.children.push(child); } + + self } /// Extends the [`Row`] with the given children. diff --git a/widget/src/table.rs b/widget/src/table.rs new file mode 100644 index 00000000..373f93cc --- /dev/null +++ b/widget/src/table.rs @@ -0,0 +1,646 @@ +//! Display tables. +use crate::core; +use crate::core::alignment; +use crate::core::layout; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::widget; +use crate::core::{ + Alignment, Background, Element, Layout, Length, Pixels, Rectangle, Size, + Widget, +}; + +/// Creates a new [`Table`] with the given columns and rows. +/// +/// Columns can be created using the [`column()`] function, while rows can be any +/// iterator over some data type `T`. +pub fn table<'a, 'b, T, Message, Theme, Renderer>( + columns: impl IntoIterator>, + rows: impl IntoIterator, +) -> Table<'a, Message, Theme, Renderer> +where + T: Clone, + Theme: Catalog, + Renderer: core::Renderer, +{ + Table::new(columns, rows) +} + +/// Creates a new [`Column`] with the given header and view function. +/// +/// The view function will be called for each row in a [`Table`] and it must +/// produce the resulting contents of a cell. +pub fn column<'a, 'b, T, E, Message, Theme, Renderer>( + header: impl Into>, + view: impl Fn(T) -> E + 'b, +) -> Column<'a, 'b, T, Message, Theme, Renderer> +where + T: 'a, + E: Into>, +{ + Column { + header: header.into(), + view: Box::new(move |data| view(data).into()), + width: Length::Shrink, + align_x: alignment::Horizontal::Left, + align_y: alignment::Vertical::Top, + } +} + +/// A grid-like visual representation of data distributed in columns and rows. +#[allow(missing_debug_implementations)] +pub struct Table<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> +where + Theme: Catalog, +{ + columns: Vec, + cells: Vec>, + width: Length, + height: Length, + padding_x: f32, + padding_y: f32, + separator_x: f32, + separator_y: f32, + class: Theme::Class<'a>, +} + +struct Column_ { + width: Length, + align_x: alignment::Horizontal, + align_y: alignment::Vertical, +} + +impl<'a, Message, Theme, Renderer> Table<'a, Message, Theme, Renderer> +where + Theme: Catalog, + Renderer: core::Renderer, +{ + /// Creates a new [`Table`] with the given columns and rows. + /// + /// Columns can be created using the [`column()`] function, while rows can be any + /// iterator over some data type `T`. + pub fn new<'b, T>( + columns: impl IntoIterator< + Item = Column<'a, 'b, T, Message, Theme, Renderer>, + >, + rows: impl IntoIterator, + ) -> Self + where + T: Clone, + { + let columns = columns.into_iter(); + let rows = rows.into_iter(); + + let mut width = Length::Shrink; + let mut height = Length::Shrink; + + let mut cells = Vec::with_capacity( + columns.size_hint().0 * (1 + rows.size_hint().0), + ); + + let (mut columns, views): (Vec<_>, Vec<_>) = columns + .map(|column| { + width = width.enclose(column.width); + + cells.push(column.header); + + ( + Column_ { + width: column.width, + align_x: column.align_x, + align_y: column.align_y, + }, + column.view, + ) + }) + .collect(); + + for row in rows { + for view in &views { + let cell = view(row.clone()); + let size_hint = cell.as_widget().size_hint(); + + height = height.enclose(size_hint.height); + + cells.push(cell); + } + } + + if width == Length::Shrink { + if let Some(first) = columns.first_mut() { + first.width = Length::Fill; + } + } + + Self { + columns, + cells, + width, + height, + padding_x: 10.0, + padding_y: 5.0, + separator_x: 1.0, + separator_y: 1.0, + class: Theme::default(), + } + } + + /// Sets the width of the [`Table`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the padding of the cells of the [`Table`]. + pub fn padding(self, padding: impl Into) -> Self { + let padding = padding.into(); + + self.padding_x(padding).padding_y(padding) + } + + /// Sets the horizontal padding of the cells of the [`Table`]. + pub fn padding_x(mut self, padding: impl Into) -> Self { + self.padding_x = padding.into().0; + self + } + + /// Sets the vertical padding of the cells of the [`Table`]. + pub fn padding_y(mut self, padding: impl Into) -> Self { + self.padding_y = padding.into().0; + self + } + + /// Sets the thickness of the line separator between the cells of the [`Table`]. + pub fn separator(self, separator: impl Into) -> Self { + let separator = separator.into(); + + self.separator_x(separator).separator_y(separator) + } + + /// Sets the thickness of the horizontal line separator between the cells of the [`Table`]. + pub fn separator_x(mut self, separator: impl Into) -> Self { + self.separator_x = separator.into().0; + self + } + + /// Sets the thickness of the vertical line separator between the cells of the [`Table`]. + pub fn separator_y(mut self, separator: impl Into) -> Self { + self.separator_y = separator.into().0; + self + } +} + +struct Metrics { + columns: Vec, + rows: Vec, +} + +impl<'a, Message, Theme, Renderer> Widget + for Table<'a, Message, Theme, Renderer> +where + Theme: Catalog, + Renderer: core::Renderer, +{ + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(Metrics { + columns: Vec::new(), + rows: Vec::new(), + }) + } + + fn children(&self) -> Vec { + self.cells + .iter() + .map(|cell| widget::Tree::new(cell.as_widget())) + .collect() + } + + fn diff(&self, state: &mut widget::Tree) { + state.diff_children(&self.cells); + } + + fn layout( + &self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let metrics = tree.state.downcast_mut::(); + let columns = self.columns.len(); + let rows = self.cells.len() / columns; + + let limits = limits.width(self.width).height(self.height); + let available = limits.max(); + let table_fluid = self.width.fluid(); + + let mut cells = Vec::with_capacity(self.cells.len()); + cells.resize(self.cells.len(), layout::Node::default()); + + metrics.columns = vec![0.0; self.columns.len()]; + metrics.rows = vec![0.0; rows]; + + let mut column_factors = vec![0; self.columns.len()]; + let mut total_row_factors = 0; + let mut total_fluid_height = 0.0; + let mut row_factor = 0; + + let spacing_x = self.padding_x * 2.0 + self.separator_x; + let spacing_y = self.padding_y * 2.0 + self.separator_y; + + // FIRST PASS + // Lay out non-fluid cells + let mut x = self.padding_x; + let mut y = self.padding_y; + + for (i, (cell, state)) in + self.cells.iter().zip(&mut tree.children).enumerate() + { + let row = i / columns; + let column = i % columns; + + let width = self.columns[column].width; + let size = cell.as_widget().size(); + + if column == 0 { + x = self.padding_x; + + if row > 0 { + y += metrics.rows[row - 1] + spacing_y; + + if row_factor != 0 { + total_fluid_height += metrics.rows[row - 1]; + total_row_factors += row_factor; + + row_factor = 0; + } + } + } + + let width_factor = width.fill_factor(); + let height_factor = size.height.fill_factor(); + + if width_factor != 0 || height_factor != 0 || size.width.is_fill() { + column_factors[column] = + column_factors[column].max(width_factor); + + row_factor = row_factor.max(height_factor); + + continue; + } + + let limits = layout::Limits::new( + Size::ZERO, + Size::new(available.width - x, available.height - y), + ) + .width(width); + + let layout = cell.as_widget().layout(state, renderer, &limits); + let size = limits.resolve(width, Length::Shrink, layout.size()); + + metrics.columns[column] = metrics.columns[column].max(size.width); + metrics.rows[row] = metrics.rows[row].max(size.height); + cells[i] = layout; + + x += size.width + spacing_x; + } + + // SECOND PASS + // Lay out fluid cells, using metrics from the first pass as limits + let left = Size::new( + available.width + - metrics + .columns + .iter() + .enumerate() + .filter(|(i, _)| column_factors[*i] == 0) + .map(|(_, width)| width) + .sum::(), + available.height - total_fluid_height, + ); + + let width_unit = (left.width + - spacing_x * self.columns.len().saturating_sub(1) as f32 + - self.padding_x * 2.0) + / column_factors.iter().sum::() as f32; + + let height_unit = (left.height + - spacing_y * rows.saturating_sub(1) as f32 + - self.padding_y * 2.0) + / total_row_factors as f32; + + let mut x = self.padding_x; + let mut y = self.padding_y; + + for (i, (cell, state)) in + self.cells.iter().zip(&mut tree.children).enumerate() + { + let row = i / columns; + let column = i % columns; + + let size = cell.as_widget().size(); + + let width = self.columns[column].width; + let width_factor = width.fill_factor(); + let height_factor = size.height.fill_factor(); + + if column == 0 { + x = self.padding_x; + + if row > 0 { + y += metrics.rows[row - 1] + spacing_y; + } + } + + if width_factor == 0 + && size.width.fill_factor() == 0 + && size.height.fill_factor() == 0 + { + continue; + } + + let max_width = if width_factor == 0 { + if size.width.is_fill() { + metrics.columns[column] + } else { + (available.width - x).max(0.0) + } + } else { + width_unit * width_factor as f32 + }; + + let max_height = if height_factor == 0 { + if size.height.is_fill() { + metrics.rows[row] + } else { + (available.height - y).max(0.0) + } + } else { + height_unit * height_factor as f32 + }; + + let limits = layout::Limits::new( + Size::ZERO, + Size::new(max_width, max_height), + ) + .width(width); + + let layout = cell.as_widget().layout(state, renderer, &limits); + let size = limits.resolve( + if let Length::Fixed(_) = width { + width + } else { + table_fluid + }, + Length::Shrink, + layout.size(), + ); + + metrics.columns[column] = metrics.columns[column].max(size.width); + metrics.rows[row] = metrics.rows[row].max(size.height); + cells[i] = layout; + + x += size.width + spacing_x; + } + + // THIRD PASS + // Position each cell + let mut x = self.padding_x; + let mut y = self.padding_y; + + for (i, cell) in cells.iter_mut().enumerate() { + let row = i / columns; + let column = i % columns; + + if column == 0 { + x = self.padding_x; + + if row > 0 { + y += metrics.rows[row - 1] + spacing_y; + } + } + + let Column_ { + align_x, align_y, .. + } = &self.columns[column]; + + cell.move_to_mut((x, y)); + cell.align_mut( + Alignment::from(*align_x), + Alignment::from(*align_y), + Size::new(metrics.columns[column], metrics.rows[row]), + ); + + x += metrics.columns[column] + spacing_x; + } + + let intrinsic = limits.resolve( + self.width, + self.height, + Size::new( + x - spacing_x + self.padding_x, + y + metrics + .rows + .last() + .copied() + .map(|height| height + self.padding_y) + .unwrap_or_default(), + ), + ); + + layout::Node::with_children(intrinsic, cells) + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + for ((cell, state), layout) in + self.cells.iter().zip(&tree.children).zip(layout.children()) + { + cell.as_widget() + .draw(state, renderer, theme, style, layout, cursor, viewport); + } + + let bounds = layout.bounds(); + let metrics = tree.state.downcast_ref::(); + let style = theme.style(&self.class); + + if self.separator_x > 0.0 { + let mut x = self.padding_x; + + for width in + &metrics.columns[..metrics.columns.len().saturating_sub(1)] + { + x += width + self.padding_x; + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x + x, + y: bounds.y, + width: self.separator_x, + height: bounds.height, + }, + snap: true, + ..renderer::Quad::default() + }, + style.separator_x, + ); + + x += self.separator_x + self.padding_x; + } + } + + if self.separator_y > 0.0 { + let mut y = self.padding_y; + + for height in &metrics.rows[..metrics.rows.len().saturating_sub(1)] + { + y += height + self.padding_y; + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x, + y: bounds.y + y, + width: bounds.width, + height: self.separator_y, + }, + snap: true, + ..renderer::Quad::default() + }, + style.separator_y, + ); + + y += self.separator_y + self.padding_y; + } + } + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::Renderer + 'a, +{ + fn from(table: Table<'a, Message, Theme, Renderer>) -> Self { + Element::new(table) + } +} + +/// A vertical visualization of some data with a header. +#[allow(missing_debug_implementations)] +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> + 'b>, + width: Length, + align_x: alignment::Horizontal, + align_y: alignment::Vertical, +} + +impl<'a, 'b, T, Message, Theme, Renderer> + Column<'a, 'b, T, Message, Theme, Renderer> +{ + /// Sets the width of the [`Column`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the alignment for the horizontal axis of the [`Column`]. + pub fn align_x( + mut self, + alignment: impl Into, + ) -> Self { + self.align_x = alignment.into(); + self + } + + /// Sets the alignment for the vertical axis of the [`Column`]. + pub fn align_y( + mut self, + alignment: impl Into, + ) -> Self { + self.align_y = alignment.into(); + self + } +} + +/// The appearance of a [`Table`]. +#[derive(Debug, Clone, Copy)] +pub struct Style { + /// The background color of the horizontal line separator between cells. + pub separator_x: Background, + /// The background color of the vertical line separator between cells. + pub separator_y: Background, +} + +/// The theme catalog of a [`Table`]. +pub trait Catalog { + /// The item class of the [`Catalog`]. + type Class<'a>; + + /// The default class produced by the [`Catalog`]. + fn default<'a>() -> Self::Class<'a>; + + /// The [`Style`] of a class with the given status. + fn style(&self, class: &Self::Class<'_>) -> Style; +} + +/// A styling function for a [`Table`]. +pub type StyleFn<'a, Theme> = Box Style + 'a>; + +impl From