diff --git a/masonry/examples/grid_masonry.rs b/masonry/examples/grid_masonry.rs new file mode 100644 index 000000000..95ffdfe0d --- /dev/null +++ b/masonry/examples/grid_masonry.rs @@ -0,0 +1,121 @@ +// Copyright 2024 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Shows how to use a grid layout in Masonry. + +// On Windows platform, don't show a console when opening the app. +#![windows_subsystem = "windows"] + +use masonry::app_driver::{AppDriver, DriverCtx}; +use masonry::dpi::LogicalSize; +use masonry::widget::{Button, Grid, GridParams, Prose, RootWidget, SizedBox}; +use masonry::{Action, Color, PointerButton, WidgetId}; +use parley::layout::Alignment; +use winit::window::Window; + +struct Driver { + grid_spacing: f64, +} + +impl AppDriver for Driver { + fn on_action(&mut self, ctx: &mut DriverCtx<'_>, _widget_id: WidgetId, action: Action) { + if let Action::ButtonPressed(button) = action { + if button == PointerButton::Primary { + self.grid_spacing += 1.0; + } else if button == PointerButton::Secondary { + self.grid_spacing -= 1.0; + } else { + self.grid_spacing += 0.5; + } + + ctx.get_root::>() + .get_element() + .set_spacing(self.grid_spacing); + } + } +} + +fn grid_button(params: GridParams) -> Button { + Button::new(format!( + "X: {}, Y: {}, W: {}, H: {}", + params.x, params.y, params.width, params.height + )) +} + +pub fn main() { + let label = SizedBox::new( + Prose::new("Change spacing by right and left clicking on the buttons") + .with_text_size(14.0) + .with_text_alignment(Alignment::Middle), + ) + .border(Color::rgb8(40, 40, 80), 1.0); + let button_inputs = vec![ + GridParams { + x: 0, + y: 0, + width: 1, + height: 1, + }, + GridParams { + x: 2, + y: 0, + width: 2, + height: 1, + }, + GridParams { + x: 0, + y: 1, + width: 1, + height: 2, + }, + GridParams { + x: 1, + y: 1, + width: 2, + height: 2, + }, + GridParams { + x: 3, + y: 1, + width: 1, + height: 1, + }, + GridParams { + x: 3, + y: 2, + width: 1, + height: 1, + }, + GridParams { + x: 0, + y: 3, + width: 4, + height: 1, + }, + ]; + + let driver = Driver { grid_spacing: 1.0 }; + + // Arrange widgets in a 4 by 4 grid. + let mut main_widget = Grid::with_dimensions(4, 4) + .with_spacing(driver.grid_spacing) + .with_child(label, GridParams::new(1, 0, 1, 1)); + for button_input in button_inputs { + let button = grid_button(button_input); + main_widget = main_widget.with_child(button, button_input); + } + + let window_size = LogicalSize::new(800.0, 500.0); + let window_attributes = Window::default_attributes() + .with_title("Grid Layout") + .with_resizable(true) + .with_min_inner_size(window_size); + + masonry::event_loop_runner::run( + masonry::event_loop_runner::EventLoop::with_user_event(), + window_attributes, + RootWidget::new(main_widget), + driver, + ) + .unwrap(); +} diff --git a/masonry/src/widget/flex.rs b/masonry/src/widget/flex.rs index cf0edd8e9..3d4a7be01 100644 --- a/masonry/src/widget/flex.rs +++ b/masonry/src/widget/flex.rs @@ -524,7 +524,7 @@ impl<'a> WidgetMut<'a, Flex> { /// /// # Panics /// - /// Panics if the the element at `idx` is not a widget. + /// Panics if the element at `idx` is not a widget. pub fn update_child_flex_params(&mut self, idx: usize, params: impl Into) { let child = &mut self.widget.children[idx]; let child_val = std::mem::replace(child, Child::FixedSpacer(0.0, 0.0)); diff --git a/masonry/src/widget/grid.rs b/masonry/src/widget/grid.rs new file mode 100644 index 000000000..ec0cec36a --- /dev/null +++ b/masonry/src/widget/grid.rs @@ -0,0 +1,426 @@ +// Copyright 2024 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +use accesskit::Role; +use smallvec::SmallVec; +use tracing::{trace_span, Span}; +use vello::kurbo::{Affine, Line, Stroke}; +use vello::Scene; + +use crate::theme::get_debug_color; +use crate::widget::WidgetMut; +use crate::{ + AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx, PaintCtx, + Point, PointerEvent, Size, StatusChange, TextEvent, Widget, WidgetId, WidgetPod, +}; + +pub struct Grid { + children: Vec, + grid_width: i32, + grid_height: i32, + grid_spacing: f64, +} + +struct Child { + widget: WidgetPod>, + x: i32, + y: i32, + width: i32, + height: i32, +} + +#[derive(Default, Debug, Copy, Clone, PartialEq)] +pub struct GridParams { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +// --- MARK: IMPL GRID --- +impl Grid { + pub fn with_dimensions(width: i32, height: i32) -> Self { + Grid { + children: Vec::new(), + grid_width: width, + grid_height: height, + grid_spacing: 0.0, + } + } + + pub fn with_spacing(mut self, spacing: f64) -> Self { + self.grid_spacing = spacing; + self + } + + /// Builder-style variant of [`WidgetMut::add_child`]. + /// + /// Convenient for assembling a group of widgets in a single expression. + pub fn with_child(self, child: impl Widget, params: GridParams) -> Self { + self.with_child_pod(WidgetPod::new(Box::new(child)), params) + } + + pub fn with_child_id(self, child: impl Widget, id: WidgetId, params: GridParams) -> Self { + self.with_child_pod(WidgetPod::new_with_id(Box::new(child), id), params) + } + + pub fn with_child_pod( + mut self, + widget: WidgetPod>, + params: GridParams, + ) -> Self { + let child = Child { + widget, + x: params.x, + y: params.y, + width: params.width, + height: params.height, + }; + self.children.push(child); + self + } +} + +// --- MARK: IMPL CHILD --- +impl Child { + fn widget_mut(&mut self) -> Option<&mut WidgetPod>> { + Some(&mut self.widget) + } + fn widget(&self) -> Option<&WidgetPod>> { + Some(&self.widget) + } + + fn update_params(&mut self, params: GridParams) { + self.x = params.x; + self.y = params.y; + self.width = params.width; + self.height = params.height; + } +} + +fn new_grid_child(params: GridParams, widget: WidgetPod>) -> Child { + Child { + widget, + x: params.x, + y: params.y, + width: params.width, + height: params.height, + } +} + +// --- MARK: IMPL GRIDPARAMS --- +impl GridParams { + pub fn new(mut x: i32, mut y: i32, mut width: i32, mut height: i32) -> GridParams { + if x < 0 { + debug_panic!("Grid x value should be a non-negative number; got {}", x); + x = 0; + } + if y < 0 { + debug_panic!("Grid y value should be a non-negative number; got {}", y); + y = 0; + } + if width <= 0 { + debug_panic!( + "Grid width value should be a positive nonzero number; got {}", + width + ); + width = 1; + } + if height <= 0 { + debug_panic!( + "Grid height value should be a positive nonzero number; got {}", + height + ); + height = 1; + } + GridParams { + x, + y, + width, + height, + } + } +} + +// --- MARK: WIDGETMUT--- +impl<'a> WidgetMut<'a, Grid> { + /// Add a child widget. + /// + /// See also [`with_child`]. + /// + /// [`with_child`]: Grid::with_child + pub fn add_child(&mut self, child: impl Widget, params: GridParams) { + let child_pod: WidgetPod> = WidgetPod::new(Box::new(child)); + self.insert_child_pod(child_pod, params); + } + + pub fn add_child_id(&mut self, child: impl Widget, id: WidgetId, params: GridParams) { + let child_pod: WidgetPod> = WidgetPod::new_with_id(Box::new(child), id); + self.insert_child_pod(child_pod, params); + } + + /// Add a child widget. + pub fn insert_child_pod(&mut self, widget: WidgetPod>, params: GridParams) { + let child = new_grid_child(params, widget); + self.widget.children.push(child); + self.ctx.children_changed(); + self.ctx.request_layout(); + } + + pub fn insert_grid_child_at( + &mut self, + idx: usize, + child: impl Widget, + params: impl Into, + ) { + self.insert_grid_child_pod(idx, WidgetPod::new(Box::new(child)), params); + } + + pub fn insert_grid_child_pod( + &mut self, + idx: usize, + child: WidgetPod>, + params: impl Into, + ) { + let child = new_grid_child(params.into(), child); + self.widget.children.insert(idx, child); + self.ctx.children_changed(); + self.ctx.request_layout(); + } + + pub fn set_spacing(&mut self, spacing: f64) { + self.widget.grid_spacing = spacing; + self.ctx.request_layout(); + } + + pub fn set_width(&mut self, width: i32) { + self.widget.grid_width = width; + self.ctx.request_layout(); + } + + pub fn set_height(&mut self, height: i32) { + self.widget.grid_height = height; + self.ctx.request_layout(); + } + + pub fn child_mut(&mut self, idx: usize) -> Option>> { + let child = match self.widget.children[idx].widget_mut() { + Some(widget) => widget, + None => return None, + }; + + Some(self.ctx.get_mut(child)) + } + + /// Updates the grid parameters for the child at `idx`, + /// + /// # Panics + /// + /// Panics if the element at `idx` is not a widget. + pub fn update_child_grid_params(&mut self, idx: usize, params: GridParams) { + let child = &mut self.widget.children[idx]; + child.update_params(params); + self.ctx.request_layout(); + } + + pub fn remove_child(&mut self, idx: usize) { + let child = self.widget.children.remove(idx); + self.ctx.remove_child(child.widget); + self.ctx.request_layout(); + } +} + +// --- MARK: IMPL WIDGET--- +impl Widget for Grid { + fn on_pointer_event(&mut self, _ctx: &mut EventCtx, _event: &PointerEvent) {} + + fn on_text_event(&mut self, _ctx: &mut EventCtx, _event: &TextEvent) {} + + fn on_access_event(&mut self, _ctx: &mut EventCtx, _event: &AccessEvent) {} + + fn on_status_change(&mut self, _ctx: &mut LifeCycleCtx, _event: &StatusChange) {} + + fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle) { + for child in self.children.iter_mut().filter_map(|x| x.widget_mut()) { + child.lifecycle(ctx, event); + } + } + + fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size { + bc.debug_check("Grid"); + let total_size = bc.max(); + let width_unit = (total_size.width + self.grid_spacing) / (self.grid_width as f64); + let height_unit = (total_size.height + self.grid_spacing) / (self.grid_height as f64); + for child in &mut self.children { + let cell_size = Size::new( + child.width as f64 * width_unit - self.grid_spacing, + child.height as f64 * height_unit - self.grid_spacing, + ); + let child_bc = BoxConstraints::new(cell_size, cell_size); + let _ = ctx.run_layout(&mut child.widget, &child_bc); + ctx.place_child( + &mut child.widget, + Point::new(child.x as f64 * width_unit, child.y as f64 * height_unit), + ); + } + total_size + } + + fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) { + // paint the baseline if we're debugging layout + if ctx.debug_paint && ctx.widget_state.baseline_offset != 0.0 { + let color = get_debug_color(ctx.widget_id().to_raw()); + let my_baseline = ctx.size().height - ctx.widget_state.baseline_offset; + let line = Line::new((0.0, my_baseline), (ctx.size().width, my_baseline)); + + let stroke_style = Stroke::new(1.0).with_dashes(0., [4.0, 4.0]); + scene.stroke(&stroke_style, Affine::IDENTITY, color, None, &line); + } + } + + fn accessibility_role(&self) -> Role { + Role::GenericContainer + } + + fn accessibility(&mut self, _: &mut AccessCtx) {} + + fn children_ids(&self) -> SmallVec<[WidgetId; 16]> { + self.children + .iter() + .filter_map(|child| child.widget()) + .map(|widget_pod| widget_pod.id()) + .collect() + } + + fn make_trace_span(&self) -> Span { + trace_span!("Grid") + } +} + +// --- MARK: TESTS --- +#[cfg(test)] +mod tests { + use super::*; + use crate::assert_render_snapshot; + use crate::testing::TestHarness; + use crate::widget::button; + + #[test] + fn test_grid_basics() { + // Start with a 1x1 grid + let widget = Grid::with_dimensions(1, 1) + .with_child(button::Button::new("A"), GridParams::new(0, 0, 1, 1)); + let mut harness = TestHarness::create(widget); + // Snapshot with the single widget. + assert_render_snapshot!(harness, "initial_1x1"); + + // Expand it to a 4x4 grid + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.set_width(4); + }); + assert_render_snapshot!(harness, "expanded_4x1"); + + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.set_height(4); + }); + assert_render_snapshot!(harness, "expanded_4x4"); + + // Add a widget that takes up more than one horizontal cell + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.add_child(button::Button::new("B"), GridParams::new(1, 0, 3, 1)); + }); + assert_render_snapshot!(harness, "with_horizontal_widget"); + + // Add a widget that takes up more than one vertical cell + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.add_child(button::Button::new("C"), GridParams::new(0, 1, 1, 3)); + }); + assert_render_snapshot!(harness, "with_vertical_widget"); + + // Add a widget that takes up more than one horizontal and vertical cell + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.add_child(button::Button::new("D"), GridParams::new(1, 1, 2, 2)); + }); + assert_render_snapshot!(harness, "with_2x2_widget"); + + // Change the spacing + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.set_spacing(7.0); + }); + assert_render_snapshot!(harness, "with_changed_spacing"); + + // Make the spacing negative + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.set_spacing(-4.0); + }); + assert_render_snapshot!(harness, "with_negative_spacing"); + } + + #[test] + fn test_widget_removal_and_modification() { + let widget = Grid::with_dimensions(2, 2) + .with_child(button::Button::new("A"), GridParams::new(0, 0, 1, 1)); + let mut harness = TestHarness::create(widget); + // Snapshot with the single widget. + assert_render_snapshot!(harness, "initial_2x2"); + + // Now remove the widget + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.remove_child(0); + }); + assert_render_snapshot!(harness, "2x2_with_removed_widget"); + + // Add it back + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.add_child(button::Button::new("A"), GridParams::new(0, 0, 1, 1)); + }); + assert_render_snapshot!(harness, "initial_2x2"); // Should be back to the original state + + // Change the grid params to position it on the other corner + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.update_child_grid_params(0, GridParams::new(1, 1, 1, 1)); + }); + assert_render_snapshot!(harness, "moved_2x2_1"); + + // Now make it take up the entire grid + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.update_child_grid_params(0, GridParams::new(0, 0, 2, 2)); + }); + assert_render_snapshot!(harness, "moved_2x2_2"); + } + + #[test] + fn test_widget_order() { + let widget = Grid::with_dimensions(2, 2) + .with_child(button::Button::new("A"), GridParams::new(0, 0, 1, 1)); + let mut harness = TestHarness::create(widget); + // Snapshot with the single widget. + assert_render_snapshot!(harness, "initial_2x2"); + + // Order sets the draw order, so draw a widget over A by adding it after + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.add_child(button::Button::new("B"), GridParams::new(0, 0, 1, 1)); + }); + assert_render_snapshot!(harness, "2x2_with_overlapping_b"); + + // Draw a widget under the others by putting it at index 0 + // Make it wide enough to see it stick out, with half of it under A and B. + harness.edit_root_widget(|mut grid| { + let mut grid = grid.downcast::(); + grid.insert_grid_child_at(0, button::Button::new("C"), GridParams::new(0, 0, 2, 1)); + }); + assert_render_snapshot!(harness, "2x2_with_overlapping_c"); + } +} diff --git a/masonry/src/widget/mod.rs b/masonry/src/widget/mod.rs index 34def0863..7a3720ca2 100644 --- a/masonry/src/widget/mod.rs +++ b/masonry/src/widget/mod.rs @@ -17,6 +17,7 @@ mod align; mod button; mod checkbox; mod flex; +mod grid; mod image; mod label; mod portal; @@ -36,6 +37,7 @@ pub use align::Align; pub use button::Button; pub use checkbox::Checkbox; pub use flex::{Axis, CrossAxisAlignment, Flex, FlexParams, MainAxisAlignment}; +pub use grid::{Grid, GridParams}; pub use label::{Label, LineBreaking}; pub use portal::Portal; pub use progress_bar::ProgressBar; diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__2x2_with_overlapping_b.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__2x2_with_overlapping_b.png new file mode 100644 index 000000000..646ec41f8 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__2x2_with_overlapping_b.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4aef605773c92b01166520d3ca8065116d46540bc3880978b5f248c95c61fd8d +size 5001 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__2x2_with_overlapping_c.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__2x2_with_overlapping_c.png new file mode 100644 index 000000000..ee7ba265f --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__2x2_with_overlapping_c.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec7a39d12493409ed242cc1f41e3d4dce46a96c481d2cdc38d4da185269465e0 +size 5321 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__2x2_with_removed_widget.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__2x2_with_removed_widget.png new file mode 100644 index 000000000..793dd9c82 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__2x2_with_removed_widget.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c1515c193d2b494e792f05b5d088d1566774285e0df448ea9bf364ce03ab712 +size 4472 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__expanded_4x1.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__expanded_4x1.png new file mode 100644 index 000000000..3d3b16bcf --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__expanded_4x1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f7ce584ad5a911bb7bb8c35f3066ac3031c7ade64692896f7586e20c391b624 +size 5052 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__expanded_4x4.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__expanded_4x4.png new file mode 100644 index 000000000..2eee9b135 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__expanded_4x4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58796a0ff44c059d90b9a2e424d390721b5e0052eccbd3b6f003d1739d771440 +size 5186 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__initial_1x1.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__initial_1x1.png new file mode 100644 index 000000000..530021941 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__initial_1x1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f92e5d63eab954b39012854bafa8ce2d9badfdae4304108bd7e290c2b1cb7143 +size 5026 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__initial_2x2.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__initial_2x2.png new file mode 100644 index 000000000..f52465971 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__initial_2x2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33aabab29c8651a7efaf88d7609cb50b93e8864367ddeff54a77dc435bb746b0 +size 5312 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__moved_2x2_1.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__moved_2x2_1.png new file mode 100644 index 000000000..881db94da --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__moved_2x2_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47b741e1bcdc9ad915c2f17f3fededa3435bc089a3fea2309235c913cfce331a +size 5321 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__moved_2x2_2.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__moved_2x2_2.png new file mode 100644 index 000000000..530021941 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__moved_2x2_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f92e5d63eab954b39012854bafa8ce2d9badfdae4304108bd7e290c2b1cb7143 +size 5026 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_2x2_widget.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_2x2_widget.png new file mode 100644 index 000000000..d71393d96 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_2x2_widget.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e52f1f8d696af90e035cbb77f1eda99d14bf6fc2d800866040d0976a7c5d2a25 +size 6693 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_changed_spacing.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_changed_spacing.png new file mode 100644 index 000000000..b8a3b22f6 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_changed_spacing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81afa7e7ef34369d9edfe042b0c60d1eef1b911c098ac625164171b8b7e49e11 +size 6971 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_horizontal_widget.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_horizontal_widget.png new file mode 100644 index 000000000..0930eb878 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_horizontal_widget.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3602efdabe62bdb4592090afcb80256bfe9ba339d5b87f1271f36cbca7c35e0a +size 5609 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_negative_spacing.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_negative_spacing.png new file mode 100644 index 000000000..c8dd392d8 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_negative_spacing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d1c79d02e5f0b22b6326e7926c0af2880cdb9c4e852b04fde63cab9cce10da5 +size 6897 diff --git a/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_vertical_widget.png b/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_vertical_widget.png new file mode 100644 index 000000000..a527d1e32 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__grid__tests__with_vertical_widget.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:871275dca581cd2201a4d88e7bf50a2497f31f87afc953446d1f80293703deee +size 6173 diff --git a/xilem/examples/calc.rs b/xilem/examples/calc.rs index 2ea78f62f..a0973176d 100644 --- a/xilem/examples/calc.rs +++ b/xilem/examples/calc.rs @@ -1,14 +1,14 @@ // Copyright 2024 the Xilem Authors // SPDX-License-Identifier: Apache-2.0 -use masonry::widget::{CrossAxisAlignment, MainAxisAlignment}; +use masonry::widget::{CrossAxisAlignment, GridParams, MainAxisAlignment}; use winit::dpi::LogicalSize; use winit::error::EventLoopError; use winit::window::Window; -use xilem::view::{Flex, FlexSequence}; +use xilem::view::{grid, Flex, FlexSequence, FlexSpacer, GridExt, GridSequence}; use xilem::EventLoopBuilder; use xilem::{ - view::{button, flex, label, sized_box, Axis, FlexExt as _, FlexSpacer}, + view::{button, flex, label, sized_box, Axis}, EventLoop, WidgetView, Xilem, }; @@ -184,55 +184,54 @@ impl Calculator { } } +fn num_row(nums: [&'static str; 3], row: i32) -> impl GridSequence { + let mut views: Vec<_> = vec![]; + for (i, num) in nums.iter().enumerate() { + views.push(digit_button(num).grid_pos(i as i32, row)); + } + views +} + const DISPLAY_FONT_SIZE: f32 = 30.; const GRID_GAP: f64 = 2.; fn app_logic(data: &mut Calculator) -> impl WidgetView { - let num_row = |nums: [&'static str; 3], operator| { - flex_row(( - nums.map(|num| digit_button(num).flex(1.)), - operator_button(operator).flex(1.), - )) - }; - flex(( - // Display - centered_flex_row(( - FlexSpacer::Flex(0.1), - display_label(data.numbers[0].as_ref()), - data.operation - .map(|operation| display_label(operation.as_str())), - display_label(data.numbers[1].as_ref()), - data.result.is_some().then(|| display_label("=")), - data.result - .as_ref() - .map(|result| display_label(result.as_ref())), - FlexSpacer::Flex(0.1), - )) - .flex(1.0), - FlexSpacer::Fixed(10.0), - // Top row - flex_row(( - expanded_button("CE", Calculator::clear_entry).flex(1.), - expanded_button("C", Calculator::clear_all).flex(1.), - expanded_button("DEL", Calculator::on_delete).flex(1.), - operator_button(MathOperator::Divide).flex(1.), - )) - .flex(1.0), - num_row(["7", "8", "9"], MathOperator::Multiply).flex(1.0), - num_row(["4", "5", "6"], MathOperator::Subtract).flex(1.0), - num_row(["1", "2", "3"], MathOperator::Add).flex(1.0), - // bottom row - flex_row(( - expanded_button("±", Calculator::negate).flex(1.), - digit_button("0").flex(1.), - digit_button(".").flex(1.), - expanded_button("=", Calculator::on_equals).flex(1.), - )) - .flex(1.0), - )) - .gap(GRID_GAP) - .cross_axis_alignment(CrossAxisAlignment::Fill) - .main_axis_alignment(MainAxisAlignment::End) - .must_fill_major_axis(true) + grid( + ( + // Display + centered_flex_row(( + FlexSpacer::Flex(0.1), + display_label(data.numbers[0].as_ref()), + data.operation + .map(|operation| display_label(operation.as_str())), + display_label(data.numbers[1].as_ref()), + data.result.is_some().then(|| display_label("=")), + data.result + .as_ref() + .map(|result| display_label(result.as_ref())), + FlexSpacer::Flex(0.1), + )) + .grid_item(GridParams::new(0, 0, 4, 1)), + // Top row + expanded_button("CE", Calculator::clear_entry).grid_pos(0, 1), + expanded_button("C", Calculator::clear_all).grid_pos(1, 1), + expanded_button("DEL", Calculator::on_delete).grid_pos(2, 1), + operator_button(MathOperator::Divide).grid_pos(3, 1), + num_row(["7", "8", "9"], 2), + operator_button(MathOperator::Multiply).grid_pos(3, 2), + num_row(["4", "5", "6"], 3), + operator_button(MathOperator::Subtract).grid_pos(3, 3), + num_row(["1", "2", "3"], 4), + operator_button(MathOperator::Add).grid_pos(3, 4), + // bottom row + expanded_button("±", Calculator::negate).grid_pos(0, 5), + digit_button("0").grid_pos(1, 5), + digit_button(".").grid_pos(2, 5), + expanded_button("=", Calculator::on_equals).grid_pos(3, 5), + ), + 4, + 6, + ) + .spacing(GRID_GAP) } /// Creates a horizontal centered flex row designed for the display portion of the calculator. @@ -244,15 +243,6 @@ pub fn centered_flex_row>(sequence: Seq) -> Flex .gap(5.) } -/// Creates a horizontal filled flex row designed to be used in a grid. -pub fn flex_row>(sequence: Seq) -> Flex { - flex(sequence) - .direction(Axis::Horizontal) - .cross_axis_alignment(CrossAxisAlignment::Fill) - .main_axis_alignment(MainAxisAlignment::SpaceEvenly) - .gap(GRID_GAP) -} - /// Returns a label intended to be used in the calculator's top display. /// The default text size is out of proportion for this use case. fn display_label(text: &str) -> impl WidgetView { diff --git a/xilem/src/view/grid.rs b/xilem/src/view/grid.rs new file mode 100644 index 000000000..44ea99b64 --- /dev/null +++ b/xilem/src/view/grid.rs @@ -0,0 +1,418 @@ +// Copyright 2024 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +use std::marker::PhantomData; + +use masonry::widget::GridParams; +use masonry::{ + widget::{self, WidgetMut}, + Widget, +}; +use xilem_core::{ + AppendVec, DynMessage, ElementSplice, MessageResult, Mut, SuperElement, View, ViewElement, + ViewMarker, ViewSequence, +}; + +use crate::{Pod, ViewCtx, WidgetView}; + +pub fn grid>( + sequence: Seq, + width: i32, + height: i32, +) -> Grid { + Grid { + sequence, + spacing: 0.0, + phantom: PhantomData, + height, + width, + } +} + +pub struct Grid { + sequence: Seq, + spacing: f64, + width: i32, + height: i32, + /// Used to associate the State and Action in the call to `.grid()` with the State and Action + /// used in the View implementation, to allow inference to flow backwards, allowing State and + /// Action to be inferred properly + phantom: PhantomData (State, Action)>, +} + +impl Grid { + #[track_caller] + pub fn spacing(mut self, spacing: f64) -> Self { + if spacing.is_finite() && spacing >= 0.0 { + self.spacing = spacing; + } else { + panic!("Invalid `spacing` {spacing}; expected a non-negative finite value.") + } + self + } +} + +impl ViewMarker for Grid {} + +impl View for Grid +where + State: 'static, + Action: 'static, + Seq: GridSequence, +{ + type Element = Pod; + + type ViewState = Seq::SeqState; + + fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) { + let mut elements = AppendVec::default(); + let mut widget = widget::Grid::with_dimensions(self.width, self.height); + widget = widget.with_spacing(self.spacing); + let seq_state = self.sequence.seq_build(ctx, &mut elements); + for child in elements.into_inner() { + widget = match child { + GridElement::Child(child, params) => widget.with_child_pod(child.inner, params), + } + } + (Pod::new(widget), seq_state) + } + + fn rebuild<'el>( + &self, + prev: &Self, + view_state: &mut Self::ViewState, + ctx: &mut ViewCtx, + mut element: Mut<'el, Self::Element>, + ) -> Mut<'el, Self::Element> { + if prev.height != self.height { + element.set_height(self.height); + ctx.mark_changed(); + } + if prev.width != self.width { + element.set_width(self.width); + ctx.mark_changed(); + } + if prev.spacing != self.spacing { + element.set_spacing(self.spacing); + ctx.mark_changed(); + } + + let mut splice = GridSplice::new(element); + self.sequence + .seq_rebuild(&prev.sequence, view_state, ctx, &mut splice); + debug_assert!(splice.scratch.is_empty()); + splice.element + } + + fn teardown( + &self, + view_state: &mut Self::ViewState, + ctx: &mut ViewCtx, + element: Mut<'_, Self::Element>, + ) { + let mut splice = GridSplice::new(element); + self.sequence.seq_teardown(view_state, ctx, &mut splice); + debug_assert!(splice.scratch.into_inner().is_empty()); + } + + fn message( + &self, + view_state: &mut Self::ViewState, + id_path: &[xilem_core::ViewId], + message: DynMessage, + app_state: &mut State, + ) -> MessageResult { + self.sequence + .seq_message(view_state, id_path, message, app_state) + } +} + +// Used to become a reference form for editing. It's provided to rebuild and teardown. +impl ViewElement for GridElement { + type Mut<'w> = GridElementMut<'w>; +} + +// Used to allow the item to be used as a generic item in ViewSequence. +impl SuperElement for GridElement { + fn upcast(child: GridElement) -> Self { + child + } + + fn with_downcast_val( + mut this: Mut<'_, Self>, + f: impl FnOnce(Mut<'_, GridElement>) -> R, + ) -> (Self::Mut<'_>, R) { + let r = { + let parent = this.parent.reborrow_mut(); + let reborrow = GridElementMut { + idx: this.idx, + parent, + }; + f(reborrow) + }; + (this, r) + } +} + +impl SuperElement> for GridElement { + fn upcast(child: Pod) -> Self { + // Getting here means that the widget didn't use .grid_item or .grid_pos. + // This currently places the widget in the top left cell. + // There is not much else, beyond purposefully failing, that can be done here, + // because there isn't enough information to determine an appropriate spot + // for the widget. + GridElement::Child(child.inner.boxed().into(), GridParams::new(1, 1, 1, 1)) + } + + fn with_downcast_val( + mut this: Mut<'_, Self>, + f: impl FnOnce(Mut<'_, Pod>) -> R, + ) -> (Mut<'_, Self>, R) { + let ret = { + let mut child = this + .parent + .child_mut(this.idx) + .expect("This is supposed to be a widget"); + let downcast = child.downcast(); + f(downcast) + }; + + (this, ret) + } +} + +// Used for building and rebuilding the ViewSequence +impl ElementSplice for GridSplice<'_> { + fn with_scratch(&mut self, f: impl FnOnce(&mut AppendVec) -> R) -> R { + let ret = f(&mut self.scratch); + for element in self.scratch.drain() { + match element { + GridElement::Child(child, params) => { + self.element + .insert_grid_child_pod(self.idx, child.inner, params); + } + }; + self.idx += 1; + } + ret + } + + fn insert(&mut self, element: GridElement) { + match element { + GridElement::Child(child, params) => { + self.element + .insert_grid_child_pod(self.idx, child.inner, params); + } + }; + self.idx += 1; + } + + fn mutate(&mut self, f: impl FnOnce(Mut<'_, GridElement>) -> R) -> R { + let child = GridElementMut { + parent: self.element.reborrow_mut(), + idx: self.idx, + }; + let ret = f(child); + self.idx += 1; + ret + } + + fn skip(&mut self, n: usize) { + self.idx += n; + } + + fn delete(&mut self, f: impl FnOnce(Mut<'_, GridElement>) -> R) -> R { + let ret = { + let child = GridElementMut { + parent: self.element.reborrow_mut(), + idx: self.idx, + }; + f(child) + }; + self.element.remove_child(self.idx); + ret + } +} + +/// `GridSequence` is what allows an input to the grid that contains all the grid elements. +pub trait GridSequence: + ViewSequence +{ +} + +impl GridSequence for Seq where + Seq: ViewSequence +{ +} + +/// A trait which extends a [`WidgetView`] with methods to provide parameters for a grid item +pub trait GridExt: WidgetView { + /// Applies [`impl Into`](`GridParams`) to this view. This allows the view + /// to be placed as a child within a [`Grid`] [`View`]. + /// + /// # Examples + /// ``` + /// use masonry::widget::GridParams; + /// use xilem::{view::{button, prose, grid, GridExt}}; + /// # use xilem::{WidgetView}; + /// + /// # fn view() -> impl WidgetView { + /// grid(( + /// button("click me", |_| ()).grid_item(GridParams::new(0, 0, 2, 1)), + /// prose("a prose").grid_item(GridParams::new(1, 1, 1, 1)), + /// ), 2, 2) + /// # } + /// ``` + fn grid_item(self, params: impl Into) -> GridItem + where + State: 'static, + Action: 'static, + Self: Sized, + { + grid_item(self, params) + } + + /// Applies a [`impl Into`](`GridParams`) with the specified position to this view. + /// This allows the view to be placed as a child within a [`Grid`] [`View`]. + /// For instances where a grid item is expected to take up multiple cell units, + /// use [`GridExt::grid_item`] + /// + /// # Examples + /// ``` + /// use masonry::widget::GridParams; + /// use xilem::{view::{button, prose, grid, GridExt}}; + /// # use xilem::{WidgetView}; + /// + /// # fn view() -> impl WidgetView { + /// grid(( + /// button("click me", |_| ()).grid_pos(0, 0), + /// prose("a prose").grid_pos(1, 1), + /// ), 2, 2) + /// # } + fn grid_pos(self, x: i32, y: i32) -> GridItem + where + State: 'static, + Action: 'static, + Self: Sized, + { + grid_item(self, GridParams::new(x, y, 1, 1)) + } +} + +impl> GridExt for V {} + +pub enum GridElement { + Child(Pod>, GridParams), +} + +pub struct GridElementMut<'w> { + parent: WidgetMut<'w, widget::Grid>, + idx: usize, +} + +// Used for manipulating the ViewSequence. +pub struct GridSplice<'w> { + idx: usize, + element: WidgetMut<'w, widget::Grid>, + scratch: AppendVec, +} + +impl<'w> GridSplice<'w> { + fn new(element: WidgetMut<'w, widget::Grid>) -> Self { + Self { + idx: 0, + element, + scratch: AppendVec::default(), + } + } +} + +/// A `WidgetView` that can be used within a [`Grid`] [`View`] +pub struct GridItem { + view: V, + params: GridParams, + phantom: PhantomData (State, Action)>, +} + +pub fn grid_item( + view: V, + params: impl Into, +) -> GridItem +where + State: 'static, + Action: 'static, + V: WidgetView, +{ + GridItem { + view, + params: params.into(), + phantom: PhantomData, + } +} + +impl ViewMarker for GridItem {} + +impl View for GridItem +where + State: 'static, + Action: 'static, + V: WidgetView, +{ + type Element = GridElement; + + type ViewState = V::ViewState; + + fn build(&self, cx: &mut ViewCtx) -> (Self::Element, Self::ViewState) { + let (pod, state) = self.view.build(cx); + ( + GridElement::Child(pod.inner.boxed().into(), self.params), + state, + ) + } + + fn rebuild<'el>( + &self, + prev: &Self, + view_state: &mut Self::ViewState, + ctx: &mut ViewCtx, + mut element: Mut<'el, Self::Element>, + ) -> Mut<'el, Self::Element> { + { + if self.params != prev.params { + element + .parent + .update_child_grid_params(element.idx, self.params); + } + let mut child = element + .parent + .child_mut(element.idx) + .expect("GridWrapper always has a widget child"); + self.view + .rebuild(&prev.view, view_state, ctx, child.downcast()); + } + element + } + + fn teardown( + &self, + view_state: &mut Self::ViewState, + ctx: &mut ViewCtx, + mut element: Mut<'_, Self::Element>, + ) { + let mut child = element + .parent + .child_mut(element.idx) + .expect("GridWrapper always has a widget child"); + self.view.teardown(view_state, ctx, child.downcast()); + } + + fn message( + &self, + view_state: &mut Self::ViewState, + id_path: &[xilem_core::ViewId], + message: DynMessage, + app_state: &mut State, + ) -> MessageResult { + self.view.message(view_state, id_path, message, app_state) + } +} diff --git a/xilem/src/view/mod.rs b/xilem/src/view/mod.rs index 5b8485464..24933e884 100644 --- a/xilem/src/view/mod.rs +++ b/xilem/src/view/mod.rs @@ -16,6 +16,9 @@ pub use checkbox::*; mod flex; pub use flex::*; +mod grid; +pub use grid::*; + mod sized_box; pub use sized_box::*;