diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index b78547705..f875ffd8f 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -3041,7 +3041,7 @@ impl OpenCADStudio { auto_collapse, ), crate::ui::dock::PanelId::SheetSetManager => { - crate::ui::window::sheet_set::view(&self.sheet_set, width, auto_collapse, side == crate::app::config::DockSide::Right) + crate::ui::window::sheet_set::view(&self.sheet_set, width, auto_collapse) } }; let divider = dock_divider(id); diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 7497baed3..fcd11afca 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -27,6 +27,7 @@ pub mod alias_editor; pub mod find_replace; pub mod named_parameters; pub mod pc_manager; +pub mod row_menu; pub mod sheet_set; pub mod pdf_dialogs; pub mod xref_attach; diff --git a/src/ui/window/row_menu.rs b/src/ui/window/row_menu.rs new file mode 100644 index 000000000..fc8ec518a --- /dev/null +++ b/src/ui/window/row_menu.rs @@ -0,0 +1,286 @@ +//! A right-click menu for palette rows with one level of fly-out submenus. +//! +//! The menu opens at the cursor (flipped at the window edges); an open fly-out +//! is laid out beside it — on the right when it fits, otherwise on the left — +//! so the menu itself never moves. Pressing a submenu entry keeps the menu open. + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::widget::{self, tree, Tree, Widget}; +use iced::advanced::{mouse, overlay, renderer, Shell}; +use iced::{keyboard, Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vector}; + +use crate::app::Message; + +const FLYOUT_GAP: f32 = 2.0; + +pub(crate) struct RowMenu<'a> { + underlay: Element<'a, Message>, + main: Element<'a, Message>, + /// Per entry of the menu's column: a submenu entry (pressing it keeps the menu open). + keep_open: Vec, + /// The open fly-out: its entry's index in the column, and its content. + flyout: Option<(usize, Element<'a, Message>)>, + /// Published when the menu closes with a fly-out open. + on_close: Message, +} + +#[derive(Default)] +struct State { + show: bool, + position: Point, +} + +impl<'a> RowMenu<'a> { + pub(crate) fn new( + underlay: impl Into>, + main: Element<'a, Message>, + keep_open: Vec, + flyout: Option<(usize, Element<'a, Message>)>, + on_close: Message, + ) -> Self { + Self { underlay: underlay.into(), main, keep_open, flyout, on_close } + } +} + +impl<'a> Widget for RowMenu<'a> { + fn size(&self) -> Size { + self.underlay.as_widget().size() + } + + fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { + self.underlay.as_widget_mut().layout(&mut tree.children[0], renderer, limits) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.underlay.as_widget().draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport); + } + + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn diff(&mut self, tree: &mut Tree) { + let mut refs: Vec<&mut Element<'a, Message>> = vec![&mut self.underlay, &mut self.main]; + if let Some((_, f)) = &mut self.flyout { + refs.push(f); + } + tree.diff_children(&mut refs); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) = event { + if let Some(p) = cursor.position_over(layout.bounds()) { + let state: &mut State = tree.state.downcast_mut(); + state.show = true; + state.position = p; + if self.flyout.is_some() { + shell.publish(self.on_close.clone()); + } + shell.capture_event(); + shell.request_redraw(); + return; + } + } + self.underlay + .as_widget_mut() + .update(&mut tree.children[0], event, layout, cursor, renderer, shell, viewport); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.underlay.as_widget().mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.underlay.as_widget_mut().operate(&mut tree.children[0], layout, renderer, operation); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + let state: &mut State = tree.state.downcast_mut(); + if !state.show { + return self.underlay.as_widget_mut().overlay(&mut tree.children[0], layout, renderer, viewport, translation); + } + let position = state.position + translation; + let (_, trees) = tree.children.split_at_mut(1); + let (main_tree, fly_tree) = trees.split_at_mut(1); + Some(overlay::Element::new(Box::new(MenuOverlay { + state, + position, + main: &mut self.main, + main_tree: &mut main_tree[0], + keep_open: &self.keep_open, + flyout: self.flyout.as_mut().map(|(i, f)| (*i, f)).zip(fly_tree.first_mut()), + on_close: &self.on_close, + }))) + } +} + +impl<'a> From> for Element<'a, Message> { + fn from(w: RowMenu<'a>) -> Self { + Element::new(w) + } +} + +struct MenuOverlay<'a, 'b> { + state: &'b mut State, + position: Point, + main: &'b mut Element<'a, Message>, + main_tree: &'b mut Tree, + keep_open: &'b [bool], + flyout: Option<((usize, &'b mut Element<'a, Message>), &'b mut Tree)>, + on_close: &'b Message, +} + +/// The entries of a menu box (container › column › entries). +fn entries(main: Layout<'_>) -> impl Iterator> { + main.children().next().into_iter().flat_map(|column| column.children()) +} + +impl MenuOverlay<'_, '_> { + fn close(&mut self, shell: &mut Shell<'_, Message>) { + self.state.show = false; + if self.flyout.is_some() { + shell.publish(self.on_close.clone()); + } + shell.request_redraw(); + } +} + +impl overlay::Overlay for MenuOverlay<'_, '_> { + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { + let limits = layout::Limits::new(Size::ZERO, bounds); + let main = self.main.as_widget_mut().layout(self.main_tree, renderer, &limits); + let size = main.size(); + // At the cursor, flipped to the other side at the window edges. + let mut p = self.position; + if p.x + size.width > bounds.width { + p.x = (p.x - size.width).max(0.0); + } + if p.y + size.height > bounds.height { + p.y = (p.y - size.height).max(0.0); + } + let main = main.move_to(p); + let mut nodes = Vec::with_capacity(2); + if let Some(((index, flyout), tree)) = self.flyout.as_mut() { + let node = flyout.as_widget_mut().layout(tree, renderer, &limits); + let fly = node.size(); + // Its first item level with the entry (both boxes have the same inset). + let entry_y = main.children().first().and_then(|column| column.children().get(*index)).map_or(0.0, |e| e.bounds().y); + let y = (p.y + entry_y).min(bounds.height - fly.height).max(0.0); + // On the right when it fits, otherwise on the left. + let right = p.x + size.width + FLYOUT_GAP; + let x = if right + fly.width <= bounds.width { right } else { (p.x - fly.width - FLYOUT_GAP).max(0.0) }; + nodes.push(main); + nodes.push(node.move_to(Point::new(x, y))); + } else { + nodes.push(main); + } + layout::Node::with_children(bounds, nodes) + } + + fn draw(&self, renderer: &mut Renderer, theme: &Theme, style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor) { + let mut children = layout.children(); + if let Some(main) = children.next() { + self.main.as_widget().draw(self.main_tree, renderer, theme, style, main, cursor, &layout.bounds()); + } + if let (Some(((_, flyout), tree)), Some(fly)) = (self.flyout.as_ref(), children.next()) { + flyout.as_widget().draw(tree, renderer, theme, style, fly, cursor, &layout.bounds()); + } + } + + fn update( + &mut self, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + shell: &mut Shell<'_, Message>, + ) { + let mut children = layout.children(); + let Some(main) = children.next() else { return }; + let fly = children.next(); + let over = cursor.is_over(main.bounds()) || fly.is_some_and(|f| cursor.is_over(f.bounds())); + match event { + Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Escape), .. }) => { + self.close(shell); + shell.capture_event(); + return; + } + Event::Mouse(mouse::Event::ButtonPressed(_)) if !over => { + self.close(shell); + return; + } + _ => {} + } + let viewport = layout.bounds(); + self.main.as_widget_mut().update(self.main_tree, event, main, cursor, renderer, shell, &viewport); + if let (Some(((_, flyout), tree)), Some(fly)) = (self.flyout.as_mut(), fly) { + flyout.as_widget_mut().update(tree, event, fly, cursor, renderer, shell, &viewport); + } + if let Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) = event { + // A chosen entry closes the menu; a submenu entry opens its fly-out instead. + let on_submenu = entries(main) + .zip(self.keep_open.iter()) + .any(|(e, keep)| *keep && cursor.is_over(e.bounds())); + if over && !on_submenu { + self.close(shell); + } + } + if over { + shell.capture_event(); + } + } + + fn mouse_interaction(&self, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer) -> mouse::Interaction { + let mut children = layout.children(); + let main = children.next().map(|m| { + self.main.as_widget().mouse_interaction(self.main_tree, m, cursor, &m.bounds(), renderer) + }); + let fly = match (self.flyout.as_ref(), children.next()) { + (Some(((_, f), tree)), Some(l)) => Some(f.as_widget().mouse_interaction(tree, l, cursor, &l.bounds(), renderer)), + _ => None, + }; + fly.filter(|i| *i != mouse::Interaction::default()).or(main).unwrap_or_default() + } +} diff --git a/src/ui/window/sheet_set.rs b/src/ui/window/sheet_set.rs index 7f49066e1..72aabdc39 100644 --- a/src/ui/window/sheet_set.rs +++ b/src/ui/window/sheet_set.rs @@ -612,7 +612,7 @@ fn plain_row<'a>( fold: Option<(bool, Message)>, double: Option, menu: Vec, - open_sub: Option<(String, bool)>, + open_sub: Option, ) -> Element<'a, Message> { let arrow: Element<'a, Message> = match fold { Some((open, m)) => button(if open { crate::ui::icons::themed_arrow_down(10.0) } else { crate::ui::icons::themed_arrow_right(10.0) }) @@ -637,13 +637,10 @@ fn plain_row<'a>( if menu.is_empty() { return area.into(); } - iced_aw::ContextMenu::new(area, move || menu_panel(&menu, open_sub.as_ref())).into() + let (main, keep_open, flyout) = menu_panel(&menu, open_sub.as_deref()); + crate::ui::window::row_menu::RowMenu::new(area, main, keep_open, flyout, msg(SheetSetMsg::Submenu(None))).into() } -/// Height of a menu entry and of a separator (with the column spacing). -const MENU_ENTRY_H: f32 = 27.0; -const MENU_SEP_H: f32 = 2.0; - fn menu_box<'b>(items: Vec>) -> Element<'b, Message> { container(iced::widget::Column::with_children(items).spacing(1)) .padding(4) @@ -656,48 +653,32 @@ fn menu_box<'b>(items: Vec>) -> Element<'b, Message> { .into() } -/// A row menu; hovering a submenu entry opens its items beside it (`open`: -/// key and whether the fly-out goes on the left). -fn menu_panel(menu: &[Mi], open: Option<&(String, bool)>) -> Element<'static, Message> { +/// A row menu: the menu box, which of its entries open a fly-out, and the +/// open fly-out (its entry index and items). Hovering, pressing or right +/// pressing a submenu entry opens its fly-out. +fn menu_panel(menu: &[Mi], open: Option<&str>) -> (Element<'static, Message>, Vec, Option<(usize, Element<'static, Message>)>) { let mut items: Vec> = Vec::new(); - let mut flyout: Option<(f32, Element<'static, Message>)> = None; - let mut y = 0.0; + let mut keep_open = Vec::new(); + let mut flyout = None; for item in menu { match item { - Mi::Sep => { - items.push(menu_separator()); - y += MENU_SEP_H; - } + Mi::Sep => items.push(menu_separator()), Mi::Entry(label, m) => { items.push(mouse_area(menu_entry(label.clone(), m.clone())).on_enter(msg(SheetSetMsg::Submenu(None))).into()); - y += MENU_ENTRY_H; } Mi::Sub(label, key, sub) => { - let open_here = open.is_some_and(|(k, _)| k == key); - // Hover (or a right press) opens the fly-out; a click closes the menu. - let entry = menu_entry(format!("{label} ▸"), Some(msg(SheetSetMsg::Submenu(None)))); let open_msg = msg(SheetSetMsg::Submenu(Some(key.clone()))); - items.push(mouse_area(entry).on_enter(open_msg.clone()).on_right_press(open_msg).into()); - if open_here { + let entry = menu_entry(format!("{label} ▸"), Some(open_msg.clone())); + if open == Some(key.as_str()) { let subs = sub.iter().map(|(l, m)| menu_entry(l.clone(), m.clone())).collect(); - flyout = Some((y, menu_box(subs))); + flyout = Some((items.len(), menu_box(subs))); } - y += MENU_ENTRY_H; + items.push(mouse_area(entry).on_enter(open_msg.clone()).on_right_press(open_msg).into()); } } + keep_open.push(matches!(item, Mi::Sub(..))); } - let main = menu_box(items); - match flyout { - Some((offset, sub)) => { - let sub = column![Space::new().height(Length::Fixed(offset)), sub]; - // A menu opened from a right-docked palette sits left of the cursor - // (the overlay flips it at the window edge); its fly-out opens to - // the left so the menu itself does not move. - // ponytail: side from the dock, not the menu's actual position. - if open.is_some_and(|(_, left)| *left) { row![sub, main] } else { row![main, sub] }.spacing(2).into() - } - None => main, - } + (menu_box(items), keep_open, flyout) } /// A menu item: translated label and message (an empty label is a separator). @@ -782,7 +763,7 @@ fn push_tree<'a>( /// A sheet view row and its menu: Display, Rename & Renumber, Set category /// (the categories as sub-items), Place Callout Block (the set's callout /// blocks as sub-items) and Place View Label Block. -fn view_row<'a>(state: &SheetSetManager, db: &SheetSetDatabase, view: &SsElement, depth: u16, left: bool) -> Element<'a, Message> { +fn view_row<'a>(state: &SheetSetManager, db: &SheetSetDatabase, view: &SsElement, depth: u16) -> Element<'a, Message> { let id = view.id().to_string(); let act = |a: ViewAction| Some(msg(SheetSetMsg::ViewMenu(id.clone(), a))); let categories = db.view_categories().iter().map(|c| (c.prop("Name").unwrap_or("").to_string(), act(ViewAction::Category(c.id().to_string())))).collect(); @@ -795,11 +776,11 @@ fn view_row<'a>(state: &SheetSetManager, db: &SheetSetDatabase, view: &SsElement Mi::Sub(t!("Place Callout Block").into_owned(), format!("{id}|callout"), callouts), mi("Place View Label Block", act(ViewAction::Label)), ]; - plain_row(depth, crate::ui::icons::DOC, ss::number_and_title(view), None, act(ViewAction::Display), menu, state.submenu.clone().map(|k| (k, left))) + plain_row(depth, crate::ui::icons::DOC, ss::number_and_title(view), None, act(ViewAction::Display), menu, state.submenu.clone()) } /// The Sheet Views page: the set, then its view categories (or sheets) and their views. -fn sheet_views_rows<'a>(state: &SheetSetManager, db: &SheetSetDatabase, left: bool) -> Vec> { +fn sheet_views_rows<'a>(state: &SheetSetManager, db: &SheetSetDatabase) -> Vec> { let new_category = || mi("New View Category...", Some(msg(SheetSetMsg::NewCategory))); let mut rows = vec![plain_row(0, SET_ICON, db.name().to_string(), None, None, vec![new_category()], None)]; let views: Vec<&SsElement> = db.sheets().into_iter().flat_map(|s| db.sheet_views(s)).collect(); @@ -807,7 +788,7 @@ fn sheet_views_rows<'a>(state: &SheetSetManager, db: &SheetSetDatabase, left: bo let named = db.view_categories(); // Views of the unnamed (default) category sit directly under the set. for v in views.iter().filter(|v| !SheetSetDatabase::view_category_of(v).is_some_and(|c| named.iter().any(|n| n.id() == c))) { - rows.push(view_row(state, db, v, 1, left)); + rows.push(view_row(state, db, v, 1)); } for c in named { let id = c.id().to_string(); @@ -825,7 +806,7 @@ fn sheet_views_rows<'a>(state: &SheetSetManager, db: &SheetSetDatabase, left: bo None, )); for v in views.iter().filter(|v| SheetSetDatabase::view_category_of(v) == Some(id.as_str())) { - rows.push(view_row(state, db, v, 2, left)); + rows.push(view_row(state, db, v, 2)); } } } else { @@ -836,7 +817,7 @@ fn sheet_views_rows<'a>(state: &SheetSetManager, db: &SheetSetDatabase, left: bo } rows.push(plain_row(1, crate::ui::icons::DOC, ss::number_and_title(sheet), None, None, vec![], None)); for v in views { - rows.push(view_row(state, db, v, 2, left)); + rows.push(view_row(state, db, v, 2)); } } } @@ -919,8 +900,7 @@ fn heading(label: &str) -> Element<'static, Message> { .into() } -/// `dock_right`: the palette is docked on the right (fly-outs open leftwards). -pub fn view<'a>(state: &'a SheetSetManager, width: f32, auto_collapse: bool, dock_right: bool) -> Element<'a, Message> { +pub fn view<'a>(state: &'a SheetSetManager, width: f32, auto_collapse: bool) -> Element<'a, Message> { let title_bar = crate::ui::dock::title_bar(PanelId::SheetSetManager, t!("Sheet Set Manager").into_owned(), auto_collapse); let mut picks: Vec = @@ -986,7 +966,7 @@ pub fn view<'a>(state: &'a SheetSetManager, width: f32, auto_collapse: bool, doc ] .spacing(4) .align_y(iced::Center); - column![header, scrollable(iced::widget::Column::with_children(sheet_views_rows(state, db, dock_right)).spacing(1)).height(Fill)] + column![header, scrollable(iced::widget::Column::with_children(sheet_views_rows(state, db)).spacing(1)).height(Fill)] .spacing(6) .into() }