diff --git a/crates/yakui-core/src/types.rs b/crates/yakui-core/src/types.rs index 75f4b246..30fbbc78 100644 --- a/crates/yakui-core/src/types.rs +++ b/crates/yakui-core/src/types.rs @@ -64,6 +64,33 @@ pub enum MainAxisAlignment { // SpaceEvenly, } +/// Defines alignment for items within a container's main axis when there is space left. +/// +/// This occurs in a Grid when items of the same row are bigger than one self. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum MainAxisAlignItems { + /// Align item to the beginning of the cell main axis. + /// + /// For a left-to-right grid, this is the left side of the cell. + /// + /// For a top-down grid, this is the top of the cell. + Start, + + /// Align items to the center of the cell's main axis. + Center, + + /// Align items to the end of the cell's main axis. + /// + /// For a left-to-right list, this is the right side of the cell. + /// + /// For a top-down list, this is the bottom of the cell. + End, + + /// Stretch items to fill the maximum size of the cell's main axis. + Stretch, +} + /// Defines alignment along a container's cross axis. /// /// For example, a horizontal list's cross axis is vertical, and a vertical diff --git a/crates/yakui-widgets/src/shorthand.rs b/crates/yakui-widgets/src/shorthand.rs index 57a071f8..fc9a9890 100644 --- a/crates/yakui-widgets/src/shorthand.rs +++ b/crates/yakui-widgets/src/shorthand.rs @@ -12,8 +12,8 @@ use yakui_core::{Alignment, ManagedTextureId, Response, TextureId}; use crate::widgets::{ Align, AlignResponse, Button, ButtonResponse, Canvas, CanvasResponse, Checkbox, CheckboxResponse, Circle, CircleResponse, ColoredBox, ColoredBoxResponse, ConstrainedBox, - ConstrainedBoxResponse, Draggable, DraggableResponse, Flexible, FlexibleResponse, Image, - ImageResponse, List, ListResponse, MaxWidth, MaxWidthResponse, NineSlice, Offset, + ConstrainedBoxResponse, CountGrid, Draggable, DraggableResponse, Flexible, FlexibleResponse, + Image, ImageResponse, List, ListResponse, MaxWidth, MaxWidthResponse, NineSlice, Offset, OffsetResponse, Opaque, OpaqueResponse, Pad, PadResponse, Reflow, ReflowResponse, Scrollable, ScrollableResponse, Slider, SliderResponse, State, StateResponse, Text, TextBox, TextBoxResponse, TextResponse, @@ -29,6 +29,16 @@ pub fn row(children: F) -> Response { List::row().show(children) } +/// See [CountGrid]. +pub fn countgrid_column(n_columns: usize, children: F) -> Response { + CountGrid::col(n_columns).show(children) +} + +/// See [CountGrid]. +pub fn countgrid_row(n_rows: usize, children: F) -> Response { + CountGrid::row(n_rows).show(children) +} + /// See [Align]. pub fn center(children: F) -> Response { Align::center().show(children) diff --git a/crates/yakui-widgets/src/widgets/count_grid.rs b/crates/yakui-widgets/src/widgets/count_grid.rs new file mode 100644 index 00000000..d7e35347 --- /dev/null +++ b/crates/yakui-widgets/src/widgets/count_grid.rs @@ -0,0 +1,297 @@ +use std::cell::RefCell; +use yakui_core::geometry::{Constraints, Vec2}; +use yakui_core::widget::{LayoutContext, Widget}; +use yakui_core::{ + CrossAxisAlignment, Direction, MainAxisAlignItems, MainAxisAlignment, MainAxisSize, Response, +}; + +use crate::util::widget_children; + +/** +CountGrid lays out its children such as all cells within the same column have the same width, and +all cells within the same row have the same height. + +The children should be provided in cross-axis-major order. +For example, if you want a 2x3 column-based grid, you should provide the children in this order: +```text +0 1 +2 3 +4 5 +``` + +The grid tries to replicate the same layout logic as a List. +A n x 1 grid should be almost equivalent to a List for non-flex content. + +Check the count_grid example to see it in action with different alignments and sizes. + +Responds with [CountGridResponse]. +*/ +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct CountGrid { + pub direction: Direction, + pub cross_axis_count: usize, + pub main_axis_alignment: MainAxisAlignment, + pub main_axis_size: MainAxisSize, + pub main_axis_align_items: MainAxisAlignItems, + pub cross_axis_alignment: CrossAxisAlignment, +} + +impl CountGrid { + /// The children will be laid out in a grid with the given number of columns. + /// They should be provided in row-major order. + pub fn col(n_columns: usize) -> Self { + Self { + direction: Direction::Down, + cross_axis_count: n_columns, + main_axis_size: MainAxisSize::Max, + main_axis_alignment: MainAxisAlignment::Start, + cross_axis_alignment: CrossAxisAlignment::Start, + main_axis_align_items: MainAxisAlignItems::Start, + } + } + + /// The children will be laid out in a grid with the given number of rows. + /// They should be provided in column-major order. + pub fn row(n_rows: usize) -> Self { + Self { + direction: Direction::Right, + cross_axis_count: n_rows, + main_axis_size: MainAxisSize::Max, + main_axis_alignment: MainAxisAlignment::Start, + cross_axis_alignment: CrossAxisAlignment::Start, + main_axis_align_items: MainAxisAlignItems::Start, + } + } + + pub fn cross_axis_aligment(mut self, alignment: CrossAxisAlignment) -> Self { + self.cross_axis_alignment = alignment; + self + } + + pub fn main_axis_aligment(mut self, alignment: MainAxisAlignment) -> Self { + self.main_axis_alignment = alignment; + self + } + + pub fn main_axis_size(mut self, size: MainAxisSize) -> Self { + self.main_axis_size = size; + self + } + + pub fn main_axis_align_items(mut self, items: MainAxisAlignItems) -> Self { + self.main_axis_align_items = items; + self + } + + /// The children will be laid out in a grid with the given number of columns/rows. + /// They should be provided in cross-axis-major order. + /// For example, if you want a 2x3 column-based grid, you should provide the children in this order: + /// ```text + /// 0 1 + /// 2 3 + /// 4 5 + /// ``` + pub fn show(self, children: F) -> Response { + widget_children::(children, self) + } +} + +#[derive(Debug)] +pub struct CountGridWidget { + props: CountGrid, + max_sizes: RefCell>, // cache max_sizes vector to avoid reallocating every frame +} + +pub type CountGridResponse = (); + +impl Widget for CountGridWidget { + type Props<'a> = CountGrid; + type Response = CountGridResponse; + + fn new() -> Self { + Self { + props: CountGrid::col(0), + max_sizes: RefCell::new(vec![]), + } + } + + fn update(&mut self, props: Self::Props<'_>) -> Self::Response { + self.props = props; + } + + fn layout(&self, mut ctx: LayoutContext<'_>, input: Constraints) -> Vec2 { + let node = ctx.dom.get_current(); + + let n_cross = self.props.cross_axis_count; + let direction = self.props.direction; + + // Pad the number of children to be a multiple of cross_n (if not already the case) + let n = node.children.len(); + let n_cells = n + (n_cross - n % n_cross) % n_cross; + + let n_main = n_cells / n_cross; + + // Calculate cell constraints + // In general, to get cell constraint we divide the input constraints by the number of cells + // in each axis + + let cell_cross_max = direction.get_cross_axis(input.max) / n_cross as f32; + let cell_cross_min = match self.props.cross_axis_alignment { + // If stretch, the cells will be as wide as possible + CrossAxisAlignment::Stretch => cell_cross_max, + _ => 0.0, + }; + + // Same logic as for lists, we cannot allow going infinitely far in the main axis + let mut total_main_max = direction.get_main_axis(input.max); + if total_main_max.is_infinite() { + total_main_max = direction.get_main_axis(input.min); + }; + + let cell_main_max = total_main_max / n_main as f32; + let cell_main_min = match self.props.main_axis_align_items { + MainAxisAlignItems::Stretch => cell_main_max, + _ => 0.0, + }; + + let cell_constraint = Constraints { + min: direction.vec2(cell_main_min, cell_cross_min), + max: direction.vec2(cell_main_max, cell_cross_max), + }; + + // max_sizes holds the maximum size on cross axis and main axis + // its layout is: + // 0 ... n_cross - 1 ... n_cross .. (n_cross + n_main) + // where each element is the maximum size of each row/column in each axis + // it is used later to calculate where each cell should go + // it is put into a RefCell to avoid reallocating every frame + let mut max_sizes = std::mem::take(&mut *self.max_sizes.borrow_mut()); + max_sizes.resize(n_cross + n_main, 0.0); + + // dispatch layout and find the maximum size of each row/column + for (i, &child_id) in node.children.iter().enumerate() { + let size = ctx.calculate_layout(child_id, cell_constraint); + + let main_id = i / n_cross; + let cross_id = i % n_cross; + + let main_size = direction.get_main_axis(size); + let cross_size = direction.get_cross_axis(size); + + max_sizes[n_cross + main_id] = max_sizes[n_cross + main_id].max(main_size); + max_sizes[cross_id] = max_sizes[cross_id].max(cross_size); + } + + // We keep track of the final size of each axis to apply alignment later + total grid size + // + set the positions without more allocations + let mut total_main_size: f32 = 0.0; + let mut max_total_cross_size: f32 = 0.0; + + // Set the positions without caring for alignment for now (as if alignment was Start, Start) + for main_axis_id in 0..n_main { + let cross_line_slice = &node.children + [main_axis_id * n_cross..((main_axis_id + 1) * n_cross).min(node.children.len())]; + + // We keep track of cross axis size to set positions of the cross-axis line + let mut total_cross_size = 0.0; + for (cross_axis_id, &child_id) in cross_line_slice.iter().enumerate() { + let layout = ctx.layout.get_mut(child_id).unwrap(); + + let cross_axis_size = match self.props.cross_axis_alignment { + CrossAxisAlignment::Stretch => cell_cross_max, + _ => max_sizes[cross_axis_id], + }; + + let pos = direction.vec2(total_main_size, total_cross_size); + layout.rect.set_pos(pos); + + total_cross_size += cross_axis_size; + max_total_cross_size = max_total_cross_size.max(total_cross_size); + } + + total_main_size += max_sizes[n_cross + main_axis_id]; + } + + // Calculate offset needed for alignment + let mut offset_main_global = match self.props.main_axis_alignment { + MainAxisAlignment::Start => 0.0, + MainAxisAlignment::Center => ((total_main_max - total_main_size) / 2.0).max(0.0), + MainAxisAlignment::End => (total_main_max - total_main_size).max(0.0), + other => unimplemented!("MainAxisAlignment::{other:?}"), + }; + offset_main_global = match self.props.main_axis_size { + MainAxisSize::Max => offset_main_global, + MainAxisSize::Min => 0.0, + other => unimplemented!("MainAxisSize::{other:?}"), + }; + + // only used in case the widget total cross is less than the minimum cross axis + let offset_cross_global = match self.props.cross_axis_alignment { + CrossAxisAlignment::Start | CrossAxisAlignment::Stretch => 0.0, + CrossAxisAlignment::Center => { + ((direction.get_cross_axis(input.min) - max_total_cross_size) / 2.0).max(0.0) + } + CrossAxisAlignment::End => { + (direction.get_cross_axis(input.min) - max_total_cross_size).max(0.0) + } + other => unimplemented!("CrossAxisAlignment::{other:?}"), + }; + + // Apply alignment by offsetting all children + for (i, &child_id) in node.children.iter().enumerate() { + let cross_id = i % n_cross; + let main_id = i / n_cross; + + let layout = ctx.layout.get_mut(child_id).unwrap(); + + let child_cross_size = direction.get_cross_axis(layout.rect.size()); + let cell_cross_size = match self.props.cross_axis_alignment { + CrossAxisAlignment::Stretch => cell_cross_max, + _ => max_sizes[cross_id], + }; + let offset_cross = match self.props.cross_axis_alignment { + CrossAxisAlignment::Start | CrossAxisAlignment::Stretch => 0.0, + CrossAxisAlignment::Center => ((cell_cross_size - child_cross_size) / 2.0).max(0.0), + CrossAxisAlignment::End => (cell_cross_size - child_cross_size).max(0.0), + other => unimplemented!("CrossAxisAlignment::{other:?}"), + }; + + let child_main_size = direction.get_main_axis(layout.rect.size()); + let cell_main_size = match self.props.main_axis_align_items { + MainAxisAlignItems::Start | MainAxisAlignItems::Stretch => cell_main_max, + _ => max_sizes[n_cross + main_id], + }; + let offset_main = match self.props.main_axis_align_items { + MainAxisAlignItems::Start | MainAxisAlignItems::Stretch => 0.0, + MainAxisAlignItems::Center => ((cell_main_size - child_main_size) / 2.0).max(0.0), + MainAxisAlignItems::End => (cell_main_size - child_main_size).max(0.0), + other => unimplemented!("MainAxisAlignItems::{other:?}"), + }; + + let offset_pos = layout.rect.pos() + + direction.vec2( + offset_main_global + offset_main, + offset_cross_global + offset_cross, + ); + layout.rect.set_pos(offset_pos); + } + + // Put max_sizes back to be reused + max_sizes.clear(); + let _ = std::mem::replace(&mut *self.max_sizes.borrow_mut(), max_sizes); + + // Figure out the final size of the grid + let cross_grid_size = match self.props.cross_axis_alignment { + CrossAxisAlignment::Stretch => direction.get_cross_axis(input.max), + _ => max_total_cross_size, + }; + let main_grid_size = match self.props.main_axis_size { + MainAxisSize::Max => total_main_max, + MainAxisSize::Min => total_main_size, + other => unimplemented!("MainAxisSize::{other:?}"), + }; + + direction.vec2(main_grid_size, cross_grid_size) + } +} diff --git a/crates/yakui-widgets/src/widgets/mod.rs b/crates/yakui-widgets/src/widgets/mod.rs index f359ea13..5e6e3f2a 100644 --- a/crates/yakui-widgets/src/widgets/mod.rs +++ b/crates/yakui-widgets/src/widgets/mod.rs @@ -5,6 +5,7 @@ mod checkbox; mod circle; mod colored_box; mod constrained_box; +mod count_grid; mod cutout; mod draggable; mod flexible; @@ -36,6 +37,7 @@ pub use self::checkbox::*; pub use self::circle::*; pub use self::colored_box::*; pub use self::constrained_box::*; +pub use self::count_grid::*; pub use self::cutout::*; pub use self::draggable::*; pub use self::flexible::*; diff --git a/crates/yakui/examples/count_grid.rs b/crates/yakui/examples/count_grid.rs new file mode 100644 index 00000000..f69a40b4 --- /dev/null +++ b/crates/yakui/examples/count_grid.rs @@ -0,0 +1,188 @@ +use yakui::{Color, Vec2}; +use yakui_core::geometry::Constraints; +use yakui_core::{ + Alignment, CrossAxisAlignment, Direction, MainAxisAlignItems, MainAxisAlignment, MainAxisSize, + Response, +}; +use yakui_widgets::widgets::{CountGrid, List, StateResponse}; +use yakui_widgets::{ + align, button, center, checkbox, colored_box_container, constrained, label, row, use_state, +}; + +pub fn run() { + let main_axis_size = use_state(|| MainAxisSize::Min); + let main_axis_alignment = use_state(|| MainAxisAlignment::Start); + let main_axis_align_items = use_state(|| MainAxisAlignItems::Start); + let cross_axis_alignment = use_state(|| CrossAxisAlignment::Start); + let direction = use_state(|| Direction::Down); + let compare_with_list = use_state(|| false); + + column_spacing(|| { + parameter_select( + &main_axis_size, + &main_axis_alignment, + &main_axis_align_items, + &cross_axis_alignment, + &direction, + ); + + label("The following 3x2 grid will be layed out in a 600x400 container"); + test_window(|| { + let mut g = CountGrid::row(2); + g.direction = *direction.borrow(); + g.main_axis_alignment = *main_axis_alignment.borrow(); + g.main_axis_size = *main_axis_size.borrow(); + g.main_axis_align_items = *main_axis_align_items.borrow(); + g.cross_axis_alignment = *cross_axis_alignment.borrow(); + g.show(|| { + box_with_label(Color::RED, 50.0); + box_with_label(Color::GREEN, 50.0); + box_with_label(Color::BLUE, 70.0); + box_with_label(Color::CORNFLOWER_BLUE, 80.0); + box_with_label(Color::REBECCA_PURPLE, 30.0); + box_with_label(Color::FUCHSIA, 70.0); + }); + }); + + row(|| { + *compare_with_list.borrow_mut() = checkbox(compare_with_list.get()).checked; + label("compare with list"); + }); + if compare_with_list.get() { + test_window(|| { + let mut l = List::row(); + l.direction = *direction.borrow(); + l.cross_axis_alignment = *cross_axis_alignment.borrow(); + l.main_axis_alignment = *main_axis_alignment.borrow(); + l.main_axis_size = *main_axis_size.borrow(); + l.show(|| { + box_with_label(Color::RED, 50.0); + box_with_label(Color::BLUE, 70.0); + box_with_label(Color::REBECCA_PURPLE, 30.0); + }); + }); + } + }); +} + +fn test_window(children: impl FnOnce()) { + constrained(Constraints::tight(Vec2::new(600.0, 400.0)), || { + colored_box_container(Color::rgb(0, 0, 0), || { + align(Alignment::TOP_LEFT, || { + constrained(Constraints::loose(Vec2::new(600.0, 400.0)), || { + colored_box_container(Color::GRAY, children); + }); + }); + }); + }); +} + +fn parameter_select( + main_axis_size: &Response>, + main_axis_alignment: &Response>, + main_axis_align_items: &Response>, + cross_axis_alignment: &Response>, + direction: &Response>, +) { + row_spacing(|| { + label("Main axis size:"); + let main_size_button = |text, size| { + if main_axis_size.get() == size { + label(text); + return; + } + if button(text).clicked { + *main_axis_size.borrow_mut() = size; + } + }; + main_size_button("Min", MainAxisSize::Min); + main_size_button("Max", MainAxisSize::Max); + }); + row_spacing(|| { + label("Main axis alignment:"); + let main_align_button = |text, alignment| { + if main_axis_alignment.get() == alignment { + label(text); + return; + } + if button(text).clicked { + *main_axis_alignment.borrow_mut() = alignment; + } + }; + main_align_button("Start", MainAxisAlignment::Start); + main_align_button("Center", MainAxisAlignment::Center); + main_align_button("End", MainAxisAlignment::End); + }); + row_spacing(|| { + label("Main axis align items:"); + let main_align_items_button = |text, alignment| { + if main_axis_align_items.get() == alignment { + label(text); + return; + } + if button(text).clicked { + *main_axis_align_items.borrow_mut() = alignment; + } + }; + main_align_items_button("Start", MainAxisAlignItems::Start); + main_align_items_button("Center", MainAxisAlignItems::Center); + main_align_items_button("End", MainAxisAlignItems::End); + main_align_items_button("Stretch", MainAxisAlignItems::Stretch); + }); + row_spacing(|| { + label("Cross axis alignment:"); + let cross_align_button = |text, alignment| { + if cross_axis_alignment.get() == alignment { + label(text); + return; + } + if button(text).clicked { + *cross_axis_alignment.borrow_mut() = alignment; + } + }; + cross_align_button("Start", CrossAxisAlignment::Start); + cross_align_button("Center", CrossAxisAlignment::Center); + cross_align_button("End", CrossAxisAlignment::End); + cross_align_button("Stretch", CrossAxisAlignment::Stretch); + }); + row_spacing(|| { + label("Direction:"); + let direction_button = |text, dir| { + if *direction.borrow() == dir { + label(text); + return; + } + if button(text).clicked { + *direction.borrow_mut() = dir; + } + }; + direction_button("Down", Direction::Down); + direction_button("Right", Direction::Right); + }); +} + +fn column_spacing(children: F) { + let mut l = List::column(); + l.item_spacing = 5.0; + l.show(children); +} + +fn row_spacing(children: F) { + let mut l = List::row(); + l.item_spacing = 5.0; + l.show(children); +} + +fn box_with_label(color: Color, size: f32) { + constrained(Constraints::tight(Vec2::splat(size)), || { + colored_box_container(color, || { + center(|| { + yakui::text(size / 3.0, format!("{size}x{size}")); + }); + }); + }); +} + +fn main() { + bootstrap::start(run as fn()); +}