From bad44007793be7c9d422c1fd9b589159c78842f2 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 16 Nov 2023 08:00:11 -0700 Subject: [PATCH] Add menu code, adapted from iced_aw --- src/theme/style/button.rs | 15 + src/theme/style/menu_bar.rs | 80 +++ src/theme/style/mod.rs | 2 + src/widget/menu.rs | 67 ++ src/widget/menu/flex.rs | 215 ++++++ src/widget/menu/menu_bar.rs | 472 +++++++++++++ src/widget/menu/menu_inner.rs | 1203 +++++++++++++++++++++++++++++++++ src/widget/menu/menu_tree.rs | 130 ++++ src/widget/mod.rs | 2 + 9 files changed, 2186 insertions(+) create mode 100644 src/theme/style/menu_bar.rs create mode 100644 src/widget/menu.rs create mode 100644 src/widget/menu/flex.rs create mode 100644 src/widget/menu/menu_bar.rs create mode 100644 src/widget/menu/menu_inner.rs create mode 100644 src/widget/menu/menu_tree.rs diff --git a/src/theme/style/button.rs b/src/theme/style/button.rs index 879400e2..e98ec0d0 100644 --- a/src/theme/style/button.rs +++ b/src/theme/style/button.rs @@ -32,6 +32,8 @@ pub enum Button { Transparent, AppletMenu, AppletIcon, + MenuRoot, + MenuItem, } pub fn appearance( @@ -120,6 +122,19 @@ pub fn appearance( appearance.icon_color = Some(cosmic.background.on.into()); appearance.text_color = Some(cosmic.background.on.into()); } + Button::MenuRoot => { + appearance.background = None; + appearance.icon_color = Some(cosmic.accent.base.into()); + appearance.text_color = Some(cosmic.accent.base.into()); + } + Button::MenuItem => { + let (background, _, _) = color(&cosmic.background.component); + appearance.background = Some(Background::Color(background)); + + appearance.icon_color = Some(cosmic.background.on.into()); + appearance.text_color = Some(cosmic.background.on.into()); + corner_radii = &cosmic.corner_radii.radius_s; + } } appearance.border_radius = (*corner_radii).into(); diff --git a/src/theme/style/menu_bar.rs b/src/theme/style/menu_bar.rs new file mode 100644 index 00000000..dc622b86 --- /dev/null +++ b/src/theme/style/menu_bar.rs @@ -0,0 +1,80 @@ +// From iced_aw, license MIT + +//! Change the appearance of menu bars and their menus. +use iced_widget::core::Color; +use crate::Theme; + +/// The appearance of a menu bar and its menus. +#[derive(Debug, Clone, Copy)] +pub struct Appearance { + /// The background color of the menu bar and its menus. + pub background: Color, + /// The border width of the menu bar and its menus. + pub border_width: f32, + /// The border radius of the menu bar. + pub bar_border_radius: [f32; 4], + /// The border radius of the menus. + pub menu_border_radius: [f32; 4], + /// The border [`Color`] of the menu bar and its menus. + pub border_color: Color, + /// The expand value of the menus' background + pub background_expand: [u16; 4], + /// The highlighted path [`Color`] of the the menu bar and its menus. + pub path: Color, +} + +/// The style sheet of a menu bar and its menus. +pub trait StyleSheet { + /// The supported style of the [`StyleSheet`]. + type Style: Default; + + /// Produces the [`Appearance`] of a menu bar and its menus. + fn appearance(&self, style: &Self::Style) -> Appearance; +} + +/// The style of a menu bar and its menus +#[derive(Default)] +#[allow(missing_debug_implementations)] +pub enum MenuBarStyle { + /// The default style. + #[default] + Default, + /// A [`Theme`] that uses a `Custom` palette. + Custom(Box>), +} + +impl From Appearance> for MenuBarStyle { + fn from(f: fn(&Theme) -> Appearance) -> Self { + Self::Custom(Box::new(f)) + } +} + +impl StyleSheet for fn(&Theme) -> Appearance { + type Style = Theme; + + fn appearance(&self, style: &Self::Style) -> Appearance { + (self)(style) + } +} + +impl StyleSheet for Theme { + type Style = MenuBarStyle; + + fn appearance(&self, style: &Self::Style) -> Appearance { + let cosmic = self.cosmic(); + let component = &cosmic.background.component; + + match style { + MenuBarStyle::Default => Appearance { + background: component.base.into(), + border_width: 1.0, + bar_border_radius: cosmic.corner_radii.radius_xl, + menu_border_radius: cosmic.corner_radii.radius_s, + border_color: component.divider.into(), + background_expand: [1; 4], + path: component.hover.into(), + }, + MenuBarStyle::Custom(c) => c.appearance(self), + } + } +} diff --git a/src/theme/style/mod.rs b/src/theme/style/mod.rs index a1b8f3be..a0108cbe 100644 --- a/src/theme/style/mod.rs +++ b/src/theme/style/mod.rs @@ -17,6 +17,8 @@ pub use self::iced::Rule; pub use self::iced::Svg; pub use self::iced::Text; +pub mod menu_bar; + mod segmented_button; pub use self::segmented_button::SegmentedButton; diff --git a/src/widget/menu.rs b/src/widget/menu.rs new file mode 100644 index 00000000..0d786eea --- /dev/null +++ b/src/widget/menu.rs @@ -0,0 +1,67 @@ +// From iced_aw, license MIT + +//! A [`MenuBar`] widget for displaying [`MenuTree`]s +//! +//! *This API requires the following crate features to be activated: `menu`* +//! +//! # Example +//! +//! ```ignore +//! use iced::widget::button; +//! use iced_aw::menu::{MenuTree, MenuBar}; +//! +//! let sub_2 = MenuTree::with_children( +//! button("Sub Menu 2"), +//! vec![ +//! MenuTree::new(button("item_1")), +//! MenuTree::new(button("item_2")), +//! MenuTree::new(button("item_3")), +//! ] +//! ); +//! +//! let sub_1 = MenuTree::with_children( +//! button("Sub Menu 1"), +//! vec![ +//! MenuTree::new(button("item_1")), +//! sub_2, +//! MenuTree::new(button("item_2")), +//! MenuTree::new(button("item_3")), +//! ] +//! ); +//! +//! +//! let root_1 = MenuTree::with_children( +//! button("Menu 1"), +//! vec![ +//! MenuTree::new(button("item_1")), +//! MenuTree::new(button("item_2")), +//! sub_1, +//! MenuTree::new(button("item_3")), +//! ] +//! ); +//! +//! let root_2 = MenuTree::with_children( +//! button("Menu 2"), +//! vec![ +//! MenuTree::new(button("item_1")), +//! MenuTree::new(button("item_2")), +//! MenuTree::new(button("item_3")), +//! ] +//! ); +//! +//! let menu_bar = MenuBar::new(vec![root_1, root_2]); +//! +//! ``` +//! + +mod flex; +pub mod menu_bar; +mod menu_inner; +pub mod menu_tree; + +pub use crate::style::menu_bar::{Appearance, StyleSheet}; +/// A `MenuBar` collects `MenuTree`s and handles +pub type MenuBar<'a, Message, Renderer> = menu_bar::MenuBar<'a, Message, Renderer>; +pub use menu_inner::{CloseCondition, ItemHeight, ItemWidth, PathHighlight}; +/// Nested menu is essentially a tree of items, a menu is a collection of items +pub type MenuTree<'a, Message, Renderer> = menu_tree::MenuTree<'a, Message, Renderer>; diff --git a/src/widget/menu/flex.rs b/src/widget/menu/flex.rs new file mode 100644 index 00000000..ecff8dab --- /dev/null +++ b/src/widget/menu/flex.rs @@ -0,0 +1,215 @@ +// From iced_aw, license MIT + +use iced_widget::core::{ + layout::{Limits, Node}, + renderer, Alignment, Element, Padding, Point, Size, +}; + +/// The main axis of a flex layout. +#[derive(Debug)] +pub enum Axis { + /// The horizontal axis + Horizontal, + + /// The vertical axis + #[allow(dead_code)] + Vertical, +} + +impl Axis { + /// Gets the main Axis + fn main(&self, size: Size) -> f32 { + match self { + Self::Horizontal => size.width, + Self::Vertical => size.height, + } + } + + /// Gets the cross Axis + fn cross(&self, size: Size) -> f32 { + match self { + Self::Horizontal => size.height, + Self::Vertical => size.width, + } + } + + /// Returns a Packed axis + fn pack(&self, main: f32, cross: f32) -> (f32, f32) { + match self { + Self::Horizontal => (main, cross), + Self::Vertical => (cross, main), + } + } +} + +/// Computes the flex layout with the given axis and limits, applying spacing, +/// padding and alignment to the items as needed. +/// +/// It returns a new layout [`Node`]. +pub fn resolve<'a, E, Message, Renderer>( + axis: &Axis, + renderer: &Renderer, + limits: &Limits, + padding: Padding, + spacing: f32, + align_items: Alignment, + items: &[E], +) -> Node +where + E: std::borrow::Borrow>, + Renderer: renderer::Renderer, +{ + let limits = limits.pad(padding); + let total_spacing = spacing * items.len().saturating_sub(1) as f32; + let max_cross = axis.cross(limits.max()); + + let mut fill_sum = 0; + let mut cross = axis.cross(limits.min()).max(axis.cross(limits.fill())); + let mut available = axis.main(limits.max()) - total_spacing; + + let mut nodes: Vec = Vec::with_capacity(items.len()); + nodes.resize(items.len(), Node::default()); + + if align_items == Alignment::Center { + let mut fill_cross = axis.cross(limits.min()); + + for child in items { + let child = child.borrow(); + let cross_fill_factor = match axis { + Axis::Horizontal => child.as_widget().height(), + Axis::Vertical => child.as_widget().width(), + } + .fill_factor(); + + if cross_fill_factor == 0 { + let (max_width, max_height) = axis.pack(available, max_cross); + + let child_limits = Limits::new(Size::ZERO, Size::new(max_width, max_height)); + + let layout = child.as_widget().layout(renderer, &child_limits); + let size = layout.size(); + + fill_cross = fill_cross.max(axis.cross(size)); + } + } + + cross = fill_cross; + } + + for (i, child) in items.iter().enumerate() { + let child = child.borrow(); + let fill_factor = match axis { + Axis::Horizontal => child.as_widget().width(), + Axis::Vertical => child.as_widget().height(), + } + .fill_factor(); + + if fill_factor == 0 { + let (min_width, min_height) = if align_items == Alignment::Center { + axis.pack(0.0, cross) + } else { + axis.pack(0.0, 0.0) + }; + + let (max_width, max_height) = if align_items == Alignment::Center { + axis.pack(available, cross) + } else { + axis.pack(available, max_cross) + }; + + let child_limits = Limits::new( + Size::new(min_width, min_height), + Size::new(max_width, max_height), + ); + + let layout = child.as_widget().layout(renderer, &child_limits); + let size = layout.size(); + + available -= axis.main(size); + + if align_items != Alignment::Center { + cross = cross.max(axis.cross(size)); + } + + nodes[i] = layout; + } else { + fill_sum += fill_factor; + } + } + + let remaining = available.max(0.0); + + for (i, child) in items.iter().enumerate() { + let child = child.borrow(); + let fill_factor = match axis { + Axis::Horizontal => child.as_widget().width(), + Axis::Vertical => child.as_widget().height(), + } + .fill_factor(); + + if fill_factor != 0 { + let max_main = remaining * f32::from(fill_factor) / f32::from(fill_sum); + let min_main = if max_main.is_infinite() { + 0.0 + } else { + max_main + }; + + let (min_width, min_height) = if align_items == Alignment::Center { + axis.pack(min_main, cross) + } else { + axis.pack(min_main, axis.cross(limits.min())) + }; + + let (max_width, max_height) = if align_items == Alignment::Center { + axis.pack(max_main, cross) + } else { + axis.pack(max_main, max_cross) + }; + + let child_limits = Limits::new( + Size::new(min_width, min_height), + Size::new(max_width, max_height), + ); + + let layout = child.as_widget().layout(renderer, &child_limits); + + if align_items != Alignment::Center { + cross = cross.max(axis.cross(layout.size())); + } + + nodes[i] = layout; + } + } + + let pad = axis.pack(padding.left, padding.top); + let mut main = pad.0; + + for (i, node) in nodes.iter_mut().enumerate() { + if i > 0 { + main += spacing; + } + + let (x, y) = axis.pack(main, pad.1); + + node.move_to(Point::new(x, y)); + + match axis { + Axis::Horizontal => { + node.align(Alignment::Start, align_items, Size::new(0.0, cross)); + } + Axis::Vertical => { + node.align(align_items, Alignment::Start, Size::new(cross, 0.0)); + } + } + + let size = node.size(); + + main += axis.main(size); + } + + let (width, height) = axis.pack(main - pad.0, cross); + let size = limits.resolve(Size::new(width, height)); + + Node::with_children(size.pad(padding), nodes) +} diff --git a/src/widget/menu/menu_bar.rs b/src/widget/menu/menu_bar.rs new file mode 100644 index 00000000..308b142e --- /dev/null +++ b/src/widget/menu/menu_bar.rs @@ -0,0 +1,472 @@ +// From iced_aw, license MIT + +//! A widget that handles menu trees +use super::{ + menu_inner::{ + CloseCondition, Direction, ItemHeight, ItemWidth, Menu, MenuState, PathHighlight, + }, + menu_tree::MenuTree, +}; +use crate::style::menu_bar::StyleSheet; + +use iced_widget::core::{ + event, + layout::{Limits, Node}, + mouse::{self, Cursor}, + overlay, renderer, touch, + widget::{tree, Tree}, + Alignment, Clipboard, Color, Element, Layout, Length, Padding, Rectangle, Shell, Widget, +}; + +pub(super) struct MenuBarState { + pub(super) pressed: bool, + pub(super) view_cursor: Cursor, + pub(super) open: bool, + pub(super) active_root: Option, + pub(super) horizontal_direction: Direction, + pub(super) vertical_direction: Direction, + pub(super) menu_states: Vec, +} +impl MenuBarState { + pub(super) fn get_trimmed_indices(&self) -> impl Iterator + '_ { + self.menu_states + .iter() + .take_while(|ms| ms.index.is_some()) + .map(|ms| ms.index.expect("No indices were found in the menu state.")) + } + + pub(super) fn reset(&mut self) { + self.open = false; + self.active_root = None; + self.menu_states.clear(); + } +} +impl Default for MenuBarState { + fn default() -> Self { + Self { + pressed: false, + view_cursor: Cursor::Available([-0.5, -0.5].into()), + open: false, + active_root: None, + horizontal_direction: Direction::Positive, + vertical_direction: Direction::Positive, + menu_states: Vec::new(), + } + } +} + +/// A `MenuBar` collects `MenuTree`s and handles +/// all the layout, event processing and drawing +#[allow(missing_debug_implementations)] +pub struct MenuBar<'a, Message, Renderer = crate::Renderer> +where + Renderer: renderer::Renderer, + Renderer::Theme: StyleSheet, +{ + width: Length, + height: Length, + spacing: f32, + padding: Padding, + bounds_expand: u16, + main_offset: i32, + cross_offset: i32, + close_condition: CloseCondition, + item_width: ItemWidth, + item_height: ItemHeight, + path_highlight: Option, + menu_roots: Vec>, + style: ::Style, +} + +impl<'a, Message, Renderer> MenuBar<'a, Message, Renderer> +where + Renderer: renderer::Renderer, + Renderer::Theme: StyleSheet, +{ + /// Creates a new [`MenuBar`] with the given menu roots + #[must_use] + pub fn new(menu_roots: Vec>) -> Self { + let mut menu_roots = menu_roots; + menu_roots.iter_mut().for_each(MenuTree::set_index); + + Self { + width: Length::Shrink, + height: Length::Shrink, + spacing: 0.0, + padding: Padding::ZERO, + bounds_expand: 16, + main_offset: 0, + cross_offset: 0, + close_condition: CloseCondition { + leave: false, + click_outside: true, + click_inside: true, + }, + item_width: ItemWidth::Uniform(150), + item_height: ItemHeight::Uniform(30), + path_highlight: Some(PathHighlight::MenuActive), + menu_roots, + style: ::Style::default(), + } + } + + /// Sets the expand value for each menu's check bounds + /// + /// When the cursor goes outside of a menu's check bounds, + /// the menu will be closed automatically, this value expands + /// the check bounds + #[must_use] + pub fn bounds_expand(mut self, value: u16) -> Self { + self.bounds_expand = value; + self + } + + /// [`CloseCondition`] + #[must_use] + pub fn close_condition(mut self, close_condition: CloseCondition) -> Self { + self.close_condition = close_condition; + self + } + + /// Moves each menu in the horizontal open direction + #[must_use] + pub fn cross_offset(mut self, value: i32) -> Self { + self.cross_offset = value; + self + } + + /// Sets the height of the [`MenuBar`] + #[must_use] + pub fn height(mut self, height: Length) -> Self { + self.height = height; + self + } + + /// [`ItemHeight`] + #[must_use] + pub fn item_height(mut self, item_height: ItemHeight) -> Self { + self.item_height = item_height; + self + } + + /// [`ItemWidth`] + #[must_use] + pub fn item_width(mut self, item_width: ItemWidth) -> Self { + self.item_width = item_width; + self + } + + /// Moves all the menus in the vertical open direction + #[must_use] + pub fn main_offset(mut self, value: i32) -> Self { + self.main_offset = value; + self + } + + /// Sets the [`Padding`] of the [`MenuBar`] + #[must_use] + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the method for drawing path highlight + #[must_use] + pub fn path_highlight(mut self, path_highlight: Option) -> Self { + self.path_highlight = path_highlight; + self + } + + /// Sets the spacing between menu roots + #[must_use] + pub fn spacing(mut self, units: f32) -> Self { + self.spacing = units; + self + } + + /// Sets the style of the menu bar and its menus + #[must_use] + pub fn style(mut self, style: impl Into<::Style>) -> Self { + self.style = style.into(); + self + } + + /// Sets the width of the [`MenuBar`] + #[must_use] + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } +} +impl<'a, Message, Renderer> Widget for MenuBar<'a, Message, Renderer> +where + Renderer: renderer::Renderer, + Renderer::Theme: StyleSheet, +{ + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + self.height + } + + fn diff(&mut self, tree: &mut Tree) { + if tree.children.len() > self.menu_roots.len() { + tree.children.truncate(self.menu_roots.len()); + } + + /*TODO + tree.children + .iter_mut() + .zip(self.menu_roots.iter()) + .for_each(|(t, root)| { + let flat = root + .flattern() + .iter() + .map(|mt| mt.item.as_widget()) + .collect::>(); + + t.diff_children(&flat); + }); + */ + + if tree.children.len() < self.menu_roots.len() { + let extended = self.menu_roots[tree.children.len()..].iter().map(|root| { + let mut tree = Tree::empty(); + let flat = root + .flattern() + .iter() + .map(|mt| Tree::new(mt.item.as_widget())) + .collect(); + tree.children = flat; + tree + }); + tree.children.extend(extended); + } + } + + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(MenuBarState::default()) + } + + fn children(&self) -> Vec { + /* + menu bar + menu root 1 (stateless) + flat tree + menu root 2 (stateless) + flat tree + ... + */ + + self.menu_roots + .iter() + .map(|root| { + let mut tree = Tree::empty(); + let flat = root + .flattern() + .iter() + .map(|mt| Tree::new(mt.item.as_widget())) + .collect(); + tree.children = flat; + tree + }) + .collect() + } + + fn layout(&self, renderer: &Renderer, limits: &Limits) -> Node { + use super::flex; + + let limits = limits.width(self.width).height(self.height); + let children = self + .menu_roots + .iter() + .map(|root| &root.item) + .collect::>(); + flex::resolve( + &flex::Axis::Horizontal, + renderer, + &limits, + self.padding, + self.spacing, + Alignment::Center, + &children, + ) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: event::Event, + layout: Layout<'_>, + view_cursor: Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + use event::Event::{Mouse, Touch}; + use mouse::{Button::Left, Event::ButtonReleased}; + use touch::Event::{FingerLifted, FingerLost}; + + let root_status = process_root_events( + &mut self.menu_roots, + view_cursor, + tree, + &event, + layout, + renderer, + clipboard, + shell, + viewport, + ); + + let state = tree.state.downcast_mut::(); + + match event { + Mouse(ButtonReleased(Left)) | Touch(FingerLifted { .. } | FingerLost { .. }) => { + if state.menu_states.is_empty() && view_cursor.is_over(layout.bounds()) { + state.view_cursor = view_cursor; + state.open = true; + } + } + _ => (), + } + root_status + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &::Theme, + style: &renderer::Style, + layout: Layout<'_>, + view_cursor: Cursor, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_ref::(); + let cursor_pos = view_cursor.position().unwrap_or_default(); + let position = if state.open && (cursor_pos.x < 0.0 || cursor_pos.y < 0.0) { + state.view_cursor + } else { + view_cursor + }; + + // draw path highlight + if self.path_highlight.is_some() { + let styling = theme.appearance(&self.style); + if let Some(active) = state.active_root { + let active_bounds = layout + .children() + .nth(active) + .expect("Active child not found in menu?") + .bounds(); + let path_quad = renderer::Quad { + bounds: active_bounds, + border_radius: styling.bar_border_radius.into(), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }; + let path_color = styling.path; + renderer.fill_quad(path_quad, path_color); + } + } + + self.menu_roots + .iter() + .zip(&tree.children) + .zip(layout.children()) + .for_each(|((root, t), lo)| { + root.item.as_widget().draw( + &t.children[root.index], + renderer, + theme, + style, + lo, + position, + viewport, + ); + }); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + _renderer: &Renderer, + ) -> Option> { + let state = tree.state.downcast_ref::(); + if !state.open { + return None; + } + + Some( + Menu { + tree, + menu_roots: &mut self.menu_roots, + bounds_expand: self.bounds_expand, + close_condition: self.close_condition, + item_width: self.item_width, + item_height: self.item_height, + bar_bounds: layout.bounds(), + main_offset: self.main_offset, + cross_offset: self.cross_offset, + root_bounds_list: layout.children().map(|lo| lo.bounds()).collect(), + path_highlight: self.path_highlight, + style: &self.style, + } + .overlay(), + ) + } +} +impl<'a, Message, Renderer> From> for Element<'a, Message, Renderer> +where + Message: 'a, + Renderer: 'a + renderer::Renderer, + Renderer::Theme: StyleSheet, +{ + fn from(value: MenuBar<'a, Message, Renderer>) -> Self { + Self::new(value) + } +} + +#[allow(unused_results, clippy::too_many_arguments)] +fn process_root_events( + menu_roots: &mut [MenuTree<'_, Message, Renderer>], + view_cursor: Cursor, + tree: &mut Tree, + event: &event::Event, + layout: Layout<'_>, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, +) -> event::Status +where + Renderer: renderer::Renderer, +{ + menu_roots + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + .map(|((root, t), lo)| { + // assert!(t.tag == tree::Tag::stateless()); + root.item.as_widget_mut().on_event( + &mut t.children[root.index], + event.clone(), + lo, + view_cursor, + renderer, + clipboard, + shell, + viewport, + ) + }) + .fold(event::Status::Ignored, event::Status::merge) +} diff --git a/src/widget/menu/menu_inner.rs b/src/widget/menu/menu_inner.rs new file mode 100644 index 00000000..63629ae4 --- /dev/null +++ b/src/widget/menu/menu_inner.rs @@ -0,0 +1,1203 @@ +// From iced_aw, license MIT + +//! Menu tree overlay +use super::{menu_bar::MenuBarState, menu_tree::MenuTree}; +use crate::style::menu_bar::StyleSheet; + +use iced_widget::core::{ + event, + layout::{Limits, Node}, + mouse::{self, Cursor}, + overlay, renderer, touch, + widget::Tree, + Clipboard, Color, Layout, Length, Padding, Point, Rectangle, Shell, Size, Vector, +}; + +/// The condition of when to close a menu +#[derive(Debug, Clone, Copy)] +pub struct CloseCondition { + /// Close menus when the cursor moves outside the check bounds + pub leave: bool, + + /// Close menus when the cursor clicks outside the check bounds + pub click_outside: bool, + + /// Close menus when the cursor clicks inside the check bounds + pub click_inside: bool, +} + +/// The width of an item +#[derive(Debug, Clone, Copy)] +pub enum ItemWidth { + /// Use uniform width + Uniform(u16), + /// Static tries to use the width value of each menu(menu tree with children), + /// the widths of items(menu tree with empty children) will be the same as the menu they're in, + /// if that value is None, + /// the default value will be used instead, + /// which is the value of the Static variant + Static(u16), +} + +/// The height of an item +#[derive(Debug, Clone, Copy)] +pub enum ItemHeight { + /// Use uniform height. + Uniform(u16), + /// Static tries to use `MenuTree.height` as item height, + /// when it's `None` it'll fallback to the value of the `Static` variant. + Static(u16), + /// Dynamic tries to automatically choose the proper item height for you, + /// but it only works in certain cases: + /// + /// - Fixed height + /// - Shrink height + /// - Menu tree height + /// + /// If none of these is the case, it'll fallback to the value of the `Dynamic` variant. + Dynamic(u16), +} + +/// Methods for drawing path highlight +#[derive(Debug, Clone, Copy)] +pub enum PathHighlight { + /// Draw the full path, + Full, + /// Omit the active item(the last item in the path) + OmitActive, + /// Omit the active item if it's not a menu + MenuActive, +} + +/// X+ goes right and Y+ goes down +#[derive(Debug, Clone, Copy)] +pub(super) enum Direction { + Positive, + Negative, +} + +/// Adaptive open direction +#[derive(Debug)] +#[allow(clippy::struct_excessive_bools)] +struct Aod { + // whether or not to use aod + horizontal: bool, + vertical: bool, + + // whether or not to use overlap + horizontal_overlap: bool, + vertical_overlap: bool, + + // default direction + horizontal_direction: Direction, + vertical_direction: Direction, + + // Offset of the child in the default direction + horizontal_offset: f32, + vertical_offset: f32, +} +impl Aod { + /// Returns child position and offset position + #[allow(clippy::too_many_arguments)] + fn adaptive( + parent_pos: f32, + parent_size: f32, + child_size: f32, + max_size: f32, + offset: f32, + on: bool, + overlap: bool, + direction: Direction, + ) -> (f32, f32) { + /* + Imagine there're two sticks, parent and child + parent: o-----o + child: o----------o + + Now we align the child to the parent in one dimension + There are 4 possibilities: + + 1. to the right + o-----oo----------o + + 2. to the right but allow overlaping + o-----o + o----------o + + 3. to the left + o----------oo-----o + + 4. to the left but allow overlaping + o-----o + o----------o + + The child goes to the default direction by default, + if the space on the default direction runs out it goes to the the other, + whether to use overlap is the caller's decision + + This can be applied to any direction + */ + + match direction { + Direction::Positive => { + let space_negative = parent_pos; + let space_positive = max_size - parent_pos - parent_size; + + if overlap { + let overshoot = child_size - parent_size; + if on && space_negative > space_positive && overshoot > space_positive { + (parent_pos - overshoot, parent_pos - overshoot) + } else { + (parent_pos, parent_pos) + } + } else { + let overshoot = child_size + offset; + if on && space_negative > space_positive && overshoot > space_positive { + (parent_pos - overshoot, parent_pos - offset) + } else { + (parent_pos + parent_size + offset, parent_pos + parent_size) + } + } + } + Direction::Negative => { + let space_positive = parent_pos; + let space_negative = max_size - parent_pos - parent_size; + + if overlap { + let overshoot = child_size - parent_size; + if on && space_negative > space_positive && overshoot > space_positive { + (parent_pos, parent_pos) + } else { + (parent_pos - overshoot, parent_pos - overshoot) + } + } else { + let overshoot = child_size + offset; + if on && space_negative > space_positive && overshoot > space_positive { + (parent_pos + parent_size + offset, parent_pos + parent_size) + } else { + (parent_pos - overshoot, parent_pos - offset) + } + } + } + } + } + + /// Returns child position and offset position + fn resolve( + &self, + parent_bounds: Rectangle, + children_size: Size, + viewport_size: Size, + ) -> (Point, Point) { + let (x, ox) = Self::adaptive( + parent_bounds.x, + parent_bounds.width, + children_size.width, + viewport_size.width, + self.horizontal_offset, + self.horizontal, + self.horizontal_overlap, + self.horizontal_direction, + ); + let (y, oy) = Self::adaptive( + parent_bounds.y, + parent_bounds.height, + children_size.height, + viewport_size.height, + self.vertical_offset, + self.vertical, + self.vertical_overlap, + self.vertical_direction, + ); + + ([x, y].into(), [ox, oy].into()) + } +} + +/// A part of a menu where items are displayed. +/// +/// When the bounds of a menu exceed the viewport, +/// only items inside the viewport will be displayed, +/// when scrolling happens, this should be updated +#[derive(Debug, Clone, Copy)] +struct MenuSlice { + start_index: usize, + end_index: usize, + lower_bound_rel: f32, + upper_bound_rel: f32, +} + +/// Menu bounds in overlay space +struct MenuBounds { + child_positions: Vec, + child_sizes: Vec, + children_bounds: Rectangle, + parent_bounds: Rectangle, + check_bounds: Rectangle, + offset_bounds: Rectangle, +} +impl MenuBounds { + #[allow(clippy::too_many_arguments)] + fn new( + menu_tree: &MenuTree<'_, Message, Renderer>, + renderer: &Renderer, + item_width: ItemWidth, + item_height: ItemHeight, + viewport_size: Size, + overlay_offset: Vector, + aod: &Aod, + bounds_expand: u16, + parent_bounds: Rectangle, + ) -> Self + where + Renderer: renderer::Renderer, + { + let (children_size, child_positions, child_sizes) = + get_children_layout(menu_tree, renderer, item_width, item_height); + + // viewport space parent bounds + let view_parent_bounds = parent_bounds + overlay_offset; + + // overlay space children position + let (children_position, offset_position) = { + let (cp, op) = aod.resolve(view_parent_bounds, children_size, viewport_size); + (cp - overlay_offset, op - overlay_offset) + }; + + // calc offset bounds + let delta = children_position - offset_position; + let offset_size = if delta.x.abs() > delta.y.abs() { + Size::new(delta.x, children_size.height) + } else { + Size::new(children_size.width, delta.y) + }; + let offset_bounds = Rectangle::new(offset_position, offset_size); + + let children_bounds = Rectangle::new(children_position, children_size); + let check_bounds = pad_rectangle(children_bounds, [bounds_expand; 4].into()); + + Self { + child_positions, + child_sizes, + children_bounds, + parent_bounds, + check_bounds, + offset_bounds, + } + } +} + +pub(super) struct MenuState { + pub(super) index: Option, + scroll_offset: f32, + menu_bounds: MenuBounds, +} +impl MenuState { + fn layout( + &self, + overlay_offset: Vector, + slice: MenuSlice, + renderer: &Renderer, + menu_tree: &MenuTree<'_, Message, Renderer>, + ) -> Node + where + Renderer: renderer::Renderer, + { + let MenuSlice { + start_index, + end_index, + lower_bound_rel, + upper_bound_rel, + } = slice; + + assert_eq!( + menu_tree.children.len(), + self.menu_bounds.child_positions.len() + ); + + // viewport space children bounds + let children_bounds = self.menu_bounds.children_bounds + overlay_offset; + + let child_nodes = self.menu_bounds.child_positions[start_index..=end_index] + .iter() + .zip(self.menu_bounds.child_sizes[start_index..=end_index].iter()) + .zip(menu_tree.children[start_index..=end_index].iter()) + .map(|((cp, size), mt)| { + let mut position = *cp; + let mut size = *size; + + if position < lower_bound_rel && (position + size.height) > lower_bound_rel { + size.height = position + size.height - lower_bound_rel; + position = lower_bound_rel; + } else if position <= upper_bound_rel && (position + size.height) > upper_bound_rel + { + size.height = upper_bound_rel - position; + } + + let limits = Limits::new(Size::ZERO, size); + + let mut node = mt.item.as_widget().layout(renderer, &limits); + node.move_to(Point::new(0.0, position + self.scroll_offset)); + node + }) + .collect::>(); + + let mut node = Node::with_children(children_bounds.size(), child_nodes); + node.move_to(children_bounds.position()); + node + } + + fn layout_single( + &self, + overlay_offset: Vector, + index: usize, + renderer: &Renderer, + menu_tree: &MenuTree<'_, Message, Renderer>, + ) -> Node + where + Renderer: renderer::Renderer, + { + // viewport space children bounds + let children_bounds = self.menu_bounds.children_bounds + overlay_offset; + + let position = self.menu_bounds.child_positions[index]; + let limits = Limits::new(Size::ZERO, self.menu_bounds.child_sizes[index]); + let parent_offset = children_bounds.position() - Point::ORIGIN; + let mut node = menu_tree.item.as_widget().layout(renderer, &limits); + node.move_to(Point::new( + parent_offset.x, + parent_offset.y + position + self.scroll_offset, + )); + node + } + + fn slice( + &self, + viewport_size: Size, + overlay_offset: Vector, + item_height: ItemHeight, + ) -> MenuSlice { + // viewport space children bounds + let children_bounds = self.menu_bounds.children_bounds + overlay_offset; + + let max_index = self.menu_bounds.child_positions.len().saturating_sub(1); + + // viewport space absolute bounds + let lower_bound = children_bounds.y.max(0.0); + let upper_bound = (children_bounds.y + children_bounds.height).min(viewport_size.height); + + // menu space relative bounds + let lower_bound_rel = lower_bound - (children_bounds.y + self.scroll_offset); + let upper_bound_rel = upper_bound - (children_bounds.y + self.scroll_offset); + + // index range + let (start_index, end_index) = match item_height { + ItemHeight::Uniform(u) => { + let start_index = (lower_bound_rel / f32::from(u)).floor() as usize; + let end_index = ((upper_bound_rel / f32::from(u)).floor() as usize).min(max_index); + (start_index, end_index) + } + ItemHeight::Static(_) | ItemHeight::Dynamic(_) => { + let positions = &self.menu_bounds.child_positions; + let sizes = &self.menu_bounds.child_sizes; + + let start_index = search_bound(0, 0, max_index, lower_bound_rel, positions, sizes); + let end_index = search_bound( + max_index, + start_index, + max_index, + upper_bound_rel, + positions, + sizes, + ) + .min(max_index); + + (start_index, end_index) + } + }; + + MenuSlice { + start_index, + end_index, + lower_bound_rel, + upper_bound_rel, + } + } +} + +pub(super) struct Menu<'a, 'b, Message, Renderer> +where + Renderer: renderer::Renderer, + Renderer::Theme: StyleSheet, +{ + pub(super) tree: &'b mut Tree, + pub(super) menu_roots: &'b mut Vec>, + pub(super) bounds_expand: u16, + pub(super) close_condition: CloseCondition, + pub(super) item_width: ItemWidth, + pub(super) item_height: ItemHeight, + pub(super) bar_bounds: Rectangle, + pub(super) main_offset: i32, + pub(super) cross_offset: i32, + pub(super) root_bounds_list: Vec, + pub(super) path_highlight: Option, + pub(super) style: &'b ::Style, +} +impl<'a, 'b, Message, Renderer> Menu<'a, 'b, Message, Renderer> +where + Renderer: renderer::Renderer, + Renderer::Theme: StyleSheet, +{ + pub(super) fn overlay(self) -> overlay::Element<'b, Message, Renderer> { + overlay::Element::new(Point::ORIGIN, Box::new(self)) + } +} +impl<'a, 'b, Message, Renderer> overlay::Overlay + for Menu<'a, 'b, Message, Renderer> +where + Renderer: renderer::Renderer, + Renderer::Theme: StyleSheet, +{ + fn layout(&self, _renderer: &Renderer, bounds: Size, position: Point) -> Node { + // overlay space viewport rectangle + Node::new(bounds).translate(Point::ORIGIN - position) + } + + fn on_event( + &mut self, + event: event::Event, + layout: Layout<'_>, + view_cursor: Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + use event::{ + Event::{Mouse, Touch}, + Status::{Captured, Ignored}, + }; + use mouse::{ + Button::Left, + Event::{ButtonPressed, ButtonReleased, CursorMoved, WheelScrolled}, + }; + use touch::Event::{FingerLifted, FingerMoved, FingerPressed}; + + if !self.tree.state.downcast_ref::().open { + return Ignored; + }; + + let viewport = layout.bounds(); + let viewport_size = viewport.size(); + let overlay_offset = Point::ORIGIN - viewport.position(); + let overlay_cursor = view_cursor.position().unwrap_or_default() - overlay_offset; + + let menu_status = process_menu_events( + self.tree, + self.menu_roots, + event.clone(), + view_cursor, + renderer, + clipboard, + shell, + overlay_offset, + ); + + init_root_menu( + self, + renderer, + overlay_cursor, + viewport_size, + overlay_offset, + self.bar_bounds, + self.main_offset as f32, + ); + + match event { + Mouse(WheelScrolled { delta }) => { + process_scroll_events(self, delta, overlay_cursor, viewport_size, overlay_offset) + .merge(menu_status) + } + + Mouse(ButtonPressed(Left)) | Touch(FingerPressed { .. }) => { + let state = self.tree.state.downcast_mut::(); + state.pressed = true; + state.view_cursor = view_cursor; + Captured + } + + Mouse(CursorMoved { position }) | Touch(FingerMoved { position, .. }) => { + let view_cursor = Cursor::Available(position); + let overlay_cursor = view_cursor.position().unwrap_or_default() - overlay_offset; + process_overlay_events( + self, + renderer, + viewport_size, + overlay_offset, + view_cursor, + overlay_cursor, + self.cross_offset as f32, + ) + .merge(menu_status) + } + + Mouse(ButtonReleased(Left)) | Touch(FingerLifted { .. }) => { + let state = self.tree.state.downcast_mut::(); + state.pressed = false; + + // process close condition + if state + .view_cursor + .position() + .unwrap_or_default() + .distance(view_cursor.position().unwrap_or_default()) + < 2.0 + { + let is_inside = state + .menu_states + .iter() + .any(|ms| ms.menu_bounds.check_bounds.contains(overlay_cursor)); + + if self.close_condition.click_inside && is_inside { + state.reset(); + return Captured; + } + + if self.close_condition.click_outside && !is_inside { + state.reset(); + return Captured; + } + } + + // close all menus when clicking inside the menu bar + if self.bar_bounds.contains(overlay_cursor) { + state.reset(); + Captured + } else { + menu_status + } + } + + _ => menu_status, + } + } + + #[allow(unused_results)] + fn draw( + &self, + renderer: &mut Renderer, + theme: &Renderer::Theme, + style: &renderer::Style, + layout: Layout<'_>, + view_cursor: Cursor, + ) { + let state = self.tree.state.downcast_ref::(); + let Some(active_root) = state.active_root else { + return; + }; + + let viewport = layout.bounds(); + let viewport_size = viewport.size(); + let overlay_offset = Point::ORIGIN - viewport.position(); + let render_bounds = Rectangle::new(Point::ORIGIN, viewport.size()); + + let styling = theme.appearance(self.style); + + let tree = &self.tree.children[active_root].children; + let root = &self.menu_roots[active_root]; + + let indices = state.get_trimmed_indices().collect::>(); + + state + .menu_states + .iter() + .enumerate() + .fold(root, |menu_root, (i, ms)| { + let draw_path = self.path_highlight.as_ref().map_or(false, |ph| match ph { + PathHighlight::Full => true, + PathHighlight::OmitActive => !indices.is_empty() && i < indices.len() - 1, + PathHighlight::MenuActive => i < state.menu_states.len() - 1, + }); + + // react only to the last menu + let view_cursor = if i == state.menu_states.len() - 1 { + view_cursor + } else { + Cursor::Available([-1.0; 2].into()) + }; + + let draw_menu = |r: &mut Renderer| { + // calc slice + let slice = ms.slice(viewport_size, overlay_offset, self.item_height); + let start_index = slice.start_index; + let end_index = slice.end_index; + + // calc layout + let children_node = ms.layout(overlay_offset, slice, r, menu_root); + let children_layout = Layout::new(&children_node); + let children_bounds = children_layout.bounds(); + + // draw menu background + // let bounds = pad_rectangle(children_bounds, styling.background_expand.into()); + // println!("cursor: {:?}", view_cursor); + // println!("bg_bounds: {:?}", bounds); + // println!("color: {:?}\n", styling.background); + let menu_quad = renderer::Quad { + bounds: pad_rectangle(children_bounds, styling.background_expand.into()), + border_radius: styling.menu_border_radius.into(), + border_width: styling.border_width, + border_color: styling.border_color, + }; + let menu_color = styling.background; + r.fill_quad(menu_quad, menu_color); + + // draw path hightlight + if let (true, Some(active)) = (draw_path, ms.index) { + let active_bounds = children_layout + .children() + .nth(active.saturating_sub(start_index)) + .expect("No active children were found in menu?") + .bounds(); + let path_quad = renderer::Quad { + bounds: active_bounds, + border_radius: styling.menu_border_radius.into(), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }; + let path_color = styling.path; + r.fill_quad(path_quad, path_color); + } + + // draw item + menu_root.children[start_index..=end_index] + .iter() + .zip(children_layout.children()) + .for_each(|(mt, clo)| { + mt.item.as_widget().draw( + &tree[mt.index], + r, + theme, + style, + clo, + view_cursor, + &children_layout.bounds(), + ); + }); + }; + + renderer.with_layer(render_bounds, draw_menu); + + // only the last menu can have a None active index + ms.index + .map_or(menu_root, |active| &menu_root.children[active]) + }); + } +} + +fn pad_rectangle(rect: Rectangle, padding: Padding) -> Rectangle { + Rectangle { + x: rect.x - padding.left, + y: rect.y - padding.top, + width: rect.width + padding.horizontal(), + height: rect.height + padding.vertical(), + } +} + +fn init_root_menu( + menu: &mut Menu<'_, '_, Message, Renderer>, + renderer: &Renderer, + overlay_cursor: Point, + viewport_size: Size, + overlay_offset: Vector, + bar_bounds: Rectangle, + main_offset: f32, +) where + Renderer: renderer::Renderer, + Renderer::Theme: StyleSheet, +{ + let state = menu.tree.state.downcast_mut::(); + if !(state.menu_states.is_empty() && bar_bounds.contains(overlay_cursor)) { + return; + } + + for (i, (&root_bounds, mt)) in menu + .root_bounds_list + .iter() + .zip(menu.menu_roots.iter()) + .enumerate() + { + if mt.children.is_empty() { + continue; + } + + if root_bounds.contains(overlay_cursor) { + let view_center = viewport_size.width * 0.5; + let rb_center = root_bounds.center_x(); + + state.horizontal_direction = if rb_center > view_center { + Direction::Negative + } else { + Direction::Positive + }; + + let aod = Aod { + horizontal: true, + vertical: true, + horizontal_overlap: true, + vertical_overlap: false, + horizontal_direction: state.horizontal_direction, + vertical_direction: state.vertical_direction, + horizontal_offset: 0.0, + vertical_offset: main_offset, + }; + + let menu_bounds = MenuBounds::new( + mt, + renderer, + menu.item_width, + menu.item_height, + viewport_size, + overlay_offset, + &aod, + menu.bounds_expand, + root_bounds, + ); + + state.active_root = Some(i); + state.menu_states.push(MenuState { + index: None, + scroll_offset: 0.0, + menu_bounds, + }); + + break; + } + } +} + +#[allow(clippy::too_many_arguments)] +fn process_menu_events<'b, Message, Renderer>( + tree: &'b mut Tree, + menu_roots: &'b mut [MenuTree<'_, Message, Renderer>], + event: event::Event, + view_cursor: Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + overlay_offset: Vector, +) -> event::Status +where + Renderer: renderer::Renderer, +{ + use event::Status; + + let state = tree.state.downcast_mut::(); + let Some(active_root) = state.active_root else { + return Status::Ignored; + }; + + let indices = state.get_trimmed_indices().collect::>(); + + if indices.is_empty() { + return Status::Ignored; + } + + // get active item + let mt = indices + .iter() + .fold(&mut menu_roots[active_root], |mt, &i| &mut mt.children[i]); + + // get layout + let last_ms = &state.menu_states[indices.len() - 1]; + let child_node = last_ms.layout_single( + overlay_offset, + last_ms.index.expect("missing index within menu state."), + renderer, + mt, + ); + let child_layout = Layout::new(&child_node); + + // widget tree + let tree = &mut tree.children[active_root].children[mt.index]; + + // process only the last widget + mt.item.as_widget_mut().on_event( + tree, + event, + child_layout, + view_cursor, + renderer, + clipboard, + shell, + &Rectangle::default(), + ) +} + +#[allow(unused_results)] +fn process_overlay_events( + menu: &mut Menu<'_, '_, Message, Renderer>, + renderer: &Renderer, + viewport_size: Size, + overlay_offset: Vector, + view_cursor: Cursor, + overlay_cursor: Point, + cross_offset: f32, +) -> event::Status +where + Renderer: renderer::Renderer, + Renderer::Theme: StyleSheet, +{ + use event::Status::{Captured, Ignored}; + /* + if no active root || pressed: + return + else: + remove invalid menus // overlay space + update active item + if active item is a menu: + add menu // viewport space + */ + + let state = menu.tree.state.downcast_mut::(); + + let Some(active_root) = state.active_root else { + if !menu.bar_bounds.contains(overlay_cursor) { + state.reset(); + } + return Ignored; + }; + + if state.pressed { + return Ignored; + } + + /* When overlay is running, cursor_position in any widget method will go negative + but I still want Widget::draw() to react to cursor movement */ + state.view_cursor = view_cursor; + + // * remove invalid menus + let mut prev_bounds = std::iter::once(menu.bar_bounds) + .chain( + state.menu_states[..state.menu_states.len().saturating_sub(1)] + .iter() + .map(|ms| ms.menu_bounds.children_bounds), + ) + .collect::>(); + + if menu.close_condition.leave { + for i in (0..state.menu_states.len()).rev() { + let mb = &state.menu_states[i].menu_bounds; + + if mb.parent_bounds.contains(overlay_cursor) + || mb.children_bounds.contains(overlay_cursor) + || mb.offset_bounds.contains(overlay_cursor) + || (mb.check_bounds.contains(overlay_cursor) + && prev_bounds.iter().all(|pvb| !pvb.contains(overlay_cursor))) + { + break; + } + prev_bounds.pop(); + state.menu_states.pop(); + } + } else { + for i in (0..state.menu_states.len()).rev() { + let mb = &state.menu_states[i].menu_bounds; + + if mb.parent_bounds.contains(overlay_cursor) + || mb.children_bounds.contains(overlay_cursor) + || prev_bounds.iter().all(|pvb| !pvb.contains(overlay_cursor)) + { + break; + } + prev_bounds.pop(); + state.menu_states.pop(); + } + } + + // get indices + let indices = state + .menu_states + .iter() + .map(|ms| ms.index) + .collect::>(); + + // * update active item + let Some(last_menu_state) = state.menu_states.last_mut() else { + // no menus left + state.active_root = None; + + // keep state.open when the cursor is still inside the menu bar + // this allows the overlay to keep drawing when the cursor is + // moving aroung the menu bar + if !menu.bar_bounds.contains(overlay_cursor) { + state.open = false; + } + return Captured; + }; + + let last_menu_bounds = &last_menu_state.menu_bounds; + let last_parent_bounds = last_menu_bounds.parent_bounds; + let last_children_bounds = last_menu_bounds.children_bounds; + + if last_parent_bounds.contains(overlay_cursor) + // cursor is in the parent part + || !last_children_bounds.contains(overlay_cursor) + // cursor is outside + { + last_menu_state.index = None; + return Captured; + } + // cursor is in the children part + + // calc new index + let height_diff = (overlay_cursor.y - (last_children_bounds.y + last_menu_state.scroll_offset)) + .clamp(0.0, last_children_bounds.height - 0.001); + + let active_menu_root = &menu.menu_roots[active_root]; + + let active_menu = indices[0..indices.len().saturating_sub(1)] + .iter() + .fold(active_menu_root, |mt, i| { + &mt.children[i.expect("missing active child index in menu")] + }); + + let new_index = match menu.item_height { + ItemHeight::Uniform(u) => (height_diff / f32::from(u)).floor() as usize, + ItemHeight::Static(_) | ItemHeight::Dynamic(_) => { + let max_index = active_menu.children.len() - 1; + search_bound( + 0, + 0, + max_index, + height_diff, + &last_menu_bounds.child_positions, + &last_menu_bounds.child_sizes, + ) + } + }; + + // set new index + last_menu_state.index = Some(new_index); + + // get new active item + let item = &active_menu.children[new_index]; + + // * add new menu if the new item is a menu + if !item.children.is_empty() { + let item_position = Point::new( + 0.0, + last_menu_bounds.child_positions[new_index] + last_menu_state.scroll_offset, + ); + let item_size = last_menu_bounds.child_sizes[new_index]; + + // overlay space item bounds + let item_bounds = Rectangle::new(item_position, item_size) + + (last_menu_bounds.children_bounds.position() - Point::ORIGIN); + + let aod = Aod { + horizontal: true, + vertical: true, + horizontal_overlap: false, + vertical_overlap: true, + horizontal_direction: state.horizontal_direction, + vertical_direction: state.vertical_direction, + horizontal_offset: cross_offset, + vertical_offset: 0.0, + }; + + state.menu_states.push(MenuState { + index: None, + scroll_offset: 0.0, + menu_bounds: MenuBounds::new( + item, + renderer, + menu.item_width, + menu.item_height, + viewport_size, + overlay_offset, + &aod, + menu.bounds_expand, + item_bounds, + ), + }); + } + + Captured +} + +fn process_scroll_events( + menu: &mut Menu<'_, '_, Message, Renderer>, + delta: mouse::ScrollDelta, + overlay_cursor: Point, + viewport_size: Size, + overlay_offset: Vector, +) -> event::Status +where + Renderer: renderer::Renderer, + Renderer::Theme: StyleSheet, +{ + use event::Status::{Captured, Ignored}; + use mouse::ScrollDelta; + + let state = menu.tree.state.downcast_mut::(); + + let delta_y = match delta { + ScrollDelta::Lines { y, .. } => y * 60.0, + ScrollDelta::Pixels { y, .. } => y, + }; + + let calc_offset_bounds = |menu_state: &MenuState, viewport_size: Size| -> (f32, f32) { + // viewport space children bounds + let children_bounds = menu_state.menu_bounds.children_bounds + overlay_offset; + + let max_offset = (0.0 - children_bounds.y).max(0.0); + let min_offset = + (viewport_size.height - (children_bounds.y + children_bounds.height)).min(0.0); + (max_offset, min_offset) + }; + + // update + if state.menu_states.is_empty() { + return Ignored; + } else if state.menu_states.len() == 1 { + let last_ms = &mut state.menu_states[0]; + + if last_ms.index.is_none() { + return Captured; + } + + let (max_offset, min_offset) = calc_offset_bounds(last_ms, viewport_size); + last_ms.scroll_offset = (last_ms.scroll_offset + delta_y).clamp(min_offset, max_offset); + } else { + // >= 2 + let max_index = state.menu_states.len() - 1; + let last_two = &mut state.menu_states[max_index - 1..=max_index]; + + if last_two[1].index.is_some() { + // scroll the last one + let (max_offset, min_offset) = calc_offset_bounds(&last_two[1], viewport_size); + last_two[1].scroll_offset = + (last_two[1].scroll_offset + delta_y).clamp(min_offset, max_offset); + } else { + if !last_two[0] + .menu_bounds + .children_bounds + .contains(overlay_cursor) + { + return Captured; + } + + // scroll the second last one + let (max_offset, min_offset) = calc_offset_bounds(&last_two[0], viewport_size); + let scroll_offset = (last_two[0].scroll_offset + delta_y).clamp(min_offset, max_offset); + let clamped_delta_y = scroll_offset - last_two[0].scroll_offset; + last_two[0].scroll_offset = scroll_offset; + + // update the bounds of the last one + last_two[1].menu_bounds.parent_bounds.y += clamped_delta_y; + last_two[1].menu_bounds.children_bounds.y += clamped_delta_y; + last_two[1].menu_bounds.check_bounds.y += clamped_delta_y; + } + } + Captured +} + +#[allow(clippy::pedantic)] +/// Returns (children_size, child_positions, child_sizes) +fn get_children_layout( + menu_tree: &MenuTree<'_, Message, Renderer>, + renderer: &Renderer, + item_width: ItemWidth, + item_height: ItemHeight, +) -> (Size, Vec, Vec) +where + Renderer: renderer::Renderer, +{ + let width = match item_width { + ItemWidth::Uniform(u) => f32::from(u), + ItemWidth::Static(s) => f32::from(menu_tree.width.unwrap_or(s)), + }; + + let child_sizes: Vec = match item_height { + ItemHeight::Uniform(u) => { + let count = menu_tree.children.len(); + (0..count).map(|_| Size::new(width, f32::from(u))).collect() + } + ItemHeight::Static(s) => menu_tree + .children + .iter() + .map(|mt| Size::new(width, f32::from(mt.height.unwrap_or(s)))) + .collect(), + ItemHeight::Dynamic(d) => menu_tree + .children + .iter() + .map(|mt| { + let w = mt.item.as_widget(); + match w.height() { + Length::Fixed(f) => Size::new(width, f), + Length::Shrink => { + let l_height = w + .layout( + renderer, + &Limits::new(Size::ZERO, Size::new(width, f32::MAX)), + ) + .size() + .height; + + let height = if (f32::MAX - l_height) < 0.001 { + f32::from(d) + } else { + l_height + }; + + Size::new(width, height) + } + _ => mt.height.map_or_else( + || Size::new(width, f32::from(d)), + |h| Size::new(width, f32::from(h)), + ), + } + }) + .collect(), + }; + + let max_index = menu_tree.children.len() - 1; + let child_positions: Vec = std::iter::once(0.0) + .chain(child_sizes[0..max_index].iter().scan(0.0, |acc, x| { + *acc += x.height; + Some(*acc) + })) + .collect(); + + let height = child_sizes.iter().fold(0.0, |acc, x| acc + x.height); + + (Size::new(width, height), child_positions, child_sizes) +} + +fn search_bound( + default: usize, + default_left: usize, + default_right: usize, + bound: f32, + positions: &[f32], + sizes: &[Size], +) -> usize { + // binary search + let mut left = default_left; + let mut right = default_right; + + let mut index = default; + while left != right { + let m = ((left + right) / 2) + 1; + if positions[m] > bound { + right = m - 1; + } else { + left = m; + } + } + // let height = f32::from(menu_tree.children[left].height.unwrap_or(default_height)); + let height = sizes[left].height; + if positions[left] + height > bound { + index = left; + } + index +} diff --git a/src/widget/menu/menu_tree.rs b/src/widget/menu/menu_tree.rs new file mode 100644 index 00000000..50340c4a --- /dev/null +++ b/src/widget/menu/menu_tree.rs @@ -0,0 +1,130 @@ +// From iced_aw, license MIT + +//! A tree structure for constructing a hierarchical menu + +use iced_widget::core::{renderer, Element}; +/// Nested menu is essentially a tree of items, a menu is a collection of items +/// a menu itself can also be an item of another menu. +/// +/// A `MenuTree` represents a node in the tree, it holds a widget as a menu item +/// for its parent, and a list of menu tree as child nodes. +/// Conceptually a node is either a menu(inner node) or an item(leaf node), +/// but there's no need to explicitly distinguish them here, if a menu tree +/// has children, it's a menu, otherwise it's an item +#[allow(missing_debug_implementations)] +pub struct MenuTree<'a, Message, Renderer = crate::Renderer> { + /// The menu tree will be flatten into a vector to build a linear widget tree, + /// the `index` field is the index of the item in that vector + pub(super) index: usize, + + /// The item of the menu tree + pub(super) item: Element<'a, Message, Renderer>, + /// The children of the menu tree + pub(super) children: Vec>, + /// The width of the menu tree + pub(super) width: Option, + /// The height of the menu tree + pub(super) height: Option, +} +impl<'a, Message, Renderer> MenuTree<'a, Message, Renderer> +where + Renderer: renderer::Renderer, +{ + /// Create a new menu tree from a widget + pub fn new(item: impl Into>) -> Self { + Self { + index: 0, + item: item.into(), + children: Vec::new(), + width: None, + height: None, + } + } + + /// Create a menu tree from a widget and a vector of sub trees + pub fn with_children( + item: impl Into>, + children: Vec>>, + ) -> Self { + Self { + index: 0, + item: item.into(), + children: children.into_iter().map(Into::into).collect(), + width: None, + height: None, + } + } + + /// Sets the width of the menu tree. + /// See [`ItemWidth`] + /// + /// [`ItemWidth`]:`super::ItemWidth` + #[must_use] + pub fn width(mut self, width: u16) -> Self { + self.width = Some(width); + self + } + + /// Sets the height of the menu tree. + /// See [`ItemHeight`] + /// + /// [`ItemHeight`]: `super::ItemHeight` + #[must_use] + pub fn height(mut self, height: u16) -> Self { + self.height = Some(height); + self + } + + /* Keep `set_index()` and `flattern()` recurse in the same order */ + + /// Set the index of each item + pub(super) fn set_index(&mut self) { + /// inner counting function. + fn rec(mt: &mut MenuTree<'_, Message, Renderer>, count: &mut usize) { + // keep items under the same menu line up + mt.children.iter_mut().for_each(|c| { + c.index = *count; + *count += 1; + }); + + mt.children.iter_mut().for_each(|c| rec(c, count)); + } + + let mut count = 0; + self.index = count; + count += 1; + rec(self, &mut count); + } + + /// Flatten the menu tree + pub(super) fn flattern(&'a self) -> Vec<&Self> { + /// Inner flattening function + fn rec<'a, Message, Renderer>( + mt: &'a MenuTree<'a, Message, Renderer>, + flat: &mut Vec<&MenuTree<'a, Message, Renderer>>, + ) { + mt.children.iter().for_each(|c| { + flat.push(c); + }); + + mt.children.iter().for_each(|c| { + rec(c, flat); + }); + } + + let mut flat = Vec::new(); + flat.push(self); + rec(self, &mut flat); + + flat + } +} + +impl<'a, Message, Renderer> From> for MenuTree<'a, Message, Renderer> +where + Renderer: renderer::Renderer, +{ + fn from(value: Element<'a, Message, Renderer>) -> Self { + Self::new(value) + } +} diff --git a/src/widget/mod.rs b/src/widget/mod.rs index ad03f115..448bf2ea 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -126,6 +126,8 @@ pub mod frames; pub mod list; pub use list::*; +pub mod menu; + pub mod nav_bar; pub use nav_bar::nav_bar;