From 8b969660310b24456f32907559f4b612f935cc8e Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Thu, 9 May 2024 08:16:34 -0700 Subject: [PATCH] Basic context menus Missing a ton of functionality (separators, keyboard accessibility, submenus), but the basic concept is working. --- examples/menu.rs | 40 +++++ examples/overlays.rs | 2 +- src/styles/components.rs | 2 +- src/widgets.rs | 1 + src/widgets/layers.rs | 86 ++++++---- src/widgets/menu.rs | 359 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 459 insertions(+), 31 deletions(-) create mode 100644 examples/menu.rs create mode 100644 src/widgets/menu.rs diff --git a/examples/menu.rs b/examples/menu.rs new file mode 100644 index 0000000..597c074 --- /dev/null +++ b/examples/menu.rs @@ -0,0 +1,40 @@ +use cushy::widget::MakeWidget; +use cushy::widgets::layers::{OverlayLayer, Overlayable}; +use cushy::widgets::menu::{Menu, MenuItem}; +use cushy::Run; + +#[derive(Clone, Copy, Debug)] +enum MenuOptions { + First, + Second, + Third, +} + +fn main() -> cushy::Result { + let overlay = OverlayLayer::default(); + + "Click Me" + .into_button() + .on_click({ + let overlay = overlay.clone(); + move |click| { + if let Some(click) = click { + Menu::new() + .with(MenuItem::new(MenuOptions::First, "First")) + .with(MenuItem::new(MenuOptions::Second, "Second")) + .with(MenuItem::new(MenuOptions::Third, "Third")) + .on_selected(|selected| { + println!("Selected item: {selected:?}"); + }) + .overlay_in(&overlay) + .at(click.window_location) + .show(); + } + } + }) + .centered() + .expand() + .and(overlay) + .into_layers() + .run() +} diff --git a/examples/overlays.rs b/examples/overlays.rs index 472a8c1..9f2ff03 100644 --- a/examples/overlays.rs +++ b/examples/overlays.rs @@ -1,6 +1,6 @@ use cushy::widget::{MakeWidget, MakeWidgetWithTag, WidgetTag}; use cushy::widgets::container::ContainerShadow; -use cushy::widgets::layers::{OverlayBuilder, OverlayLayer}; +use cushy::widgets::layers::{OverlayBuilder, OverlayLayer, Overlayable}; use cushy::Run; use figures::units::Lp; use figures::{Point, Zero}; diff --git a/src/styles/components.rs b/src/styles/components.rs index ff8e105..bc2769c 100644 --- a/src/styles/components.rs +++ b/src/styles/components.rs @@ -220,7 +220,7 @@ define_components! { OpaqueWidgetColor(Color, "opaque_color", .surface.opaque_widget) /// 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(10)))) + CornerRadius(CornerRadii, "corner_radius", CornerRadii::from(Dimension::Lp(Lp::points(6)))) /// The font family to render text using. FontFamily(FontFamilyList, "font_family", FontFamilyList::from(FamilyOwned::SansSerif)) /// The font (boldness) weight to apply to text rendering. diff --git a/src/widgets.rs b/src/widgets.rs index 5a6d55e..4c54082 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -18,6 +18,7 @@ pub mod input; pub mod label; pub mod layers; pub mod list; +pub mod menu; mod mode_switch; pub mod progress; pub mod radio; diff --git a/src/widgets/layers.rs b/src/widgets/layers.rs index 63bef0c..fdecf22 100644 --- a/src/widgets/layers.rs +++ b/src/widgets/layers.rs @@ -574,6 +574,48 @@ impl OverlayState { } } +/// A type that is being prepared to be shown in an [`OverlayLayer`]. +pub trait Overlayable: Sized { + /// The resulting handle type when this overlay is shown. + type Handle; + + /// Sets this overlay to hide automatically when it or its relative widget + /// are no longer hovered by the mouse cursor. + #[must_use] + fn hide_on_unhover(self) -> Self; + + /// Show this overlay to the left of the specified widget. + #[must_use] + fn left_of(self, id: WidgetId) -> Self; + + /// Show this overlay to the right of the specified widget. + #[must_use] + fn right_of(self, id: WidgetId) -> Self; + + /// Show this overlay to show below the specified widget. + #[must_use] + fn below(self, id: WidgetId) -> Self; + + /// Show this overlay to show above the specified widget. + #[must_use] + fn above(self, id: WidgetId) -> Self; + + /// Shows this overlay near `id` off to the `direction` side. + #[must_use] + fn near(self, id: WidgetId, direction: Direction) -> Self; + + /// Shows this overlay at a specified window `location`. + #[must_use] + fn at(self, location: Point) -> Self; + + /// Sets `callback` to be invoked once this overlay is dismissed. + #[must_use] + fn on_dismiss(self, callback: Callback) -> Self; + + /// Shows this overlay, returning a handle that to the displayed overlay. + fn show(self) -> Self::Handle; +} + /// A builder for overlaying a widget on an [`OverlayLayer`]. #[derive(Debug, Clone)] pub struct OverlayBuilder<'a> { @@ -581,64 +623,47 @@ pub struct OverlayBuilder<'a> { layout: OverlayLayout, } -impl OverlayBuilder<'_> { - /// Sets this overlay to hide automatically when it or its relative widget - /// are no longer hovered by the mouse cursor. - #[must_use] - pub fn hide_on_unhover(mut self) -> Self { +impl Overlayable for OverlayBuilder<'_> { + type Handle = OverlayHandle; + + fn hide_on_unhover(mut self) -> Self { self.layout.requires_hover = true; self } - /// Show this overlay to the left of the specified widget. - #[must_use] - pub fn left_of(self, id: WidgetId) -> Self { + fn left_of(self, id: WidgetId) -> Self { self.near(id, Direction::Left) } - /// Show this overlay to the right of the specified widget. - #[must_use] - pub fn right_of(self, id: WidgetId) -> Self { + fn right_of(self, id: WidgetId) -> Self { self.near(id, Direction::Right) } - /// Show this overlay to show below the specified widget. - #[must_use] - pub fn below(self, id: WidgetId) -> Self { + fn below(self, id: WidgetId) -> Self { self.near(id, Direction::Down) } - /// Show this overlay to show above the specified widget. - #[must_use] - pub fn above(self, id: WidgetId) -> Self { + fn above(self, id: WidgetId) -> Self { self.near(id, Direction::Up) } - /// Shows this overlay near `id` off to the `direction` side. - #[must_use] - pub fn near(mut self, id: WidgetId, direction: Direction) -> Self { + fn near(mut self, id: WidgetId, direction: Direction) -> Self { self.layout.relative_to = Some(id); self.layout.positioning = Position::Relative(direction); self } - /// Shows this overlay at a specified window `location`. - #[must_use] - pub fn at(mut self, location: Point) -> Self { + fn at(mut self, location: Point) -> Self { self.layout.positioning = Position::At(location); self } - /// Sets `callback` to be invoked once this overlay is dismissed. - #[must_use] - pub fn on_dismiss(mut self, callback: Callback) -> Self { + fn on_dismiss(mut self, callback: Callback) -> Self { self.layout.on_dismiss = Some(Arc::new(Mutex::new(callback))); self } - /// Shows this overlay, returning a handle that to the displayed overlay. - #[must_use] - pub fn show(self) -> OverlayHandle { + fn show(self) -> Self::Handle { self.fade_in(); self.overlay.state.map_mut(|mut state| { state.new_overlays += 1; @@ -649,7 +674,9 @@ impl OverlayBuilder<'_> { } }) } +} +impl OverlayBuilder<'_> { fn fade_in(&self) { self.layout .opacity @@ -746,6 +773,7 @@ impl Direction { /// A handle to an overlay that was shown in an [`OverlayLayer`]. #[derive(PartialEq, Eq)] +#[must_use = "Overlay handles will dismiss the shown overlay when dropped."] pub struct OverlayHandle { state: Dynamic, id: LotId, diff --git a/src/widgets/menu.rs b/src/widgets/menu.rs new file mode 100644 index 0000000..ebf2462 --- /dev/null +++ b/src/widgets/menu.rs @@ -0,0 +1,359 @@ +//! Overlay menu widgets. + +use std::fmt::Debug; +use std::sync::Arc; + +use figures::units::{Lp, Px, UPx}; +use figures::{IntoSigned, Point, Rect, Size, Zero}; +use parking_lot::Mutex; + +use super::button::{ButtonClick, ButtonKind}; +use super::container::ContainerShadow; +use super::layers::{OverlayBuilder, OverlayHandle, OverlayLayer, Overlayable}; +use crate::context::{GraphicsContext, LayoutContext}; +use crate::value::Dynamic; +use crate::widget::{Callback, MakeWidget, Widget, WidgetInstance, WidgetRef}; +use crate::ConstraintLimit; + +/// An overlayable menu of selectable items. +/// +/// This widget is designed to implement Cushy's contextual menu system. When +/// used with an [`OverlayLayer`], this widget can be shown above other widgets +/// or at a specific location. +#[derive(Debug, Clone)] +pub struct Menu { + items: Vec>, + on_click: Option>>>>, +} + +impl Default for Menu +where + T: Debug + Send + Clone + 'static, +{ + fn default() -> Self { + Self::new() + } +} + +impl Menu +where + T: Debug + Send + Clone + 'static, +{ + /// Returns a new, empty menu. + #[must_use] + pub const fn new() -> Self { + Self { + items: Vec::new(), + on_click: None, + } + } + + /// Adds another menu `item` that is displayed using `widget`. + #[must_use] + pub fn with(mut self, item: impl Into>) -> Self { + self.items.push(item.into()); + self + } + + /// Sets the selected handler to `selected`, causing it to be invoked when + /// an item is chosen. + #[must_use] + pub fn on_selected(mut self, selected: F) -> Self + where + F: FnMut(ChosenMenuItem) + Send + 'static, + { + self.on_click = Some(Arc::new(Mutex::new(Callback::new(selected)))); + self + } + + /// Presents this menu in `overlay`, returning an [`Overlayable`] that can + /// be positioned relative or absolutely within `overlay`. + #[must_use] + pub fn overlay_in<'overlay>(&self, overlay: &'overlay OverlayLayer) -> MenuOverlay<'overlay> { + let Self { items, on_click } = self; + let handle = OpenMenuHandle(Dynamic::new(None)); + let items = items + .iter() + .map(|item| { + let MenuItem { value, widget } = item; + let handle = handle.clone(); + OpenItem { + contents: WidgetRef::new( + widget + .clone() + .into_button() + .kind(ButtonKind::Transparent) + .on_click({ + let on_click = on_click.clone(); + let value = value.clone(); + move |click| { + if let Some(on_click) = &on_click { + let mut on_click = on_click.lock(); + on_click.invoke(ChosenMenuItem { + item: value.clone(), + click, + }); + } + handle.dismiss(); + } + }), + ), + height: UPx::ZERO, + } + }) + .collect(); + MenuOverlay( + overlay.build_overlay( + OpenMenu { + items, + handle: handle.clone(), + } + .contain() + .shadow(ContainerShadow::drop(Lp::mm(1), Lp::mm(2))) + .vertical_scroll(), + ), + handle, + ) + } +} + +/// A [`Menu`] that is preparing to be shown in an [`OverlayLayer`]. +pub struct MenuOverlay<'a>(OverlayBuilder<'a>, OpenMenuHandle); + +impl<'a> Overlayable for MenuOverlay<'a> { + type Handle = OpenMenuHandle; + + fn hide_on_unhover(self) -> Self { + Self(self.0.hide_on_unhover(), self.1) + } + + fn left_of(self, id: crate::widget::WidgetId) -> Self { + Self(self.0.left_of(id), self.1) + } + + fn right_of(self, id: crate::widget::WidgetId) -> Self { + Self(self.0.right_of(id), self.1) + } + + fn below(self, id: crate::widget::WidgetId) -> Self { + Self(self.0.below(id), self.1) + } + + fn above(self, id: crate::widget::WidgetId) -> Self { + Self(self.0.above(id), self.1) + } + + fn near(self, id: crate::widget::WidgetId, direction: super::layers::Direction) -> Self { + Self(self.0.near(id, direction), self.1) + } + + fn at(self, location: Point) -> Self { + Self(self.0.at(location), self.1) + } + + fn on_dismiss(self, callback: Callback) -> Self { + Self(self.0.on_dismiss(callback), self.1) + } + + fn show(self) -> Self::Handle { + let handle = self.0.show(); + *self.1 .0.lock() = Some(handle); + self.1 + } +} + +/// A handle to a [`Menu`] that was shown. +#[derive(Clone, Debug)] +pub struct OpenMenuHandle(Dynamic>); + +impl OpenMenuHandle { + /// Closes the menu, if it is still shown. + pub fn dismiss(&self) { + *self.0.lock() = None; + } +} + +/// The selected item of a shown [`Menu`]. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct ChosenMenuItem { + /// The item that was chosen. + pub item: T, + /// Information about the button click that caused this item to be chosen, + /// if present. + pub click: Option, +} + +/// A builder of a [`MenuItem`]. +pub struct MenuItemBuilder { + value: T, + contents: Contents, +} + +impl MenuItemBuilder { + /// Sets the text of this menu item to `text` and returns self. + pub fn text(self, text: impl Into) -> MenuItemBuilder { + let Self { + value, + contents: (), + } = self; + + MenuItemBuilder { + value, + contents: text.into(), + } + } + + /// Sets the contents of this menu item to `widget` and returns self. + pub fn widget(self, widget: impl MakeWidget) -> MenuItemBuilder { + let Self { + value, + contents: (), + } = self; + + MenuItemBuilder { + value, + contents: widget.make_widget(), + } + } +} + +/// A type that can be used inside of a [`MenuItemBuilder`] as a menu item's +/// contents. +pub trait MenuItemContents: sealed::MenuItemContentsSealed {} + +mod sealed { + pub trait MenuItemContentsSealed { + fn make_item(self, value: T) -> super::MenuItem; + } +} + +impl MenuItemContents for String {} +impl MenuItemContents for WidgetInstance {} +impl sealed::MenuItemContentsSealed for String { + fn make_item(self, value: T) -> MenuItem { + MenuItem { + value, + widget: self.make_widget(), + } + } +} + +impl sealed::MenuItemContentsSealed for WidgetInstance { + fn make_item(self, value: T) -> MenuItem { + MenuItem { + value, + widget: self, + } + } +} + +impl MenuItemBuilder +where + Contents: MenuItemContents, +{ + /// Returns the finished menu item. + pub fn finish(self) -> MenuItem { + self.contents.make_item(self.value) + } +} + +impl From> for MenuItem +where + Contents: MenuItemContents, +{ + fn from(builder: MenuItemBuilder) -> Self { + builder.finish() + } +} + +/// An item in a [`Menu`]. +#[derive(Debug, Clone)] +pub struct MenuItem { + value: T, + widget: WidgetInstance, + // submenu: Option>, +} + +impl MenuItem { + /// Returns a new menu item with the given value and contents. + pub fn new(value: T, contents: impl MakeWidget) -> Self { + Self::build(value).widget(contents).finish() + } + + /// Returns a builder for a menu item with the given value. + pub fn build(value: T) -> MenuItemBuilder { + MenuItemBuilder { + value, + contents: (), + } + } +} + +#[derive(Debug)] +struct OpenMenu { + items: Vec, + handle: OpenMenuHandle, +} + +#[derive(Debug)] +struct OpenItem { + contents: WidgetRef, + height: UPx, +} + +impl Widget for OpenMenu { + fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) { + for item in &mut self.items { + let mounted = item.contents.mounted(context); + context.for_other(&mounted).redraw(); + } + } + + fn layout( + &mut self, + available_space: Size, + context: &mut LayoutContext<'_, '_, '_, '_>, + ) -> Size { + let mut maximum_item_width = UPx::ZERO; + let mut remaining_height = available_space.height.max(); + + for item in &mut self.items { + let mounted = item.contents.mounted(context); + let size = context.for_other(&mounted).layout(Size::new( + ConstraintLimit::SizeToFit(available_space.width.max()), + ConstraintLimit::SizeToFit(remaining_height), + )); + item.height = size.height; + + remaining_height = remaining_height.saturating_sub(item.height); + maximum_item_width = maximum_item_width.max(size.width); + } + + let mut y = UPx::ZERO; + for item in &mut self.items { + let mounted = item.contents.mounted(context); + context.set_child_layout( + &mounted, + Rect::new( + Point::new(Px::ZERO, y.into_signed()), + Size::new(maximum_item_width, item.height).into_signed(), + ), + ); + y += item.height; + } + + Size::new(maximum_item_width, y) + } + + fn accept_focus(&mut self, _context: &mut crate::context::EventContext<'_>) -> bool { + true + } + + fn mounted(&mut self, context: &mut crate::context::EventContext<'_>) { + context.focus(); + } + + fn blur(&mut self, _context: &mut crate::context::EventContext<'_>) { + self.handle.dismiss(); + } +}