diff --git a/examples/gallery/Cargo.toml b/examples/gallery/Cargo.toml index 3dd5d378..24e4c8fe 100644 --- a/examples/gallery/Cargo.toml +++ b/examples/gallery/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] iced.workspace = true -iced.features = ["tokio", "sipper", "image", "web-colors", "debug"] +iced.features = ["tokio", "sipper", "image", "debug"] reqwest.version = "0.12" reqwest.features = ["json"] diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 01e6aab4..0d52483b 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -9,8 +9,8 @@ use crate::civitai::{Error, Id, Image, Rgba, Size}; use iced::animation; use iced::time::{Instant, milliseconds}; use iced::widget::{ - button, center_x, container, horizontal_space, image, mouse_area, opaque, - pop, row, scrollable, stack, + button, container, grid, horizontal_space, image, mouse_area, opaque, pop, + scrollable, stack, }; use iced::window; use iced::{ @@ -22,6 +22,10 @@ use std::collections::HashMap; fn main() -> iced::Result { iced::application(Gallery::new, Gallery::update, Gallery::view) + .window_size(( + Preview::WIDTH as f32 * 4.0, + Preview::HEIGHT as f32 * 2.5, + )) .subscription(Gallery::subscription) .theme(Gallery::theme) .run() @@ -175,19 +179,18 @@ impl Gallery { } pub fn view(&self) -> Element<'_, Message> { - let gallery = if self.images.is_empty() { - row((0..=Image::LIMIT).map(|_| placeholder())) - } else { - row(self.images.iter().map(|image| { - card(image, self.previews.get(&image.id), self.now) - })) - } - .spacing(10) - .wrap(); + let images = self + .images + .iter() + .map(|image| card(image, self.previews.get(&image.id), self.now)) + .chain((self.images.len()..=Image::LIMIT).map(|_| placeholder())); - let content = - container(scrollable(center_x(gallery)).spacing(10)).padding(10); + let gallery = grid(images) + .fluid(Preview::WIDTH) + .height(grid::aspect_ratio(Preview::WIDTH, Preview::HEIGHT)) + .spacing(10); + let content = container(scrollable(gallery).spacing(10)).padding(10); let viewer = self.viewer.view(self.now); stack![content, viewer].into() @@ -204,7 +207,6 @@ fn card<'a>( if let Preview::Ready { thumbnail, .. } = &preview { image(&thumbnail.handle) .width(Fill) - .height(Fill) .content_fit(ContentFit::Cover) .opacity(thumbnail.fade_in.interpolate(0.0, 1.0, now)) .scale(thumbnail.zoom.interpolate(1.0, 1.1, now)) @@ -216,7 +218,6 @@ fn card<'a>( if let Some(blurhash) = preview.blurhash(now) { let blurhash = image(&blurhash.handle) .width(Fill) - .height(Fill) .content_fit(ContentFit::Cover) .opacity(blurhash.fade_in.interpolate(0.0, 1.0, now)); @@ -228,14 +229,9 @@ fn card<'a>( horizontal_space().into() }; - let card = mouse_area( - container(image) - .width(Preview::WIDTH) - .height(Preview::HEIGHT) - .style(container::dark), - ) - .on_enter(Message::ThumbnailHovered(metadata.id, true)) - .on_exit(Message::ThumbnailHovered(metadata.id, false)); + let card = mouse_area(container(image).style(container::dark)) + .on_enter(Message::ThumbnailHovered(metadata.id, true)) + .on_exit(Message::ThumbnailHovered(metadata.id, false)); if let Some(preview) = preview { let is_thumbnail = matches!(preview, Preview::Ready { .. }); @@ -253,11 +249,7 @@ fn card<'a>( } fn placeholder<'a>() -> Element<'a, Message> { - container(horizontal_space()) - .width(Preview::WIDTH) - .height(Preview::HEIGHT) - .style(container::dark) - .into() + container(horizontal_space()).style(container::dark).into() } enum Preview { diff --git a/widget/src/grid.rs b/widget/src/grid.rs new file mode 100644 index 00000000..26e741db --- /dev/null +++ b/widget/src/grid.rs @@ -0,0 +1,403 @@ +//! Distribute content on a grid. +use crate::core::layout::{self, Layout}; +use crate::core::mouse; +use crate::core::overlay; +use crate::core::renderer; +use crate::core::widget::{Operation, Tree}; +use crate::core::{ + Clipboard, Element, Event, Length, Pixels, Rectangle, Shell, Size, Vector, + Widget, +}; + +/// A container that distributes its contents on a responsive grid. +#[allow(missing_debug_implementations)] +pub struct Grid<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> { + spacing: f32, + columns: Constraint, + width: Option, + height: Sizing, + children: Vec>, +} + +enum Constraint { + MaxWidth(Pixels), + Amount(usize), +} + +impl<'a, Message, Theme, Renderer> Grid<'a, Message, Theme, Renderer> +where + Renderer: crate::core::Renderer, +{ + /// Creates an empty [`Grid`]. + pub fn new() -> Self { + Self::from_vec(Vec::new()) + } + + /// Creates a [`Grid`] with the given capacity. + pub fn with_capacity(capacity: usize) -> Self { + Self::from_vec(Vec::with_capacity(capacity)) + } + + /// Creates a [`Grid`] with the given elements. + pub fn with_children( + children: impl IntoIterator>, + ) -> Self { + let iterator = children.into_iter(); + + Self::with_capacity(iterator.size_hint().0).extend(iterator) + } + + /// Creates a [`Grid`] from an already allocated [`Vec`]. + pub fn from_vec( + children: Vec>, + ) -> Self { + Self { + spacing: 0.0, + columns: Constraint::Amount(3), + width: None, + height: Sizing::AspectRatio(1.0), + children, + } + } + + /// Sets the spacing _between_ cells in the [`Grid`]. + pub fn spacing(mut self, amount: impl Into) -> Self { + self.spacing = amount.into().0; + self + } + + /// Sets the width of the [`Grid`] in [`Pixels`]. + /// + /// By default, a [`Grid`] will [`Fill`] its parent. + /// + /// [`Fill`]: Length::Fill + pub fn width(mut self, width: impl Into) -> Self { + self.width = Some(width.into()); + self + } + + /// Sets the height of the [`Grid`]. + /// + /// By default, a [`Grid`] uses a cell aspect ratio of `1.0` (i.e. squares). + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the amount of columns in the [`Grid`]. + pub fn columns(mut self, column: usize) -> Self { + self.columns = Constraint::Amount(column); + self + } + + /// Makes the amount of columns dynamic in the [`Grid`], never + /// exceeding the provided `max_width`. + pub fn fluid(mut self, max_width: impl Into) -> Self { + self.columns = Constraint::MaxWidth(max_width.into()); + self + } + + /// Adds an [`Element`] to the [`Grid`]. + pub fn push( + mut self, + child: impl Into>, + ) -> Self { + self.children.push(child.into()); + self + } + + /// Adds an element to the [`Grid`], if `Some`. + pub fn push_maybe( + self, + child: Option>>, + ) -> Self { + if let Some(child) = child { + self.push(child) + } else { + self + } + } + + /// Extends the [`Grid`] with the given children. + pub fn extend( + self, + children: impl IntoIterator>, + ) -> Self { + children.into_iter().fold(self, Self::push) + } +} + +impl Default for Grid<'_, Message, Renderer> +where + Renderer: crate::core::Renderer, +{ + fn default() -> Self { + Self::new() + } +} + +impl<'a, Message, Theme, Renderer: crate::core::Renderer> + FromIterator> + for Grid<'a, Message, Theme, Renderer> +{ + fn from_iter< + T: IntoIterator>, + >( + iter: T, + ) -> Self { + Self::with_children(iter) + } +} + +impl Widget + for Grid<'_, Message, Theme, Renderer> +where + Renderer: crate::core::Renderer, +{ + fn children(&self) -> Vec { + self.children.iter().map(Tree::new).collect() + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(&self.children); + } + + fn size(&self) -> Size { + Size { + width: self + .width + .map(|pixels| Length::Fixed(pixels.0)) + .unwrap_or(Length::Fill), + height: match self.height { + Sizing::AspectRatio(_) => Length::Shrink, + Sizing::EvenlyDistribute(length) => length, + }, + } + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let size = self.size(); + let limits = limits.width(size.width).height(size.height); + let available = limits.max(); + + let cells_per_row = match self.columns { + // width = n * (cell + spacing) - spacing, given n > 0 + Constraint::MaxWidth(pixels) => ((available.width + self.spacing) + / (pixels.0 + self.spacing)) + .ceil() as usize, + Constraint::Amount(amount) => amount, + }; + + let total_rows = self.children.len() / cells_per_row; + + let cell_width = (available.width + - self.spacing * (cells_per_row - 1) as f32) + / cells_per_row as f32; + + let cell_height = match self.height { + Sizing::AspectRatio(ratio) => Some(cell_width / ratio), + Sizing::EvenlyDistribute(Length::Shrink) => None, + Sizing::EvenlyDistribute(_) => { + Some(available.height / total_rows as f32) + } + }; + + let cell_limits = layout::Limits::new( + Size::new(cell_width, cell_height.unwrap_or(0.0)), + Size::new(cell_width, cell_height.unwrap_or(available.height)), + ); + + let mut nodes = Vec::new(); + let mut x = 0.0; + let mut y = 0.0; + let mut row_height = 0.0f32; + + for (i, (child, tree)) in + self.children.iter().zip(&mut tree.children).enumerate() + { + let node = child + .as_widget() + .layout(tree, renderer, &cell_limits) + .move_to((x, y)); + + let size = node.size(); + + x += size.width + self.spacing; + row_height = row_height.max(size.height); + + if (i + 1) % cells_per_row == 0 { + y += cell_height.unwrap_or(row_height) + self.spacing; + x = 0.0; + row_height = 0.0; + } + + nodes.push(node); + } + + if x == 0.0 { + y -= self.spacing; + } else { + y += cell_height.unwrap_or(row_height); + } + + layout::Node::with_children(Size::new(available.width, y), nodes) + } + + fn operate( + &self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.container(None, layout.bounds(), &mut |operation| { + self.children + .iter() + .zip(&mut tree.children) + .zip(layout.children()) + .for_each(|((child, state), layout)| { + child + .as_widget() + .operate(state, layout, renderer, operation); + }); + }); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + for ((child, state), layout) in self + .children + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + { + child.as_widget_mut().update( + state, event, layout, cursor, renderer, clipboard, shell, + viewport, + ); + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .map(|((child, state), layout)| { + child.as_widget().mouse_interaction( + state, layout, cursor, viewport, renderer, + ) + }) + .max() + .unwrap_or_default() + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + if let Some(viewport) = layout.bounds().intersection(viewport) { + for ((child, state), layout) in self + .children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .filter(|(_, layout)| layout.bounds().intersects(&viewport)) + { + child.as_widget().draw( + state, renderer, theme, style, layout, cursor, &viewport, + ); + } + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + translation: Vector, + ) -> Option> { + overlay::from_children( + &mut self.children, + tree, + layout, + renderer, + translation, + ) + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: crate::core::Renderer + 'a, +{ + fn from(row: Grid<'a, Message, Theme, Renderer>) -> Self { + Self::new(row) + } +} + +/// The sizing strategy of a [`Grid`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Sizing { + /// The [`Grid`] will ensure each cell follows the given aspect ratio and the + /// total size will be the sum of the cells and the spacing between them. + /// + /// The ratio is the amount of horizontal pixels per each vertical pixel of a cell + /// in the [`Grid`]. + AspectRatio(f32), + + /// The [`Grid`] will evenly distribute the space available in the given [`Length`] + /// for each cell. + EvenlyDistribute(Length), +} + +impl From for Sizing { + fn from(height: f32) -> Self { + Self::EvenlyDistribute(Length::from(height)) + } +} + +impl From for Sizing { + fn from(height: Length) -> Self { + Self::EvenlyDistribute(height) + } +} + +/// Creates a new [`Sizing`] strategy that maintains the given aspect ratio. +pub fn aspect_ratio( + width: impl Into, + height: impl Into, +) -> Sizing { + Sizing::AspectRatio(width.into().0 / height.into().0) +} diff --git a/widget/src/helpers.rs b/widget/src/helpers.rs index ff178e4f..1bd9f8ee 100644 --- a/widget/src/helpers.rs +++ b/widget/src/helpers.rs @@ -24,7 +24,7 @@ use crate::text_input::{self, TextInput}; use crate::toggler::{self, Toggler}; use crate::tooltip::{self, Tooltip}; use crate::vertical_slider::{self, VerticalSlider}; -use crate::{Column, MouseArea, Pin, Pop, Row, Space, Stack, Themer}; +use crate::{Column, Grid, MouseArea, Pin, Pop, Row, Space, Stack, Themer}; use std::borrow::Borrow; use std::ops::RangeInclusive; @@ -529,6 +529,16 @@ where Row::with_children(children) } +/// Creates a new [`Grid`] from an iterator. +pub fn grid<'a, Message, Theme, Renderer>( + children: impl IntoIterator>, +) -> Grid<'a, Message, Theme, Renderer> +where + Renderer: core::Renderer, +{ + Grid::with_children(children) +} + /// Creates a new [`Stack`] with the given children. /// /// [`Stack`]: crate::Stack diff --git a/widget/src/lib.rs b/widget/src/lib.rs index 31dcc205..dcaea007 100644 --- a/widget/src/lib.rs +++ b/widget/src/lib.rs @@ -20,6 +20,7 @@ pub mod button; pub mod checkbox; pub mod combo_box; pub mod container; +pub mod grid; pub mod keyed; pub mod overlay; pub mod pane_grid; @@ -59,6 +60,8 @@ pub use combo_box::ComboBox; #[doc(no_inline)] pub use container::Container; #[doc(no_inline)] +pub use grid::Grid; +#[doc(no_inline)] pub use mouse_area::MouseArea; #[doc(no_inline)] pub use pane_grid::PaneGrid; diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index 0c876036..d50591a1 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -461,7 +461,7 @@ where limits.max().width }, if self.direction.vertical().is_some() { - f32::MAX + f32::INFINITY } else { limits.max().height },