Merge pull request #3018 from iced-rs/feature/table-widget

`table` widget
This commit is contained in:
Héctor 2025-07-18 21:38:16 +02:00 committed by GitHub
commit 71b0b6ae07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1267 additions and 123 deletions

7
Cargo.lock generated
View file

@ -5633,6 +5633,13 @@ dependencies = [
"iced",
]
[[package]]
name = "table"
version = "0.1.0"
dependencies = [
"iced",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"

View file

@ -532,3 +532,49 @@ where
)
}
}
impl<'a, T, Message, Theme, Renderer> From<Option<T>>
for Element<'a, Message, Theme, Renderer>
where
T: Into<Self>,
Renderer: crate::Renderer,
{
fn from(element: Option<T>) -> Self {
struct Void;
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer> for Void
where
Renderer: crate::Renderer,
{
fn size(&self) -> Size<Length> {
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))
}
}

View file

@ -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;

View file

@ -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,

View file

@ -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<Length> {
/// 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<T> From<[T; 2]> for Size<T> {
fn from([width, height]: [T; 2]) -> Self {
Size { width, height }

View file

@ -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 {

View file

@ -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 {

View file

@ -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);

View file

@ -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)
};

10
examples/table/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "table"
version = "0.1.0"
authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"]
edition = "2024"
publish = false
[dependencies]
iced.workspace = true
iced.features = ["debug"]

252
examples/table/src/main.rs Normal file
View file

@ -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<Event>,
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<f32>,
(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<Self> {
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,
},
]
}
}

View file

@ -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(),

View file

@ -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<impl Into<Element<'a, Message, Theme, Renderer>>>,
) -> 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.

View file

@ -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.

View file

@ -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;

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

@ -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<impl Into<Element<'a, Message, Theme, Renderer>>>,
) -> 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.

646
widget/src/table.rs Normal file
View file

@ -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<Item = Column<'a, 'b, T, Message, Theme, Renderer>>,
rows: impl IntoIterator<Item = T>,
) -> 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<Element<'a, 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>>,
{
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<Column_>,
cells: Vec<Element<'a, Message, Theme, Renderer>>,
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<Item = T>,
) -> 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<Length>) -> Self {
self.width = width.into();
self
}
/// Sets the padding of the cells of the [`Table`].
pub fn padding(self, padding: impl Into<Pixels>) -> 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<Pixels>) -> 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<Pixels>) -> 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<Pixels>) -> 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<Pixels>) -> 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<Pixels>) -> Self {
self.separator_y = separator.into().0;
self
}
}
struct Metrics {
columns: Vec<f32>,
rows: Vec<f32>,
}
impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Table<'a, Message, Theme, Renderer>
where
Theme: Catalog,
Renderer: core::Renderer,
{
fn size(&self) -> Size<Length> {
Size {
width: self.width,
height: self.height,
}
}
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<Metrics>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(Metrics {
columns: Vec::new(),
rows: Vec::new(),
})
}
fn children(&self) -> Vec<widget::Tree> {
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::<Metrics>();
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::<f32>(),
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::<u16>() 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::<Metrics>();
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<Table<'a, Message, Theme, Renderer>>
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<dyn Fn(T) -> 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<Length>) -> 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<alignment::Horizontal>,
) -> 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<alignment::Vertical>,
) -> 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<dyn Fn(&Theme) -> Style + 'a>;
impl<Theme> From<Style> for StyleFn<'_, Theme> {
fn from(style: Style) -> Self {
Box::new(move |_theme| style)
}
}
impl Catalog for crate::Theme {
type Class<'a> = StyleFn<'a, Self>;
fn default<'a>() -> Self::Class<'a> {
Box::new(default)
}
fn style(&self, class: &Self::Class<'_>) -> Style {
class(self)
}
}
/// The default style of a [`Table`].
pub fn default(theme: &crate::Theme) -> Style {
let palette = theme.extended_palette();
let separator = palette.background.strong.color.into();
Style {
separator_x: separator,
separator_y: separator,
}
}