From 8d082ab77f462a9798739fe0ddc39476b79231e2 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 11 Sep 2024 20:52:08 -0700 Subject: [PATCH] Modal layer --- CHANGELOG.md | 2 + examples/modal.rs | 30 +++++++++++ src/styles/components.rs | 2 + src/widget.rs | 2 +- src/widgets/layers.rs | 107 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 examples/modal.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8ccec24..8c78cc6b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `WindowHandle::execute` executes a function on the window's thread providing access to an `EventContext`. This can be used to gain access to the window directly, including getting a reference to the underlying winit Window. +- `Modal` is a new layer widget that presents a single widget as a modal + session. [139]: https://github.com/khonsulabs/cushy/issues/139 diff --git a/examples/modal.rs b/examples/modal.rs new file mode 100644 index 000000000..59c912df3 --- /dev/null +++ b/examples/modal.rs @@ -0,0 +1,30 @@ +use cushy::widget::MakeWidget; +use cushy::widgets::layers::Modal; +use cushy::Run; + +fn main() -> cushy::Result { + let modal = Modal::new(); + + "Show Modal" + .into_button() + .on_click({ + let modal = modal.clone(); + move |_| { + modal.present(dialog(&modal)); + } + }) + .align_top() + .and(modal) + .into_layers() + .run() +} + +fn dialog(modal: &Modal) -> impl MakeWidget { + let modal = modal.clone(); + "This is a modal" + .and("Dismiss".into_button().on_click(move |_| { + modal.dismiss(); + })) + .into_rows() + .contain() +} diff --git a/src/styles/components.rs b/src/styles/components.rs index 5b4cb19b5..bcc10a910 100644 --- a/src/styles/components.rs +++ b/src/styles/components.rs @@ -243,6 +243,8 @@ define_components! { /// A [`Color`] to be used as a background color for widgets that render an /// opaque background. OpaqueWidgetColor(Color, "opaque_color", .surface.opaque_widget) + /// A [`Color`] to be use for the transparent surface behind an overlay. + ScrimColor(Color, "scrim_color", |context| context.theme_pair().scrim.with_alpha(50)) /// A set of radius descriptions for how much roundness to apply to the /// shapes of widgets. CornerRadius(CornerRadii, "corner_radius", CornerRadii::from(Dimension::Lp(Lp::points(6)))) diff --git a/src/widget.rs b/src/widget.rs index 007957e73..8120aa3ff 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -2575,7 +2575,7 @@ impl WidgetRef { self.mounted_for_context(context).clone() } - /// Returns this child, mounting it in the process if necessary. + /// Returns this child, if it has been mounted. #[must_use] pub fn as_mounted(&self, context: &WidgetContext<'_>) -> Option<&MountedWidget> { self.mounted.get(context) diff --git a/src/widgets/layers.rs b/src/widgets/layers.rs index f666b52fb..54a60bb0d 100644 --- a/src/widgets/layers.rs +++ b/src/widgets/layers.rs @@ -7,16 +7,17 @@ use alot::{LotId, OrderedLots}; use cushy::widget::{RootBehavior, WidgetInstance}; use easing_function::EasingFunction; use figures::units::{Lp, Px, UPx}; -use figures::{IntoSigned, IntoUnsigned, Point, Rect, Size, Zero}; +use figures::{IntoComponents, IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size, Zero}; use intentional::Assert; +use super::super::widget::MountedWidget; use crate::animation::{AnimationHandle, AnimationTarget, IntoAnimate, Spawn, ZeroToOne}; use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContext, Trackable}; -use crate::styles::components::EasingIn; +use crate::styles::components::{EasingIn, IntrinsicPadding, ScrimColor}; use crate::value::{Destination, Dynamic, DynamicGuard, IntoValue, Source, Value}; use crate::widget::{ - Callback, MakeWidget, MountedChildren, MountedWidget, SharedCallback, Widget, WidgetId, - WidgetList, WidgetRef, WrapperWidget, + Callback, MakeWidget, MountedChildren, SharedCallback, Widget, WidgetId, WidgetList, WidgetRef, + WrapperWidget, }; use crate::widgets::container::ContainerShadow; use crate::ConstraintLimit; @@ -884,3 +885,101 @@ impl WrapperWidget for Tooltipped { self.data.shown_tooltip.set(None); } } + +/// A layer to present a widget in a modal session. +/// +/// Designed to be used in a [`Layers`] widget. +#[derive(Debug, Clone, Default)] +pub struct Modal { + modal: Dynamic>, +} + +impl Modal { + /// Returns a new modal layer. + #[must_use] + pub fn new() -> Self { + Self { + modal: Dynamic::default(), + } + } + + /// Presents `contents` as the modal session. + pub fn present(&self, contents: impl MakeWidget) { + self.modal.set(Some(contents.make_widget())); + } + + /// Dismisses the modal session. + pub fn dismiss(&self) { + self.modal.set(None); + } + + /// Returns true if this layer is currently presenting a modal session. + #[must_use] + pub fn visible(&self) -> bool { + self.modal.map_ref(Option::is_some) + } +} + +impl MakeWidget for Modal { + fn make_widget(self) -> WidgetInstance { + ModalLayer { + presented: None, + modal: self.modal, + } + .make_widget() + } +} + +#[derive(Debug)] +struct ModalLayer { + presented: Option, + modal: Dynamic>, +} + +impl Widget for ModalLayer { + fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) { + if let Some(presented) = &self.presented { + let bg = context.get(&ScrimColor); + context.fill(bg); + context.for_other(presented).redraw(); + } + } + + fn layout( + &mut self, + available_space: Size, + context: &mut LayoutContext<'_, '_, '_, '_>, + ) -> Size { + let modal = self.modal.get_tracking_invalidate(context); + if self.presented.as_ref().map(MountedWidget::instance) != modal.as_ref() { + if let Some(presented) = self.presented.take() { + context.remove_child(&presented); + } + self.presented = modal.map(|modal| { + let mounted = context.push_child(modal); + context.for_other(&mounted).focus(); + mounted + }); + } + let full_area = available_space.map(ConstraintLimit::max); + if let Some(child) = &self.presented { + let padding = context.get(&IntrinsicPadding); + let layout_size = full_area - Size::squared(padding.into_upx(context.gfx.scale())); + let child_size = context + .for_other(child) + .layout(layout_size.map(ConstraintLimit::SizeToFit)) + .into_signed(); + let margin = full_area.into_signed() - child_size; + context.set_child_layout( + child, + Rect::new(margin.to_vec::>() / 2, child_size), + ); + } + + full_area + } + + fn hit_test(&mut self, _location: Point, _context: &mut EventContext<'_>) -> bool { + self.presented.is_some() + } +}