From 0fd7c8fd5c2f599a6723d764e796a59fdbd43360 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Thu, 14 Dec 2023 10:48:35 -0800 Subject: [PATCH] Implemented Wrap Closes #59 --- examples/wrap.rs | 65 +++++++++++ src/value.rs | 13 ++- src/widget.rs | 16 ++- src/widgets.rs | 2 + src/widgets/container.rs | 18 +-- src/widgets/image.rs | 12 +- src/widgets/wrap.rs | 235 +++++++++++++++++++++++++++++++++++++++ src/window.rs | 6 + 8 files changed, 350 insertions(+), 17 deletions(-) create mode 100644 examples/wrap.rs create mode 100644 src/widgets/wrap.rs diff --git a/examples/wrap.rs b/examples/wrap.rs new file mode 100644 index 0000000..afcddf3 --- /dev/null +++ b/examples/wrap.rs @@ -0,0 +1,65 @@ +use gooey::styles::components::{LineHeight, TextSize}; +use gooey::value::Dynamic; +use gooey::widget::{Children, MakeWidget}; +use gooey::widgets::wrap::{VerticalAlign, WrapAlign}; +use gooey::Run; +use kludgine::figures::units::Lp; +use rand::{thread_rng, Rng}; + +const EXPLANATION: &str = "This example demonstrates the Wrap widget. Each word shown here is an individual Label widget that is being positioned by the Wrap widget."; + +fn main() -> gooey::Result { + let mut rng = thread_rng(); + let words = EXPLANATION + .split_ascii_whitespace() + .map(|word| { + let text_size = Lp::points(rng.gen_range(14..48)); + word.with(&TextSize, text_size).with(&LineHeight, text_size) + }) + .collect::(); + + let align = Dynamic::::default(); + let vertical_align = Dynamic::::default(); + + let editors = "Settings" + .h3() + .and( + "Wrap Align" + .h5() + .and(align.new_radio(WrapAlign::Start, "Start")) + .and(align.new_radio(WrapAlign::End, "End")) + .and(align.new_radio(WrapAlign::Center, "Center")) + .and(align.new_radio(WrapAlign::SpaceAround, "Space Around")) + .and(align.new_radio(WrapAlign::SpaceEvenly, "Space Evenly")) + .and(align.new_radio(WrapAlign::SpaceBetween, "Space Between")) + .into_rows() + .contain(), + ) + .and( + "Vertical Align" + .h5() + .and(vertical_align.new_radio(VerticalAlign::Top, "Top")) + .and(vertical_align.new_radio(VerticalAlign::Middle, "Middle")) + .and(vertical_align.new_radio(VerticalAlign::Bottom, "Bottom")) + .into_rows() + .contain(), + ) + .into_rows(); + + let preview = "Preview" + .h3() + .and( + words + .wrap() + .align(align) + .vertical_align(vertical_align) + .expand_horizontally() + .contain() + .pad() + .expand(), + ) + .into_rows() + .expand(); + + editors.and(preview).into_columns().pad().run() +} diff --git a/src/value.rs b/src/value.rs index eeb0f01..0e37595 100644 --- a/src/value.rs +++ b/src/value.rs @@ -1647,7 +1647,7 @@ impl Value { /// Returns a clone of the currently stored value. /// - /// If `self` is a dynamic, `context` will be invalidated when the value is + /// If `self` is a dynamic, `context` will be refreshed when the value is /// updated. pub fn get_tracked(&self, context: &WidgetContext<'_, '_>) -> T where @@ -1656,6 +1656,17 @@ impl Value { self.map_tracking_redraw(context, Clone::clone) } + /// Returns a clone of the currently stored value. + /// + /// If `self` is a dynamic, `context` will be invalidated when the value is + /// updated. + pub fn get_tracking_invalidate(&self, context: &WidgetContext<'_, '_>) -> T + where + T: Clone, + { + self.map_tracking_invalidate(context, Clone::clone) + } + /// Returns the current generation of the data stored, if the contained /// value is [`Dynamic`]. pub fn generation(&self) -> Option { diff --git a/src/widget.rs b/src/widget.rs index 9fa4d03..fdb45cb 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -42,7 +42,7 @@ use crate::widgets::checkbox::{Checkable, CheckboxState}; use crate::widgets::layers::{OverlayLayer, Tooltipped}; use crate::widgets::{ Align, Button, Checkbox, Collapse, Container, Expand, Layers, Resize, Scroll, Space, Stack, - Style, Themed, ThemedMode, Validated, + Style, Themed, ThemedMode, Validated, Wrap, }; use crate::window::{RunningWindow, ThemeMode, Window, WindowBehavior}; use crate::{ConstraintLimit, Run}; @@ -1763,6 +1763,13 @@ impl Children { Layers::new(self) } + /// Returns a [`Wrap`] that lays the children out horizontally, wrapping + /// into additional rows as needed. + #[must_use] + pub fn wrap(self) -> Wrap { + Wrap::new(self) + } + /// Synchronizes this list of children with another collection. /// /// This function updates `collection` by calling `change_fn` for each @@ -1829,6 +1836,13 @@ impl Dynamic { pub fn into_layers(self) -> Layers { Layers::new(self) } + + /// Returns a [`Wrap`] that lays the children out horizontally, wrapping + /// into additional rows as needed. + #[must_use] + pub fn wrap(self) -> Wrap { + Wrap::new(self) + } } impl FromIterator for Children diff --git a/src/widgets.rs b/src/widgets.rs index a0e395c..d0d1947 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -28,6 +28,7 @@ mod switcher; mod themed; mod tilemap; mod validated; +pub mod wrap; pub use align::Align; pub use button::Button; @@ -56,3 +57,4 @@ pub use switcher::Switcher; pub use themed::Themed; pub use tilemap::TileMap; pub use validated::Validated; +pub use wrap::Wrap; diff --git a/src/widgets/container.rs b/src/widgets/container.rs index edaf71d..0777a0d 100644 --- a/src/widgets/container.rs +++ b/src/widgets/container.rs @@ -168,11 +168,6 @@ impl Container { .map(|dim| dim.into_px(context.gfx.scale())) } - fn effective_shadow(&self, context: &WidgetContext<'_, '_>) -> ContainerShadow { - self.shadow.invalidate_when_changed(context); - self.shadow.get() - } - fn effective_background_color(&mut self, context: &WidgetContext<'_, '_>) -> kludgine::Color { let background = match self.background.get() { ContainerBackground::Color(color) => EffectiveBackground::Color(color), @@ -224,7 +219,10 @@ impl Widget for Container { let background = self.effective_background_color(context); let background = background.with_alpha_f32(background.alpha_f32() * *opacity); if background.alpha() > 0 { - let shadow = self.effective_shadow(context).into_px(context.gfx.scale()); + let shadow = self + .shadow + .get_tracking_invalidate(context) + .into_px(context.gfx.scale()); let child_shadow_offset = shadow.offset.min(Point::ZERO).abs(); let child_size = context.gfx.region().size - shadow.spread * 2 - shadow.offset.abs(); @@ -282,7 +280,10 @@ impl Widget for Container { .max(corner_radii.bottom_left / std::f32::consts::PI); let padding_amount = padding.size(); - let shadow = self.effective_shadow(context).into_px(context.gfx.scale()); + let shadow = self + .shadow + .get_tracking_invalidate(context) + .into_px(context.gfx.scale()); let shadow_spread = shadow.spread.into_unsigned(); let child_shadow_offset_amount = shadow.offset.abs().into_unsigned(); @@ -315,7 +316,8 @@ impl Widget for Container { .map(|padding| padding.get().into_px(context.kludgine.scale())) .unwrap_or_default(); let shadow = self - .effective_shadow(context) + .shadow + .get_tracking_invalidate(context) .into_px(context.kludgine.scale()); if shadow.offset.x >= 0 { diff --git a/src/widgets/image.rs b/src/widgets/image.rs index 5d0a943..422c06b 100644 --- a/src/widgets/image.rs +++ b/src/widgets/image.rs @@ -149,16 +149,14 @@ impl Widget for Image { available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - self.scaling.invalidate_when_changed(context); - - match self.scaling.get() { + match self.scaling.get_tracking_invalidate(context) { ImageScaling::Aspect { .. } | ImageScaling::Stretch => { available_space.map(ConstraintLimit::min) } - ImageScaling::Scale(factor) => { - self.contents.invalidate_when_changed(context); - self.contents.map(AnyTexture::size).map(|px| px * factor) - } + ImageScaling::Scale(factor) => self + .contents + .map_tracking_invalidate(context, AnyTexture::size) + .map(|px| px * factor), } } } diff --git a/src/widgets/wrap.rs b/src/widgets/wrap.rs new file mode 100644 index 0000000..b536ce8 --- /dev/null +++ b/src/widgets/wrap.rs @@ -0,0 +1,235 @@ +//! A widget for laying out multiple widgets in a similar fashion as how words +//! are wrapped in a paragraph. + +use intentional::Cast; +use kludgine::figures::units::{Px, UPx}; +use kludgine::figures::{IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size, Zero}; + +use crate::context::{AsEventContext, GraphicsContext, LayoutContext}; +use crate::styles::components::{IntrinsicPadding, LayoutOrder}; +use crate::styles::{FlexibleDimension, HorizontalOrder}; +use crate::value::{IntoValue, Value}; +use crate::widget::{Children, MountedChildren, Widget}; +use crate::ConstraintLimit; + +/// A widget that lays its children out horizontally, wrapping into multiple +/// rows when the widgets can't fit. +/// +/// This widget is designed to mimic how text layout occurs for words within a +/// paragraph. +#[derive(Debug)] +pub struct Wrap { + /// The children to wrap. + pub children: Value, + /// The horizontal alignment for widgets on the same row. + pub align: Value, + /// The vertical alignment for widgets on the same row. + pub vertical_align: Value, + /// The spacing to place between widgets. When [`FlexibleDimension::Auto`] + /// is set, [`IntrinsicPadding`] will be used. + pub spacing: Value>, + mounted: MountedChildren, +} + +impl Wrap { + /// Returns a new widget that wraps `children`. + #[must_use] + pub fn new(children: impl IntoValue) -> Self { + Self { + children: children.into_value(), + align: Value::default(), + vertical_align: Value::default(), + spacing: Value::Constant(Size::squared(FlexibleDimension::Auto)), + mounted: MountedChildren::default(), + } + } + + /// Sets the spacing between widgets and returns self. + #[must_use] + pub fn spacing(mut self, spacing: impl IntoValue>) -> Self { + self.spacing = spacing.into_value(); + self + } + + /// Sets the horizontal alignment and returns self. + #[must_use] + pub fn align(mut self, align: impl IntoValue) -> Self { + self.align = align.into_value(); + self + } + + /// Sets the vertical alignment and returns self. + #[must_use] + pub fn vertical_align(mut self, align: impl IntoValue) -> Self { + self.vertical_align = align.into_value(); + self + } +} + +impl Widget for Wrap { + fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { + for child in self.mounted.children() { + context.for_other(child).redraw(); + } + } + + fn layout( + &mut self, + available_space: Size, + context: &mut LayoutContext<'_, '_, '_, '_, '_>, + ) -> Size { + struct RowChild { + index: usize, + x: Px, + size: Size, + } + + let order = context.get(&LayoutOrder).horizontal; + + self.children.invalidate_when_changed(context); + let align = self.align.get_tracking_invalidate(context); + let vertical_align = self.vertical_align.get_tracking_invalidate(context); + let spacing = self + .spacing + .get_tracking_invalidate(context) + .map(|dimension| match dimension { + FlexibleDimension::Auto => context.get(&IntrinsicPadding), + FlexibleDimension::Dimension(dimension) => dimension, + }) + .into_px(context.gfx.scale()); + self.mounted + .synchronize_with(&self.children, &mut context.as_event_context()); + + let mut y = Px::ZERO; + let mut row_children = Vec::new(); + let mut index = 0; + let width = available_space.width.max().into_signed(); + let child_constraints = + available_space.map(|limit| ConstraintLimit::SizeToFit(limit.max())); + while index < self.mounted.children().len() { + if y != Px::ZERO { + y += spacing.height; + } + // Find all children that can fit on this next row. + let mut x = Px::ZERO; + let mut max_height = Px::ZERO; + while let Some(child) = self.mounted.children().get(index) { + let child_size = context + .for_other(child) + .layout(child_constraints) + .into_signed(); + max_height = max_height.max(child_size.height); + + let child_x = if x.is_zero() { + x + } else { + x.saturating_add(spacing.width) + }; + let after_child = child_x.saturating_add(child_size.width); + + if x > 0 && after_child > width { + break; + } + + row_children.push(RowChild { + index, + x: child_x, + size: child_size, + }); + + x = after_child; + index += 1; + } + + // Calculate the horizontal alignment. + let remaining = (width - x).max(Px::ZERO); + let (x, space_between) = if remaining > 0 { + match (align, order) { + (WrapAlign::Start, HorizontalOrder::LeftToRight) + | (WrapAlign::End, HorizontalOrder::RightToLeft) => (Px::ZERO, Px::ZERO), + (WrapAlign::End, HorizontalOrder::LeftToRight) + | (WrapAlign::Start, HorizontalOrder::RightToLeft) => (remaining, Px::ZERO), + (WrapAlign::Center, _) => (remaining / 2, Px::ZERO), + (WrapAlign::SpaceBetween, _) => { + if row_children.len() > 1 { + (Px::ZERO, remaining / (row_children.len() - 1).cast::()) + } else { + (Px::ZERO, Px::ZERO) + } + } + (WrapAlign::SpaceEvenly, _) => { + let spacing = remaining / row_children.len().cast::(); + (spacing / 2, spacing) + } + (WrapAlign::SpaceAround, _) => { + let spacing = remaining / (row_children.len() + 1).cast::(); + (spacing, spacing) + } + } + } else { + (Px::ZERO, Px::ZERO) + }; + + // Position the children + let mut additional_x = x; + for (child_index, child) in row_children.drain(..).enumerate() { + if child_index > 0 { + additional_x += space_between; + } + let child_x = additional_x + child.x; + let child_y = y + match vertical_align { + VerticalAlign::Top => Px::ZERO, + VerticalAlign::Middle => (max_height - child.size.height) / 2, + VerticalAlign::Bottom => max_height - child.size.height, + }; + + context.set_child_layout( + &self.mounted.children()[child.index], + Rect::new(Point::new(child_x, child_y), child.size), + ); + } + + y += max_height; + } + + Size::new(width, y).into_unsigned() + } +} + +/// The horizontal alignment to apply to widgets inside of a [`Wrap`]. +#[derive(Default, Debug, Clone, Copy, Eq, PartialEq)] +pub enum WrapAlign { + /// Position the widgets at the start of the line, honoring [`LayoutOrder`]. + #[default] + Start, + /// Position the widgets at the end of the line, honoring [`LayoutOrder`]. + End, + /// Position the widgets centered on the line. + Center, + /// Position the widgets evenly along the line with no space before the + /// first widget or after the last widget. + SpaceBetween, + /// Position the widgets evenly along the line with half of the amount of + /// spacing used between the widgets placed at the start and end of the + /// line. + SpaceEvenly, + /// Position the widgets evenly along the line with an equal amount of + /// spacing used between the widgets placed at the start and end of the + /// line. + SpaceAround, +} + +/// Alignment along the vertical axis. +#[derive(Default, Debug, Clone, Copy, Eq, PartialEq)] +pub enum VerticalAlign { + /// Align towards the top. + Top, + /// Align towards the middle/center. + Middle, + + /// Align towards the bottom. + // TODO the default should be `Baseline`, but that requires a refactor for + // layout() to return something other than a Size. + #[default] + Bottom, +} diff --git a/src/window.rs b/src/window.rs index 046de08..c87929e 100644 --- a/src/window.rs +++ b/src/window.rs @@ -690,6 +690,12 @@ where ), gfx: Exclusive::Owned(Graphics::new(graphics, &mut self.fonts)), }; + if self.initial_frame { + self.root + .lock() + .as_widget() + .mounted(&mut context.as_event_context()); + } self.theme_mode.redraw_when_changed(&context); let mut layout_context = LayoutContext::new(&mut context); let window_size = layout_context.gfx.size();