From b3ce0f23a59a336308365b9f0dd883d011005c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Tue, 15 Jul 2025 05:28:33 +0200 Subject: [PATCH 01/43] Draft `table` widget --- Cargo.lock | 7 + examples/table/Cargo.toml | 10 + examples/table/src/main.rs | 155 ++++++++++ widget/src/helpers.rs | 2 + widget/src/lib.rs | 1 + widget/src/table.rs | 563 +++++++++++++++++++++++++++++++++++++ 6 files changed, 738 insertions(+) create mode 100644 examples/table/Cargo.toml create mode 100644 examples/table/src/main.rs create mode 100644 widget/src/table.rs 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/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..c05208f9 --- /dev/null +++ b/examples/table/src/main.rs @@ -0,0 +1,155 @@ +use iced::font; +use iced::time::{Duration, hours, minutes}; +use iced::widget::{center, scrollable, table, text}; +use iced::{Element, Fill, Font}; + +pub fn main() -> iced::Result { + iced::application(Table::new, Table::update, Table::view).run() +} + +struct Table { + events: Vec, +} + +#[derive(Debug, Clone)] +enum Message {} + +impl Table { + fn new() -> Self { + Self { + events: Event::list(), + } + } + + fn update(&mut self, message: Message) { + match message {} + } + + fn view(&self) -> Element<'_, Message> { + let table = { + let bold = |header| { + text(header).font(Font { + weight: font::Weight::Bold, + ..Font::DEFAULT + }) + }; + + let columns = table::definition() + .column(bold("Name"), |event: &Event| { + text(&event.name).width(Fill) + }) + .column(bold("Time"), |event| text!("{:?}", event.duration)) + .column(bold("Price"), |event| text!("{:.2}", event.price)) + .column(bold("Rating"), |event| text!("{:.2}", event.rating)); + + table(columns, &self.events).width(640).spacing_y(5) + }; + + center(scrollable(table).spacing(10)).padding(10).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/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/table.rs b/widget/src/table.rs new file mode 100644 index 00000000..8e1853ca --- /dev/null +++ b/widget/src/table.rs @@ -0,0 +1,563 @@ +#![allow(missing_docs, missing_debug_implementations)] +use crate::core; +use crate::core::layout; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::widget; +use crate::core::{ + Background, Element, Layout, Length, Pixels, Rectangle, Size, Widget, +}; + +pub fn table<'a, R, T, Message, Theme, Renderer>( + columns: impl IntoIterator>, + rows: R, +) -> Table<'a, Message, Theme, Renderer> +where + R: IntoIterator, + R::IntoIter: Clone, + Theme: Catalog, + Renderer: core::Renderer, +{ + Table::new(columns, rows) +} + +pub fn definition<'a, T, Message, Theme, Renderer>() +-> Definition<'a, T, Message, Theme, Renderer> { + Definition { + columns: Vec::new(), + } +} + +pub fn column<'a, T, E, Message, Theme, Renderer>( + header: impl Into>, + view: impl Fn(T) -> E + 'a, +) -> Column<'a, T, Message, Theme, Renderer> +where + T: 'a, + E: Into>, +{ + Column { + header: header.into(), + view: Box::new(move |data| view(data).into()), + width: Length::Shrink, + } +} + +pub struct Table<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> +where + Theme: Catalog, +{ + columns: Vec, + cells: Vec>, + width: Length, + height: Length, + spacing_x: f32, + spacing_y: f32, + separator_x: f32, + separator_y: f32, + class: Theme::Class<'a>, +} + +impl<'a, Message, Theme, Renderer> Table<'a, Message, Theme, Renderer> +where + Theme: Catalog, + Renderer: core::Renderer, +{ + pub fn new( + columns: impl IntoIterator>, + rows: R, + ) -> Self + where + R: IntoIterator, + R::IntoIter: 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), + ); + + Self { + columns: columns + .into_iter() + .map(|column| { + let mut column_width = column.width; + + cells.push(column.header); + cells.extend(rows.clone().map(|row| { + let cell = (column.view)(row); + let size_hint = cell.as_widget().size_hint(); + + column_width = column_width.enclose(size_hint.width); + height = height.enclose(size_hint.height); + + cell + })); + + width = width.enclose(column_width); + + column_width + }) + .collect(), + cells, + width, + height, + spacing_x: 10.0, + spacing_y: 10.0, + separator_x: 1.0, + separator_y: 1.0, + class: Theme::default(), + } + } + + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + pub fn spacing(self, spacing: impl Into) -> Self { + let spacing = spacing.into(); + + self.spacing_x(spacing).spacing_y(spacing) + } + + pub fn spacing_x(mut self, spacing: impl Into) -> Self { + self.spacing_x = spacing.into().0; + self + } + + pub fn spacing_y(mut self, spacing: impl Into) -> Self { + self.spacing_y = spacing.into().0; + self + } +} + +pub struct Metrics { + column_widths: Vec, + row_heights: 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 { + column_widths: Vec::new(), + row_heights: 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 limits = limits.width(self.width).height(self.height); + let rows = self.cells.len() / self.columns.len(); + let available = limits.max(); + + let mut cells = Vec::with_capacity(self.cells.len()); + cells.resize(self.cells.len(), layout::Node::default()); + + metrics.column_widths = vec![0.0; self.columns.len()]; + metrics.row_heights = vec![0.0; rows]; + + let mut column_factors = vec![0; self.columns.len()]; + let mut row_factors = vec![0; rows]; + + let spacing_x = self.spacing_x * 2.0 + self.separator_x; + let spacing_y = self.spacing_y * 2.0 + self.separator_y; + + // FIRST PASS + // Lay out non-fluid cells + let mut x = self.spacing_x; + let mut y = self.spacing_y; + + for (i, (cell, state)) in + self.cells.iter().zip(&mut tree.children).enumerate() + { + let column = i / rows; + let row = i % rows; + let size = cell.as_widget().size(); + + if size.width.fill_factor() != 0 || size.height.fill_factor() != 0 { + column_factors[column] = + column_factors[column].max(size.width.fill_factor()); + + row_factors[row] = + row_factors[row].max(size.height.fill_factor()); + + continue; + } + + let limits = layout::Limits::new( + Size::ZERO, + Size::new(available.width - x, available.height - y), + ) + .width(self.columns[i / rows]); + + let layout = cell.as_widget().layout(state, renderer, &limits); + let size = layout.size(); + + metrics.column_widths[column] = + metrics.column_widths[column].max(size.width); + metrics.row_heights[row] = + metrics.row_heights[row].max(size.height); + cells[i] = layout; + + if row == 0 { + y = self.spacing_y; + + if column > 0 { + x += metrics.column_widths[column - 1] + spacing_x; + } + } else { + y += size.height + spacing_y; + } + } + + // SECOND PASS + // Lay out fluid cells, using metrics from the first pass as limits + let left = Size::new( + available.width + - metrics + .column_widths + .iter() + .enumerate() + .filter(|(i, _)| column_factors[*i] == 0) + .map(|(_, width)| width) + .sum::(), + available.height + - metrics + .row_heights + .iter() + .enumerate() + .filter(|(i, _)| row_factors[*i] == 0) + .map(|(_, height)| height) + .sum::(), + ); + + let width_unit = (left.width + - spacing_x * self.columns.len().saturating_sub(1) as f32 + - self.spacing_x * 2.0) + / column_factors.iter().sum::() as f32; + + let height_unit = (left.height + - spacing_y * rows.saturating_sub(1) as f32 + - self.spacing_y * 2.0) + / row_factors.iter().sum::() as f32; + + let mut x = self.spacing_x; + let mut y = self.spacing_y; + + for (i, (cell, state)) in + self.cells.iter().zip(&mut tree.children).enumerate() + { + let column = i / rows; + let row = i % rows; + let size = cell.as_widget().size(); + + if size.width.fill_factor() != 0 || size.height.fill_factor() != 0 { + let column_factor = column_factors[column]; + let row_factor = row_factors[row]; + + let max_width = if column_factor == 0 { + (available.width - x).max(0.0) + } else { + width_unit * column_factor as f32 + }; + + let max_height = if row_factor == 0 { + (available.height - y).max(0.0) + } else { + height_unit * row_factor as f32 + }; + + let limits = layout::Limits::new( + Size::ZERO, + Size::new(max_width, max_height), + ) + .width(self.columns[i / rows]); + + let layout = cell.as_widget().layout(state, renderer, &limits); + let size = layout.size(); + + metrics.column_widths[column] = + metrics.column_widths[column].max(size.width); + metrics.row_heights[row] = + metrics.row_heights[row].max(size.height); + cells[i] = layout; + } + + if row == 0 { + y = self.spacing_y; + + if column > 0 { + x += metrics.column_widths[column - 1] + spacing_x; + } + } else { + y += cells[i].size().height + spacing_y; + } + } + + // THIRD PASS + // Position each cell + let mut x = self.spacing_x; + let mut y = self.spacing_y; + + for (i, cell) in cells.iter_mut().enumerate() { + let column = i / rows; + let row = i % rows; + + if row == 0 { + y = self.spacing_y; + + if column > 0 { + x += metrics.column_widths[column - 1] + spacing_x; + } + } + + cell.move_to_mut((x, y)); + + y += metrics.row_heights[row] + spacing_y; + } + + let intrinsic = limits.resolve( + self.width, + self.height, + Size::new( + x + metrics + .column_widths + .last() + .copied() + .map(|width| width + self.spacing_x) + .unwrap_or_default(), + y - spacing_y + self.spacing_y, + ), + ); + + 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.spacing_x; + + for width in &metrics.column_widths + [..metrics.column_widths.len().saturating_sub(1)] + { + x += width + self.spacing_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.spacing_x; + } + } + + if self.separator_y > 0.0 { + let mut y = self.spacing_y; + + for height in &metrics.row_heights + [..metrics.row_heights.len().saturating_sub(1)] + { + y += height + self.spacing_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.spacing_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) + } +} + +pub struct Definition< + 'a, + T, + Message, + Theme = crate::Theme, + Renderer = crate::Renderer, +> { + columns: Vec>, +} + +impl<'a, T, Message, Theme, Renderer> + Definition<'a, T, Message, Theme, Renderer> +{ + pub fn column( + mut self, + header: impl Into>, + view: impl Fn(T) -> E + 'a, + ) -> Self + where + T: 'a, + E: Into>, + { + self.columns.push(column(header, view)); + self + } +} + +impl<'a, T, Message, Theme, Renderer> IntoIterator + for Definition<'a, T, Message, Theme, Renderer> +{ + type Item = Column<'a, T, Message, Theme, Renderer>; + type IntoIter = ::std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.columns.into_iter() + } +} + +pub struct Column< + 'a, + T, + Message, + Theme = crate::Theme, + Renderer = crate::Renderer, +> { + header: Element<'a, Message, Theme, Renderer>, + view: Box Element<'a, Message, Theme, Renderer> + 'a>, + width: Length, +} + +impl<'a, T, Message, Theme, Renderer> Column<'a, T, Message, Theme, Renderer> { + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Style { + pub separator_x: Background, + 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