Nested modals

This commit is contained in:
Jonathan Johnson 2024-10-04 10:17:40 -07:00
parent e78d1d28bf
commit 4f3ef7d9ed
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
11 changed files with 326 additions and 85 deletions

View file

@ -67,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
winit, and naga. Thanks to @bluenote10 for the feedback!
- `WrapperWidget::activate`'s default implementation now activates the wrapped
widget.
- `Space` now intercepts mouse events if its color has a non-zero alpha channel.
### Fixed
@ -91,6 +92,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`Dynamic<T>` change callbacks has been fixed.
- `Stack` no longer unwraps a `Resize` child if the resize widget is resizing in
the direction opposite of the Stack's orientation.
- If the layout of widgets changes during a redraw, the currently hovered widget
is now properly updated immediately. Previously, the hover would only update
on the next cursor event.
### Added
@ -211,6 +215,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Choosing one or more files
- Choosing a single folder/directory
- Choosing one or more folders/directories
- `DynamicGuard::unlocked` executes a closure while the guard is temporarily
unlocked.
[139]: https://github.com/khonsulabs/cushy/issues/139

16
Cargo.lock generated
View file

@ -20,9 +20,9 @@ checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046"
[[package]]
name = "addr2line"
version = "0.24.1"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
dependencies = [
"gimli",
]
@ -75,9 +75,9 @@ checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
[[package]]
name = "alot"
version = "0.3.1"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b072fc284b73a3e4154e2decdbaad711daca0e8fedfceb0d7b1cbe2dffb00e2b"
checksum = "4c7a3dc3ad32931b2d6e97c99a702208dfd1e2c446580e5f99d1d8355df26db6"
[[package]]
name = "android-activity"
@ -1382,9 +1382,9 @@ dependencies = [
[[package]]
name = "gimli"
version = "0.31.0"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "gl_generator"
@ -2403,9 +2403,9 @@ dependencies = [
[[package]]
name = "object"
version = "0.36.4"
version = "0.36.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a"
checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e"
dependencies = [
"memchr",
]

View file

@ -28,7 +28,7 @@ kludgine = { git = "https://github.com/khonsulabs/kludgine", features = [
"app",
] }
figures = { version = "0.4.0" }
alot = "0.3"
alot = "0.3.2"
interner = "0.2.1"
kempt = "0.2.1"
intentional = "0.1.0"

50
examples/nested-modals.rs Normal file
View file

@ -0,0 +1,50 @@
use cushy::dialog::MessageBox;
use cushy::widget::MakeWidget;
use cushy::widgets::layers::{Modal, ModalTarget};
use cushy::Run;
fn main() -> cushy::Result {
let modal = Modal::new();
"Show Modal"
.into_button()
.on_click({
let modal = modal.clone();
move |_| show_modal(&modal, 1)
})
.align_top()
.pad()
.and(modal)
.into_layers()
.run()
}
fn show_modal(present_in: &impl ModalTarget, level: usize) {
let handle = present_in.pending_handle();
handle
.build_dialog(
format!("Modal level: {level}")
.and("Go Deeper".into_button().on_click({
let handle = handle.clone();
move |_| {
show_modal(&handle, level + 1);
}
}))
.and("Show message".into_button().on_click({
let handle = handle.clone();
move |_| {
MessageBox::message("This is a MessageBox shown above a modal")
.open(&handle);
}
}))
.into_rows(),
)
.with_default_button("Close", || {})
.with_cancel_button("Close All", {
let handle = handle.clone();
move || {
handle.layer().dismiss();
}
})
.show();
}

View file

@ -307,7 +307,8 @@ impl<'context> EventContext<'context> {
}
pub(crate) fn update_hovered_widget(&mut self) {
self.cursor.widget = None;
let current_hover = self.cursor.widget.take();
if let Some(location) = self.cursor.location {
for widget in self.tree.widgets_under_point(location) {
let mut widget_context = self.for_other(&widget);
@ -317,7 +318,9 @@ impl<'context> EventContext<'context> {
let relative = location - widget_layout.origin;
if widget_context.hit_test(relative) {
widget_context.hover(location);
if current_hover != Some(widget.id()) {
widget_context.hover(location);
}
drop(widget_context);
self.cursor.widget = Some(widget.id());
break;

View file

@ -15,7 +15,7 @@ use crate::value::{Destination, Dynamic, Source};
use crate::widget::{MakeWidget, OnceCallback, SharedCallback, WidgetList};
use crate::widgets::button::{ButtonKind, ClickCounter};
use crate::widgets::input::InputValue;
use crate::widgets::layers::Modal;
use crate::widgets::layers::{Modal, ModalTarget};
use crate::widgets::Custom;
use crate::ModifiersExt;
@ -274,7 +274,10 @@ impl MessageBox {
/// Opens this dialog in the given target.
///
/// A target can be a [`Modal`] layer, a [`WindowHandle`], or an [`App`].
/// A target can be a [`Modal`] layer, a
/// [`ModalHandle`](crate::widgets::layers::ModalHandle), a
/// [`WindowHandle`](crate::window::WindowHandle), or an
/// [`App`](crate::App).
pub fn open(&self, open_in: &impl OpenMessageBox) {
open_in.open_message_box(self);
}
@ -294,9 +297,13 @@ fn coalesce_empty<'a>(s1: &'a str, s2: &'a str) -> &'a str {
}
}
impl OpenMessageBox for Modal {
impl<T> OpenMessageBox for T
where
T: ModalTarget,
{
fn open_message_box(&self, message: &MessageBox) {
let dialog = self.build_dialog(
let handle = self.pending_handle();
let dialog = handle.build_dialog(
message
.title
.as_str()
@ -716,7 +723,9 @@ impl MakeWidget for FilePickerWidget {
};
let chosen_paths = Dynamic::<Vec<PathBuf>>::default();
let confirm_enabled = chosen_paths.map_each(|paths| !paths.is_empty());
let confirm_enabled = chosen_paths.map_each(move |paths| {
!paths.is_empty() && paths.iter().all(|p| p.is_file() == kind.is_file())
});
let browsing_directory = Dynamic::new(
self.picker

View file

@ -1536,11 +1536,12 @@ where
}
impl<'a, T> DynamicMutexGuard<'a, T> {
fn unlocked(&mut self, while_unlocked: impl FnOnce()) {
fn unlocked<R>(&mut self, while_unlocked: impl FnOnce() -> R) -> R {
let previous_state = self.dynamic.during_callback_state.lock().take();
MutexGuard::unlocked(&mut self.guard, while_unlocked);
let result = MutexGuard::unlocked(&mut self.guard, while_unlocked);
*self.dynamic.during_callback_state.lock() = previous_state;
result
}
}
@ -2196,7 +2197,7 @@ impl<'a, T> DynamicOrOwnedGuard<'a, T> {
}
}
fn unlocked(&mut self, while_unlocked: impl FnOnce()) {
fn unlocked<R>(&mut self, while_unlocked: impl FnOnce() -> R) -> R {
match self {
Self::Dynamic(guard) => guard.unlocked(while_unlocked),
Self::Owned(_) | Self::OwnedRef(_) => while_unlocked(),
@ -2252,6 +2253,14 @@ impl<T, const READONLY: bool> DynamicGuard<'_, T, READONLY> {
pub fn prevent_notifications(&mut self) {
self.prevent_notifications = true;
}
/// Executes `while_unlocked` while this guard is temporarily unlocked.
pub fn unlocked<F, R>(&mut self, while_unlocked: F) -> R
where
F: FnOnce() -> R,
{
self.guard.unlocked(while_unlocked)
}
}
impl<'a, T, const READONLY: bool> Deref for DynamicGuard<'a, T, READONLY> {

View file

@ -8,17 +8,18 @@ use alot::{LotId, OrderedLots};
use cushy::widget::{RootBehavior, WidgetInstance};
use easing_function::EasingFunction;
use figures::units::{Lp, Px, UPx};
use figures::{IntoComponents, IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size, Zero};
use figures::{IntoSigned, IntoUnsigned, Point, Rect, Size, Zero};
use intentional::Assert;
use super::super::widget::MountedWidget;
use super::Space;
use crate::animation::{AnimationHandle, AnimationTarget, IntoAnimate, Spawn, ZeroToOne};
use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContext, Trackable};
use crate::styles::components::{EasingIn, IntrinsicPadding, ScrimColor};
use crate::value::{Destination, Dynamic, DynamicGuard, IntoValue, Source, Value};
use crate::styles::components::{EasingIn, ScrimColor};
use crate::value::{Destination, Dynamic, DynamicGuard, DynamicRead, IntoValue, Source, Value};
use crate::widget::{
Callback, MakeWidget, MountedChildren, SharedCallback, Widget, WidgetId, WidgetList, WidgetRef,
WrapperWidget,
Callback, MakeWidget, MakeWidgetWithTag, MountedChildren, SharedCallback, Widget, WidgetId,
WidgetList, WidgetRef, WidgetTag, WrapperWidget,
};
use crate::widgets::container::ContainerShadow;
use crate::ConstraintLimit;
@ -892,7 +893,7 @@ impl WrapperWidget for Tooltipped {
/// Designed to be used in a [`Layers`] widget.
#[derive(Debug, Clone, Default)]
pub struct Modal {
modal: Dynamic<Option<WidgetInstance>>,
modal: Dynamic<OrderedLots<WidgetInstance>>,
}
impl Modal {
@ -906,7 +907,23 @@ impl Modal {
/// Presents `contents` as the modal session.
pub fn present(&self, contents: impl MakeWidget) {
self.modal.set(Some(contents.make_widget()));
self.present_inner(contents);
}
fn present_inner(&self, contents: impl MakeWidget) -> LotId {
let mut state = self.modal.lock();
state.push(contents.make_widget())
}
/// Returns a new pending handle that can be used to show a modal and
/// dismiss it.
#[must_use]
pub fn pending_handle(&self) -> ModalHandle {
ModalHandle {
layer: self.clone(),
above: None,
id: Dynamic::default(),
}
}
/// Presents a modal dialog containing `message` with a default button that
@ -919,18 +936,18 @@ impl Modal {
/// Returns a builder for a modal dialog that displays `message`.
pub fn build_dialog(&self, message: impl MakeWidget) -> DialogBuilder {
DialogBuilder::new(self, message)
DialogBuilder::new(self.pending_handle(), message)
}
/// Dismisses the modal session.
pub fn dismiss(&self) {
self.modal.set(None);
self.modal.lock().clear();
}
/// 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)
!self.modal.lock().is_empty()
}
/// Returns a function that dismisses the modal when invoked.
@ -946,67 +963,97 @@ impl Modal {
}
}
impl MakeWidget for Modal {
fn make_widget(self) -> WidgetInstance {
impl MakeWidgetWithTag for Modal {
fn make_with_tag(self, tag: WidgetTag) -> WidgetInstance {
let layer_widgets = Dynamic::default();
ModalLayer {
presented: None,
layers: WidgetRef::new(Layers::new(layer_widgets.clone())),
layer_widgets,
presented: Vec::new(),
focus_top_layer: false,
modal: self.modal,
}
.make_widget()
.make_with_tag(tag)
}
}
#[derive(Debug)]
struct ModalLayer {
presented: Option<MountedWidget>,
modal: Dynamic<Option<WidgetInstance>>,
presented: Vec<WidgetInstance>,
layer_widgets: Dynamic<WidgetList>,
layers: WidgetRef,
modal: Dynamic<OrderedLots<WidgetInstance>>,
focus_top_layer: bool,
}
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();
}
impl WrapperWidget for ModalLayer {
fn child_mut(&mut self) -> &mut WidgetRef {
&mut self.layers
}
fn layout(
fn adjust_child_constraints(
&mut self,
available_space: Size<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_>,
) -> Size<UPx> {
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);
) -> Size<ConstraintLimit> {
self.modal.invalidate_when_changed(context);
let modal = self.modal.read();
let mut layer_widgets = self.layer_widgets.lock();
self.focus_top_layer = false;
for index in 0..modal.len().min(self.presented.len()) {
let modal_widget = &modal[index];
let presented = &mut self.presented[index];
if presented != modal_widget {
let modal_widget = modal_widget.clone();
*presented = modal_widget.clone();
layer_widgets[index * 2 + 1] = modal_widget.clone().centered().make_widget();
self.focus_top_layer = true;
}
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::<Point<Px>>() / 2, child_size),
);
}
full_area
for to_present in modal.iter().skip(self.presented.len()) {
self.focus_top_layer = true;
layer_widgets.push(Space::colored(context.get(&ScrimColor)));
self.presented.push(to_present.clone());
layer_widgets.push(to_present.clone().centered());
}
if self.presented.len() > modal.len() {
self.presented.truncate(modal.len());
layer_widgets.truncate(modal.len() * 2);
self.focus_top_layer = true;
}
available_space
}
fn hit_test(&mut self, _location: Point<Px>, _context: &mut EventContext<'_>) -> bool {
self.presented.is_some()
fn position_child(
&mut self,
size: Size<Px>,
available_space: Size<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_>,
) -> crate::widget::WrappedLayout {
if self.focus_top_layer {
self.focus_top_layer = false;
if let Some(mut ctx) = self
.presented
.last()
.and_then(|topmost| context.for_other(topmost))
{
ctx.focus();
}
}
Size::new(
available_space
.width
.fit_measured(size.width, context.gfx.scale()),
available_space
.height
.fit_measured(size.height, context.gfx.scale()),
)
.into()
}
}
@ -1020,16 +1067,16 @@ pub enum Yes {}
/// A modal dialog builder.
#[must_use = "DialogBuilder::show must be called for the dialog to be shown"]
pub struct DialogBuilder<HasDefault = No, HasCancel = No> {
modal: Modal,
handle: ModalHandle,
message: WidgetInstance,
buttons: WidgetList,
_state: PhantomData<(HasDefault, HasCancel)>,
}
impl DialogBuilder<No, No> {
fn new(modal: &Modal, message: impl MakeWidget) -> Self {
fn new(handle: ModalHandle, message: impl MakeWidget) -> Self {
Self {
modal: modal.clone(),
handle,
message: message.make_widget(),
buttons: WidgetList::new(),
_state: PhantomData,
@ -1065,7 +1112,7 @@ impl<HasDefault, HasCancel> DialogBuilder<HasDefault, HasCancel> {
on_click: impl FnOnce() + Send + 'static,
) {
let mut on_click = Some(on_click);
let modal = self.modal.clone();
let modal = self.handle.clone();
let mut button = caption
.into_button()
.on_click(move |_| {
@ -1084,12 +1131,12 @@ impl<HasDefault, HasCancel> DialogBuilder<HasDefault, HasCancel> {
self.buttons.push(button.fit_horizontally().make_widget());
}
/// Shows the modal dialog.
/// Shows the modal dialog, returning a handle that owns the session.
pub fn show(mut self) {
if self.buttons.is_empty() {
self.inner_push_button("OK", DialogButtonKind::Default, || {});
}
self.modal.present(
self.handle.present(
self.message
.and(self.buttons.into_columns().centered())
.into_rows()
@ -1108,13 +1155,13 @@ impl<HasCancel> DialogBuilder<No, HasCancel> {
) -> DialogBuilder<Yes, HasCancel> {
self.inner_push_button(caption, DialogButtonKind::Default, on_click);
let Self {
modal,
handle,
message,
buttons,
_state,
} = self;
DialogBuilder {
modal,
handle,
message,
buttons,
_state: PhantomData,
@ -1132,13 +1179,13 @@ impl<HasDefault> DialogBuilder<HasDefault, No> {
) -> DialogBuilder<HasDefault, Yes> {
self.inner_push_button(caption, DialogButtonKind::Cancel, on_click);
let Self {
modal,
handle,
message,
buttons,
_state,
} = self;
DialogBuilder {
modal,
handle,
message,
buttons,
_state: PhantomData,
@ -1152,3 +1199,100 @@ enum DialogButtonKind {
Default,
Cancel,
}
/// A handle to a modal dialog presented in a [`Modal`] layer.
#[derive(Clone)]
pub struct ModalHandle {
layer: Modal,
above: Option<Dynamic<Option<LotId>>>,
id: Dynamic<Option<LotId>>,
}
impl ModalHandle {
fn above(mut self, other: &Self) -> Self {
self.above = Some(other.id.clone());
self
}
/// Presents `contents` as a modal dialog, updating this handle to control
/// it.
pub fn present(&self, contents: impl MakeWidget) {
let mut state = self.layer.modal.lock();
if let Some(above) = self.above.as_ref().and_then(Source::get) {
if let Some(index) = state.index_of_id(above) {
state.truncate(index + 1);
} else {
self.id.set(None);
return;
}
} else {
state.clear();
};
self.id.set(Some(state.push(contents.make_widget())));
}
// /// Prevents the modal shown by this handle from being dismissed when the
// /// last reference is dropped.
// pub fn persist(self) {
// self.id.set(None);
// drop(self);
// }
/// Dismisses the modal shown by this handle.
pub fn dismiss(&self) {
let Some(id) = self.id.take() else { return };
let mut state = self.layer.modal.lock();
let Some(index) = state.index_of_id(id) else {
return;
};
state.truncate(index);
}
/// Returns the modal layer the dialog is presented on.
#[must_use]
pub const fn layer(&self) -> &Modal {
&self.layer
}
/// Returns a builder for a modal dialog that displays `message` in a modal
/// dialog above the dialog shown by this handle.
pub fn build_dialog(&self, message: impl MakeWidget) -> DialogBuilder {
DialogBuilder::new(self.clone(), message)
}
}
impl Drop for ModalHandle {
fn drop(&mut self) {
if self.id.instances() == 1 {
self.dismiss();
}
}
}
/// A target for a [`Modal`] session.
pub trait ModalTarget: Send + 'static {
/// Returns a new handle that can be used to show a dialog above `self`.
fn pending_handle(&self) -> ModalHandle;
/// Returns a reference to the modal layer this target presents to.
fn layer(&self) -> &Modal;
}
impl ModalTarget for Modal {
fn pending_handle(&self) -> ModalHandle {
self.pending_handle()
}
fn layer(&self) -> &Modal {
self
}
}
impl ModalTarget for ModalHandle {
fn pending_handle(&self) -> ModalHandle {
self.layer.pending_handle().above(self)
}
fn layer(&self) -> &Modal {
&self.layer
}
}

View file

@ -20,7 +20,7 @@ use super::label::DynamicDisplay;
use super::{Grid, Label};
use crate::styles::{Component, RequireInvalidation};
use crate::value::{IntoValue, MapEach, Source, Value};
use crate::widget::{MakeWidget, WidgetInstance, WidgetList};
use crate::widget::{MakeWidget, MakeWidgetWithTag, WidgetInstance, WidgetList};
/// A list of items displayed with an optional item indicator.
pub struct List {
@ -445,8 +445,8 @@ impl ListIndicator for ListStyle {
}
}
impl MakeWidget for List {
fn make_widget(self) -> WidgetInstance {
impl MakeWidgetWithTag for List {
fn make_with_tag(self, tag: crate::widget::WidgetTag) -> WidgetInstance {
let rows = match (self.children, self.style) {
(children, Value::Constant(style)) => {
children.map_each(move |children| build_grid_widgets(&style, children))
@ -459,7 +459,7 @@ impl MakeWidget for List {
Value::Dynamic(style.map_each(move |style| build_grid_widgets(style, &children)))
}
};
Grid::from_rows(rows).make_widget()
Grid::from_rows(rows).make_with_tag(tag)
}
}

View file

@ -4,7 +4,7 @@ use kludgine::Color;
use crate::context::{GraphicsContext, LayoutContext};
use crate::styles::components::PrimaryColor;
use crate::styles::{DynamicComponent, IntoDynamicComponentValue};
use crate::styles::{Component, DynamicComponent, IntoDynamicComponentValue};
use crate::value::{IntoValue, Value};
use crate::widget::Widget;
use crate::ConstraintLimit;
@ -76,6 +76,24 @@ impl Widget for Space {
) -> Size<UPx> {
available_space.map(ConstraintLimit::min)
}
fn hit_test(
&mut self,
_location: figures::Point<figures::units::Px>,
context: &mut crate::context::EventContext<'_>,
) -> bool {
let color = match self.color.get() {
ColorSource::Color(color) => color,
ColorSource::Dynamic(dynamic_component) => {
if let Some(Component::Color(color)) = dynamic_component.resolve(context) {
color
} else {
return false;
}
}
};
color.alpha() > 0
}
}
#[derive(Debug, PartialEq, Clone)]

View file

@ -1960,6 +1960,8 @@ where
self.outer_size.set(layout_context.window().outer_size());
self.root.invalidate();
}
layout_context.as_event_context().update_hovered_widget();
}
fn close_requested<W>(&mut self, window: W, kludgine: &mut Kludgine) -> bool