From 38cdea9816349771a53302eb1ef2de9c85de08d5 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Fri, 10 May 2024 09:10:39 -0700 Subject: [PATCH] Submenu support in context menus --- examples/menu.rs | 22 +- src/context.rs | 9 +- src/widgets/container.rs | 2 +- src/widgets/layers.rs | 9 + src/widgets/menu.rs | 520 +++++++++++++++++++++++++++++++++------ src/window.rs | 26 +- 6 files changed, 490 insertions(+), 98 deletions(-) diff --git a/examples/menu.rs b/examples/menu.rs index 597c074..60a911b 100644 --- a/examples/menu.rs +++ b/examples/menu.rs @@ -19,13 +19,7 @@ fn main() -> cushy::Result { 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:?}"); - }) + menu(true) .overlay_in(&overlay) .at(click.window_location) .show(); @@ -38,3 +32,17 @@ fn main() -> cushy::Result { .into_layers() .run() } + +fn menu(top: bool) -> Menu { + let mut third = MenuItem::build(MenuOptions::Third).text("Third"); + if top { + third = third.submenu(menu(false)); + } + Menu::new() + .on_selected(|selected| { + println!("Selected item: {selected:?}"); + }) + .with(MenuItem::new(MenuOptions::First, "First")) + .with(MenuItem::new(MenuOptions::Second, "Second")) + .with(third) +} diff --git a/src/context.rs b/src/context.rs index 0bb60ee..9d13bee 100644 --- a/src/context.rs +++ b/src/context.rs @@ -154,10 +154,6 @@ impl<'context> EventContext<'context> { pub(crate) fn hover(&mut self, location: Point) { let changes = self.tree.hover(Some(&self.current_node)); - for unhovered in changes.unhovered { - let mut context = self.for_other(&unhovered); - unhovered.lock().as_widget().unhover(&mut context); - } let mut cursor = None; for hover in changes.hovered.into_iter().rev() { @@ -176,6 +172,11 @@ impl<'context> EventContext<'context> { } self.window_mut() .set_cursor(cursor.unwrap_or_default().into()); + + for unhovered in changes.unhovered { + let mut context = self.for_other(&unhovered); + unhovered.lock().as_widget().unhover(&mut context); + } } pub(crate) fn clear_hover(&mut self) { diff --git a/src/widgets/container.rs b/src/widgets/container.rs index 00c0e40..bba4236 100644 --- a/src/widgets/container.rs +++ b/src/widgets/container.rs @@ -340,7 +340,7 @@ impl Widget for Container { } #[allow(clippy::too_many_lines)] -fn render_shadow( +pub(crate) fn render_shadow( child_area: &Rect, mut corner_radii: CornerRadii, shadow: &ContainerShadow, diff --git a/src/widgets/layers.rs b/src/widgets/layers.rs index fdecf22..55d228c 100644 --- a/src/widgets/layers.rs +++ b/src/widgets/layers.rs @@ -584,6 +584,10 @@ pub trait Overlayable: Sized { #[must_use] fn hide_on_unhover(self) -> Self; + /// Show this overlay with a relationship to another widget. + #[must_use] + fn parent(self, id: WidgetId) -> Self; + /// Show this overlay to the left of the specified widget. #[must_use] fn left_of(self, id: WidgetId) -> Self; @@ -631,6 +635,11 @@ impl Overlayable for OverlayBuilder<'_> { self } + fn parent(mut self, id: WidgetId) -> Self { + self.layout.relative_to = Some(id); + self + } + fn left_of(self, id: WidgetId) -> Self { self.near(id, Direction::Left) } diff --git a/src/widgets/menu.rs b/src/widgets/menu.rs index ebf2462..612376f 100644 --- a/src/widgets/menu.rs +++ b/src/widgets/menu.rs @@ -2,17 +2,32 @@ use std::fmt::Debug; use std::sync::Arc; +use std::time::Duration; -use figures::units::{Lp, Px, UPx}; -use figures::{IntoSigned, Point, Rect, Size, Zero}; +use alot::LotId; +use figures::units::{Px, UPx}; +use figures::{Angle, IntoSigned, Point, Rect, Round, ScreenScale, Size, Zero}; +use kludgine::shapes::{PathBuilder, Shape}; +use kludgine::DrawableExt; use parking_lot::Mutex; -use super::button::{ButtonClick, ButtonKind}; -use super::container::ContainerShadow; +use self::sealed::{SharedMenuState, SubmenuFactory}; +use super::button::{ButtonClick, ButtonColors, ButtonKind, VisualState}; +use super::container::{self, ContainerShadow}; +use super::disclose::IndicatorSize; 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 super::Button; +use crate::animation::{AnimationHandle, AnimationTarget, Spawn}; +use crate::context::{EventContext, GraphicsContext, LayoutContext}; +use crate::styles::components::{ + CornerRadius, Easing, IntrinsicPadding, OpaqueWidgetColor, TextColor, +}; +use crate::styles::Styles; +use crate::value::{Dynamic, Source}; +use crate::widget::{ + Callback, EventHandling, MakeWidget, MakeWidgetWithTag, Widget, WidgetId, WidgetInstance, + WidgetRef, WidgetTag, HANDLED, +}; use crate::ConstraintLimit; /// An overlayable menu of selectable items. @@ -21,12 +36,12 @@ use crate::ConstraintLimit; /// used with an [`OverlayLayer`], this widget can be shown above other widgets /// or at a specific location. #[derive(Debug, Clone)] -pub struct Menu { +pub struct Menu> { items: Vec>, - on_click: Option>>>>, + on_click: Handler, } -impl Default for Menu +impl Default for Menu where T: Debug + Send + Clone + 'static, { @@ -35,7 +50,7 @@ where } } -impl Menu +impl Menu where T: Debug + Send + Clone + 'static, { @@ -44,73 +59,95 @@ where pub const fn new() -> Self { Self { items: Vec::new(), - on_click: None, + on_click: (), } } + /// Sets the selected handler to `selected`, causing it to be invoked when + /// an item is chosen. + #[must_use] + pub fn on_selected(self, selected: F) -> Menu + where + F: FnMut(ChosenMenuItem) + Send + 'static, + { + Menu { + items: self.items, + on_click: MenuHandler(Arc::new(Mutex::new(Callback::new(selected)))), + } + } +} + +impl Menu +where + T: Debug + Send + Clone + 'static, +{ /// 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 - } - +impl Menu +where + T: Debug + Send + Clone + 'static, +{ /// 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> { + self.overlay_in_shared(overlay, Dynamic::default()) + } + + fn overlay_in_shared<'overlay>( + &self, + overlay: &'overlay OverlayLayer, + shared: Dynamic, + ) -> 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(); + let MenuItem { + value, + widget, + submenu, + } = item; 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(); - } - }), - ), + value: value.clone(), + contents: WidgetRef::new(widget.clone().align_left()), + y: UPx::ZERO, height: UPx::ZERO, + submenu: submenu.clone(), + colors: None, + color_animation: AnimationHandle::default(), + state: VisualState::Normal, } }) .collect(); + + let root_menu = shared.lock().open_menus.push(handle.clone()); + + let (menu_tag, menu_id) = WidgetTag::new(); MenuOverlay( overlay.build_overlay( OpenMenu { + on_click: on_click.clone(), items, - handle: handle.clone(), + open_id: root_menu, + padding: UPx::ZERO, + selecting: None, + mouse_down: false, + layer: overlay.clone(), + open_submenu: None, + menu_id, + disclosure_size: UPx::ZERO, + shared, } - .contain() - .shadow(ContainerShadow::drop(Lp::mm(1), Lp::mm(2))) - .vertical_scroll(), + .vertical_scroll() + .make_with_tag(menu_tag), ), handle, ) @@ -127,6 +164,10 @@ impl<'a> Overlayable for MenuOverlay<'a> { Self(self.0.hide_on_unhover(), self.1) } + fn parent(self, id: crate::widget::WidgetId) -> Self { + Self(self.0.parent(id), self.1) + } + fn left_of(self, id: crate::widget::WidgetId) -> Self { Self(self.0.left_of(id), self.1) } @@ -186,6 +227,7 @@ pub struct ChosenMenuItem { /// A builder of a [`MenuItem`]. pub struct MenuItemBuilder { value: T, + submenu: Option>, contents: Contents, } @@ -194,11 +236,13 @@ impl MenuItemBuilder { pub fn text(self, text: impl Into) -> MenuItemBuilder { let Self { value, + submenu, contents: (), } = self; MenuItemBuilder { value, + submenu, contents: text.into(), } } @@ -207,11 +251,13 @@ impl MenuItemBuilder { pub fn widget(self, widget: impl MakeWidget) -> MenuItemBuilder { let Self { value, + submenu, contents: (), } = self; MenuItemBuilder { value, + submenu, contents: widget.make_widget(), } } @@ -222,38 +268,91 @@ impl MenuItemBuilder { pub trait MenuItemContents: sealed::MenuItemContentsSealed {} mod sealed { + use std::sync::Arc; + + use alot::OrderedLots; + use kempt::Set; + + use super::{MenuOverlay, OpenMenuHandle}; + use crate::value::Dynamic; + use crate::widget::WidgetId; + use crate::widgets::layers::OverlayLayer; + + pub trait SubmenuFactory: Send + Sync + 'static { + fn overlay_submenu_in<'overlay>( + &self, + overlay: &'overlay OverlayLayer, + shared_state: Dynamic, + ) -> MenuOverlay<'overlay>; + } + pub trait MenuItemContentsSealed { - fn make_item(self, value: T) -> super::MenuItem; + fn make_item( + self, + value: T, + submenu: Option>, + ) -> super::MenuItem; + } + + #[derive(Debug, Default)] + pub struct SharedMenuState { + pub open_menus: OrderedLots, + pub hovering: Set, } } impl MenuItemContents for String {} impl MenuItemContents for WidgetInstance {} impl sealed::MenuItemContentsSealed for String { - fn make_item(self, value: T) -> MenuItem { + fn make_item(self, value: T, submenu: Option>) -> MenuItem { MenuItem { value, widget: self.make_widget(), + submenu, } } } impl sealed::MenuItemContentsSealed for WidgetInstance { - fn make_item(self, value: T) -> MenuItem { + fn make_item(self, value: T, submenu: Option>) -> MenuItem { MenuItem { value, widget: self, + submenu, } } } +impl sealed::SubmenuFactory for Menu +where + T: Clone + std::fmt::Debug + Send + Sync + 'static, +{ + fn overlay_submenu_in<'overlay>( + &self, + overlay: &'overlay OverlayLayer, + shared_state: Dynamic, + ) -> MenuOverlay<'overlay> { + self.overlay_in_shared(overlay, shared_state) + } +} + impl MenuItemBuilder where Contents: MenuItemContents, { + /// Attaches a submenu to this item and returns self. + #[must_use] + pub fn submenu(mut self, submenu: Menu) -> Self + where + U: Clone + Debug + Send + Sync + 'static, + { + self.submenu = Some(Arc::new(submenu)); + self + } + /// Returns the finished menu item. pub fn finish(self) -> MenuItem { - self.contents.make_item(self.value) + self.contents.make_item(self.value, self.submenu) } } @@ -267,11 +366,11 @@ where } /// An item in a [`Menu`]. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct MenuItem { value: T, widget: WidgetInstance, - // submenu: Option>, + submenu: Option>, } impl MenuItem { @@ -284,28 +383,179 @@ impl MenuItem { pub fn build(value: T) -> MenuItemBuilder { MenuItemBuilder { value, + submenu: None, contents: (), } } } -#[derive(Debug)] -struct OpenMenu { - items: Vec, - handle: OpenMenuHandle, +impl Debug for MenuItem +where + T: Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MenuItem") + .field("value", &self.value) + .field("widget", &self.widget) + .field("submenu", &self.submenu.is_some()) + .finish() + } } +/// A handler for a [`ChosenMenuItem`]. +#[derive(Debug, Clone)] +pub struct MenuHandler(Arc>>>); + #[derive(Debug)] -struct OpenItem { - contents: WidgetRef, - height: UPx, +struct OpenMenu { + items: Vec>, + on_click: MenuHandler, + open_id: LotId, + padding: UPx, + selecting: Option, + mouse_down: bool, + layer: OverlayLayer, + open_submenu: Option<(usize, OpenMenuHandle)>, + menu_id: WidgetId, + disclosure_size: UPx, + shared: Dynamic, +} +impl OpenMenu { + fn handle_mouse_movement(&mut self, location: Point, context: &mut EventContext<'_>) { + self.selecting = None; + for (index, item) in self.items.iter_mut().enumerate() { + let hovered = location.y >= item.y - self.padding + && location.y < item.y + item.height + self.padding; + let new_state = if hovered { + self.selecting = Some(index); + if let Some((submenu_index, handle)) = &self.open_submenu { + if *submenu_index != index { + context.focus(); + handle.dismiss(); + self.open_submenu = None; + } + } else if let Some(factory) = &item.submenu { + let last_layout = context.last_layout().expect("must have rendered"); + let menu_location = Point::new( + last_layout.origin.x + last_layout.size.width + - self.padding.into_signed() * 2, + last_layout.origin.y + (item.y - self.padding).into_signed(), + ); + self.open_submenu = Some(( + index, + factory + .overlay_submenu_in(&self.layer, self.shared.clone()) + .parent(self.menu_id) + .at(menu_location) + .show(), + )); + } + if self.mouse_down { + VisualState::Active + } else { + VisualState::Hovered + } + } else { + VisualState::Normal + }; + if item.state != new_state { + item.state = new_state; + let new_colors = if hovered { + ButtonKind::Solid.colors_for_default(new_state, context) + } else { + Button::colors_for_transparent(new_state, context) + }; + if let Some(colors) = &item.colors { + item.color_animation = colors + .transition_to(new_colors) + .over(Duration::from_millis(150)) + .with_easing(context.get(&Easing)) + .spawn(); + } else { + item.colors = Some(Dynamic::new(new_colors)); + context.set_needs_redraw(); + } + } + } + } } -impl Widget for OpenMenu { +impl Widget for OpenMenu +where + T: Clone + Debug + Send + 'static, +{ fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) { + let radii = context.get(&CornerRadius); + let radii = radii.map(|r| r.into_px(context.gfx.scale())); + let bg = context.get(&OpaqueWidgetColor); + let full_size = context.gfx.size(); + let content_rect = Rect::new( + Point::new(self.padding, UPx::ZERO), + Size::new( + full_size.width - self.padding * 2, + full_size.height - self.padding, + ), + ) + .into_signed(); + container::render_shadow( + &content_rect, + radii, + &ContainerShadow::new(Point::ZERO) + .blur_radius(self.padding.into_signed()) + .spread(self.padding.into_signed()), + bg, + context, + ); + let bg_shape = if radii.is_zero() { + Shape::filled_rect(content_rect, bg) + } else { + Shape::filled_round_rect(content_rect, radii, bg) + }; + context.gfx.draw_shape(&bg_shape); + let disclosure_size = (self.disclosure_size.into_signed() / 2).round(); + let pt1 = Point::new(disclosure_size, Px::ZERO).rotate_by(Angle::degrees(0)); + let pt2 = Point::new(disclosure_size, Px::ZERO).rotate_by(Angle::degrees(120)); + let pt3 = Point::new(disclosure_size, Px::ZERO).rotate_by(Angle::degrees(240)); + + let submenu = PathBuilder::new(pt1).line_to(pt2).line_to(pt3).close(); for item in &mut self.items { let mounted = item.contents.mounted(context); - context.for_other(&mounted).redraw(); + + if let Some(colors) = &item.colors { + let colors = colors.get_tracking_redraw(context); + let child_rect = Rect::new( + Point::new(self.padding, item.y - self.padding), + Size::new( + full_size.width - self.padding * 2, + item.height + self.padding * 2, + ), + ) + .into_signed(); + + let bg_shape = if radii.is_zero() { + Shape::filled_rect(child_rect, colors.background) + } else { + Shape::filled_round_rect(child_rect, radii, colors.background) + }; + context.gfx.draw_shape(&bg_shape); + + if item.submenu.is_some() { + let disclosure_offset = Point::new( + full_size.width - self.disclosure_size / 2 - self.padding * 2, + item.y + item.height / 2, + ) + .into_signed(); + context.gfx.draw_shape( + submenu + .fill(colors.foreground) + .translate_by(disclosure_offset), + ); + } + + let mut context = context.for_other(&mounted); + context.attach_styles(Styles::new().with(&TextColor, colors.foreground)); + context.redraw(); + } } } @@ -316,33 +566,117 @@ impl Widget for OpenMenu { ) -> Size { let mut maximum_item_width = UPx::ZERO; let mut remaining_height = available_space.height.max(); + self.padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale()); + self.disclosure_size = + (context.get(&IndicatorSize).into_upx(context.gfx.scale()) / 2).round(); + let double_padding = self.padding * 2; + let submenu_space = if self.items.iter().any(|i| i.submenu.is_some()) { + self.padding + self.disclosure_size + } else { + UPx::ZERO + }; + let available_width = available_space.width.max() - double_padding; + let mut y = self.padding; for item in &mut self.items { let mounted = item.contents.mounted(context); + let available_width = available_width - submenu_space; let size = context.for_other(&mounted).layout(Size::new( - ConstraintLimit::SizeToFit(available_space.width.max()), + ConstraintLimit::SizeToFit(available_width), ConstraintLimit::SizeToFit(remaining_height), )); + item.y = y; item.height = size.height; + let full_height = size.height + double_padding; + y += full_height; - remaining_height = remaining_height.saturating_sub(item.height); + remaining_height = remaining_height.saturating_sub(full_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(), - ), + Point::new(double_padding, item.y), + Size::new(maximum_item_width, item.height), + ) + .into_signed(), ); - y += item.height; } - Size::new(maximum_item_width, y) + Size::new(maximum_item_width + double_padding * 2 + submenu_space, y) + } + + fn hit_test( + &mut self, + _location: Point, + _context: &mut crate::context::EventContext<'_>, + ) -> bool { + true + } + + fn hover( + &mut self, + location: Point, + context: &mut crate::context::EventContext<'_>, + ) -> Option { + self.handle_mouse_movement(location, context); + self.shared.lock().hovering.insert(context.widget().id()); + None + } + + fn unhover(&mut self, context: &mut EventContext<'_>) { + let mut shared = self.shared.lock(); + shared.hovering.remove(&context.widget().id()); + if shared.hovering.is_empty() { + drop(shared); + self.handle_mouse_movement(Point::squared(Px::new(-1)), context); + } + } + + fn mouse_down( + &mut self, + location: Point, + _device_id: crate::window::DeviceId, + _button: kludgine::app::winit::event::MouseButton, + context: &mut crate::context::EventContext<'_>, + ) -> EventHandling { + self.mouse_down = true; + self.handle_mouse_movement(location, context); + + HANDLED + } + + fn mouse_drag( + &mut self, + location: Point, + _device_id: crate::window::DeviceId, + _button: kludgine::app::winit::event::MouseButton, + context: &mut crate::context::EventContext<'_>, + ) { + self.handle_mouse_movement(location, context); + } + + fn mouse_up( + &mut self, + _location: Option>, + _device_id: crate::window::DeviceId, + _button: kludgine::app::winit::event::MouseButton, + _context: &mut crate::context::EventContext<'_>, + ) { + if let Some(index) = self.selecting { + self.on_click.0.lock().invoke(ChosenMenuItem { + item: self.items[index].value.clone(), + click: None, + }); + let mut shared = self.shared.lock(); + for handle in shared.open_menus.drain() { + handle.dismiss(); + } + } + self.mouse_down = false; } fn accept_focus(&mut self, _context: &mut crate::context::EventContext<'_>) -> bool { @@ -351,9 +685,49 @@ impl Widget for OpenMenu { fn mounted(&mut self, context: &mut crate::context::EventContext<'_>) { context.focus(); + + let colors = Button::colors_for_transparent(VisualState::Normal, context); + for item in &mut self.items { + item.colors = Some(Dynamic::new(colors)); + } } fn blur(&mut self, _context: &mut crate::context::EventContext<'_>) { - self.handle.dismiss(); + if self.open_submenu.is_none() { + let mut shared = self.shared.lock(); + if let Some(index) = shared.open_menus.index_of_id(self.open_id) { + while shared.open_menus.len() > index { + let Some(handle) = shared.open_menus.pop() else { + unreachable!() + }; + handle.dismiss(); + } + } + } + } +} + +struct OpenItem { + value: T, + contents: WidgetRef, + submenu: Option>, + y: UPx, + height: UPx, + colors: Option>, + color_animation: AnimationHandle, + state: VisualState, +} + +impl Debug for OpenItem +where + T: Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenItem") + .field("value", &self.value) + .field("contents", &self.contents) + .field("submenu", &self.submenu.is_some()) + .field("height", &self.height) + .finish_non_exhaustive() } } diff --git a/src/window.rs b/src/window.rs index d903d6a..aa6a8d8 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1596,19 +1596,6 @@ where ); match state { ElementState::Pressed => { - EventContext::new( - WidgetContext::new( - self.root.clone(), - &self.current_theme, - &mut window, - &mut self.fonts, - self.theme_mode.get(), - &mut self.cursor, - ), - kludgine, - ) - .clear_focus(); - if let (ElementState::Pressed, Some(location), Some(hovered)) = ( state, self.cursor.location, @@ -1640,6 +1627,19 @@ where .insert(button, handler.id()); return HANDLED; } + } else { + EventContext::new( + WidgetContext::new( + self.root.clone(), + &self.current_theme, + &mut window, + &mut self.fonts, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ) + .clear_focus(); } IGNORED }