//! Controlled contextual menus. Keep `Popup` around the root even when closed //! so a dismissal click's release cannot reach the widget underneath it. //! Each invocation gets a fresh token; action messages should carry that token //! for application-level context/revision validation. The application closes //! the menu when handling an action; `on_close` handles dismissal only. use crate::style; use iced::advanced::Renderer as _; use iced::{ Border, Color, Element, Event, Length, Point, Rectangle, Renderer, Shadow, Size, Theme, Vector, advanced::{ Clipboard, Layout, Shell, Widget, layout, overlay, renderer, text::{self, Paragraph as _, Renderer as _}, widget::{Operation, Tree, tree}, }, alignment, keyboard, mouse, touch, window, }; const PAD: f32 = 6.; const GAP: f32 = 8.; const ROW: f32 = 34.; #[derive(Debug, Clone)] pub enum Entry { Title(String), Separator, Action { label: String, shortcut: Option, message: Option, }, } impl Entry { pub fn title(label: impl Into) -> Self { Self::Title(label.into()) } pub fn separator() -> Self { Self::Separator } pub fn action(label: impl Into, message: Message) -> Self { Self::Action { label: label.into(), shortcut: None, message: Some(message), } } pub fn disabled(label: impl Into) -> Self { Self::Action { label: label.into(), shortcut: None, message: None, } } pub fn shortcut(mut self, shortcut: impl Into) -> Self { if let Self::Action { shortcut: label, .. } = &mut self { *label = Some(shortcut.into()); } self } fn height(&self) -> f32 { match self { Self::Title(_) => 28., Self::Separator => 9., Self::Action { .. } => ROW, } } fn enabled(&self) -> bool { matches!( self, Self::Action { message: Some(_), .. } ) } } #[derive(Debug, Clone)] pub struct Menu { pub token: u64, /// Absolute window coordinates, as returned by `Area` or the diagram. pub anchor: Point, pub entries: Vec>, pub on_close: Message, } pub struct Popup<'a, Message> { content: Element<'a, Message>, menu: Option>, } impl<'a, Message> Popup<'a, Message> { pub fn new(content: impl Into>, menu: Option>) -> Self { Self { content: content.into(), menu, } } } #[derive(Default)] struct State { token: Option, focused: Option, pressed: Option, scroll: f32, scroll_drag: Option, closing: bool, swallow_mouse_release: Option, swallow_touch_release: Option, } impl State { fn reconcile(&mut self, menu: Option<&Menu>) { let token = menu.map(|menu| menu.token); if self.token != token { self.token = token; self.focused = menu.and_then(|menu| menu.entries.iter().position(Entry::enabled)); self.pressed = None; self.scroll = 0.; self.scroll_drag = None; self.closing = false; } if let Some(menu) = menu { self.focused = self .focused .filter(|index| menu.entries.get(*index).is_some_and(Entry::enabled)); self.pressed = self .pressed .filter(|index| menu.entries.get(*index).is_some_and(Entry::enabled)); } } fn swallow_release(&mut self, event: &Event) -> bool { match event { Event::Mouse(mouse::Event::ButtonReleased(button)) if self.swallow_mouse_release == Some(*button) => { self.swallow_mouse_release = None; true } Event::Touch( touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. }, ) if self.swallow_touch_release == Some(*id) => { self.swallow_touch_release = None; true } _ => false, } } fn navigate( &mut self, entries: &[Entry], direction: Navigation, height: f32, ) { self.focused = next_enabled(entries, self.focused, direction); self.pressed = None; if let Some(index) = self.focused { let top: f32 = entries[..index].iter().map(Entry::height).sum(); let bottom = top + entries[index].height(); if top < self.scroll { self.scroll = top; } if bottom > self.scroll + height { self.scroll = (bottom - height).max(0.); } } } fn dismiss(&mut self, menu: &Menu, shell: &mut Shell<'_, Message>) { if !self.closing { self.closing = true; shell.publish(menu.on_close.clone()); } } fn activate( &mut self, menu: &Menu, index: usize, shell: &mut Shell<'_, Message>, ) { if !self.closing && let Some(Entry::Action { message: Some(message), .. }) = menu.entries.get(index) { self.closing = true; shell.publish(message.clone()); } } fn update( &mut self, menu: &Menu, geometry: Geometry, event: &Event, cursor: mouse::Cursor, shell: &mut Shell<'_, Message>, ) { let hit = |point| geometry.hit(&menu.entries, self.scroll, point); if self.closing { // Iced can deliver press and release in one event batch before the // application handles Close. Do not carry that consumed release // into the next independent click after the controlled menu closes. self.swallow_release(event); if input_event(event) { shell.capture_event(); } return; } match event { Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) => match key { keyboard::Key::Named(keyboard::key::Named::Escape) => self.dismiss(menu, shell), keyboard::Key::Named(keyboard::key::Named::ArrowDown) => { self.navigate(&menu.entries, Navigation::Next, geometry.inner.height) } keyboard::Key::Named(keyboard::key::Named::ArrowUp) => { self.navigate(&menu.entries, Navigation::Previous, geometry.inner.height) } keyboard::Key::Named(keyboard::key::Named::Home) => { self.navigate(&menu.entries, Navigation::First, geometry.inner.height) } keyboard::Key::Named(keyboard::key::Named::End) => { self.navigate(&menu.entries, Navigation::Last, geometry.inner.height) } keyboard::Key::Named(keyboard::key::Named::Enter | keyboard::key::Named::Space) => { if let Some(index) = self.focused { self.activate(menu, index, shell); } } _ => {} }, Event::Mouse(mouse::Event::CursorMoved { position }) => { if let Some(offset) = self.scroll_drag { self.scroll = geometry.scroll_for_pointer(*position, offset); } else { self.focused = hit(*position).filter(|index| menu.entries[*index].enabled()); } } Event::Mouse(mouse::Event::ButtonPressed(button)) => { if let Some(point) = cursor.position() { if !geometry.menu.contains(point) { self.swallow_mouse_release = Some(*button); self.dismiss(menu, shell); } else if *button == mouse::Button::Left { if let Some(track) = geometry.scroll_track() && track.contains(point) { let thumb = geometry.scroll_thumb(self.scroll).unwrap(); let offset = if thumb.contains(point) { point.y - thumb.y } else { thumb.height / 2. }; self.scroll = geometry.scroll_for_pointer(point, offset); self.scroll_drag = Some(offset); self.pressed = None; } else { self.pressed = hit(point).filter(|index| menu.entries[*index].enabled()); self.focused = self.pressed; } } } } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { if self.scroll_drag.take().is_none() && let Some(index) = self.pressed.take() && cursor.position().and_then(hit) == Some(index) { self.activate(menu, index, shell); } } Event::Mouse(mouse::Event::WheelScrolled { delta }) => { if cursor.is_over(geometry.menu) { let y = match delta { mouse::ScrollDelta::Lines { y, .. } => y * ROW, mouse::ScrollDelta::Pixels { y, .. } => *y, }; self.scroll = (self.scroll - y).clamp(0., geometry.max_scroll()); self.focused = cursor .position() .and_then(|point| geometry.hit(&menu.entries, self.scroll, point)) .filter(|index| menu.entries[*index].enabled()); self.pressed = None; } } Event::Touch(touch::Event::FingerPressed { id, position }) => { if !geometry.menu.contains(*position) { self.swallow_touch_release = Some(*id); self.dismiss(menu, shell); } else { self.pressed = hit(*position).filter(|index| menu.entries[*index].enabled()); self.focused = self.pressed; } } Event::Touch(touch::Event::FingerLifted { position, .. }) => { if let Some(index) = self.pressed.take() && hit(*position) == Some(index) { self.activate(menu, index, shell); } } Event::Touch(touch::Event::FingerLost { .. }) => self.pressed = None, Event::Window(window::Event::Unfocused) => self.dismiss(menu, shell), _ => {} } if input_event(event) { shell.capture_event(); shell.request_redraw(); } } } fn input_event(event: &Event) -> bool { matches!( event, Event::Mouse(_) | Event::Touch(_) | Event::Keyboard(_) | Event::InputMethod(_) ) } #[derive(Clone, Copy)] enum Navigation { First, Last, Next, Previous, } fn next_enabled( entries: &[Entry], current: Option, direction: Navigation, ) -> Option { let count = entries.len(); let current = current.filter(|index| *index < count); let start = match direction { Navigation::First => 0, Navigation::Last => count.saturating_sub(1), Navigation::Next => current.map_or(0, |index| (index + 1) % count.max(1)), Navigation::Previous => current.map_or(count.saturating_sub(1), |index| { (index + count - 1) % count.max(1) }), }; let backwards = matches!(direction, Navigation::Last | Navigation::Previous); (0..count) .map(|step| { if backwards { (start + count - step) % count } else { (start + step) % count } }) .find(|index| entries[*index].enabled()) } #[derive(Clone, Copy)] struct Geometry { menu: Rectangle, inner: Rectangle, content_height: f32, } impl Geometry { fn new(anchor: Point, width: f32, content_height: f32, viewport: Size) -> Self { let width = width.min((viewport.width - 2. * GAP).max(0.)); let height = (content_height + 2. * PAD).min((viewport.height - 2. * GAP).max(0.)); let bounded = |value: f32, max: f32| { if value.is_finite() { value.clamp(GAP.min(max), max) } else { GAP.min(max) } }; let x = bounded(anchor.x, (viewport.width - width - GAP).max(0.)); let y = bounded(anchor.y, (viewport.height - height - GAP).max(0.)); let menu = Rectangle { x, y, width, height, }; Self::from_bounds(menu, content_height) } fn from_bounds(menu: Rectangle, content_height: f32) -> Self { let inner = Rectangle { x: menu.x + PAD, y: menu.y + PAD, width: (menu.width - 2. * PAD).max(0.), height: (menu.height - 2. * PAD).max(0.), }; Self { menu, inner, content_height, } } fn max_scroll(self) -> f32 { (self.content_height - self.inner.height).max(0.) } fn row(self, top: f32, height: f32, scroll: f32) -> Rectangle { Rectangle { x: self.inner.x, y: self.inner.y + top - scroll, width: (self.inner.width - if self.max_scroll() > 0. { 12. } else { 0. }).max(0.), height, } } fn hit(self, entries: &[Entry], scroll: f32, point: Point) -> Option { if !self.inner.contains(point) || self .scroll_track() .is_some_and(|track| track.contains(point)) { return None; } let mut top = 0.; entries.iter().position(|entry| { let contains = self.row(top, entry.height(), scroll).contains(point); top += entry.height(); contains }) } fn scroll_track(self) -> Option { (self.max_scroll() > 0.).then_some(Rectangle { x: self.inner.x + (self.inner.width - 8.).max(0.), width: 8f32.min(self.inner.width), ..self.inner }) } fn scroll_thumb(self, scroll: f32) -> Option { let track = self.scroll_track()?; let height = (track.height * track.height / self.content_height) .max(24.) .min(track.height); Some(Rectangle { y: track.y + (track.height - height) * (scroll / self.max_scroll()).clamp(0., 1.), height, ..track }) } fn scroll_for_pointer(self, point: Point, offset: f32) -> f32 { let Some(track) = self.scroll_track() else { return 0.; }; let height = self.scroll_thumb(0.).unwrap().height; if track.height <= height { return 0.; } ((point.y - track.y - offset) / (track.height - height)).clamp(0., 1.) * self.max_scroll() } } impl Widget for Popup<'_, Message> { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { let mut state = State::default(); state.reconcile(self.menu.as_ref()); tree::State::new(state) } fn children(&self) -> Vec { vec![Tree::new(&self.content)] } fn diff(&self, tree: &mut Tree) { tree.diff_children(std::slice::from_ref(&self.content)); tree.state .downcast_mut::() .reconcile(self.menu.as_ref()); } fn size(&self) -> Size { self.content.as_widget().size() } fn size_hint(&self) -> Size { self.content.as_widget().size_hint() } fn layout( &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { self.content .as_widget_mut() .layout(&mut tree.children[0], renderer, limits) } fn operate( &mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation, ) { self.content .as_widget_mut() .operate(&mut tree.children[0], layout, renderer, operation); } fn update( &mut self, tree: &mut Tree, event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { let state = tree.state.downcast_mut::(); if self.menu.is_some() && input_event(event) || state.swallow_release(event) { shell.capture_event(); return; } self.content.as_widget_mut().update( &mut tree.children[0], event, layout, cursor, renderer, clipboard, shell, viewport, ); } fn draw( &self, tree: &Tree, renderer: &mut Renderer, theme: &Theme, renderer_style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, ) { self.content.as_widget().draw( &tree.children[0], renderer, theme, renderer_style, layout, if self.menu.is_some() { mouse::Cursor::Unavailable } else { cursor }, viewport, ); } fn mouse_interaction( &self, tree: &Tree, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { if self.menu.is_some() { mouse::Interaction::None } else { self.content.as_widget().mouse_interaction( &tree.children[0], layout, cursor, viewport, renderer, ) } } fn overlay<'b>( &'b mut self, tree: &'b mut Tree, layout: Layout<'b>, renderer: &Renderer, viewport: &Rectangle, translation: Vector, ) -> Option> { if let Some(menu) = self.menu.as_ref() { Some(overlay::Element::new(Box::new(MenuOverlay { menu, state: tree.state.downcast_mut(), }))) } else { self.content.as_widget_mut().overlay( &mut tree.children[0], layout, renderer, viewport, translation, ) } } } impl<'a, Message: Clone + 'a> From> for Element<'a, Message> { fn from(popup: Popup<'a, Message>) -> Self { Element::new(popup) } } struct MenuOverlay<'a, Message> { menu: &'a Menu, state: &'a mut State, } impl MenuOverlay<'_, Message> { fn geometry(&self, layout: Layout<'_>) -> Geometry { // Iced reconstructs overlays between layout, update and draw. Only the // retained Layout owns the measured bounds, never a transient overlay. Geometry::from_bounds( layout .children() .next() .expect("Menu overlay layout contains its measured menu bounds") .bounds(), self.menu.entries.iter().map(Entry::height).sum(), ) } fn layout_with_font(&mut self, font: iced::Font, bounds: Size) -> layout::Node { let width = self .menu .entries .iter() .map(|entry| match entry { Entry::Title(label) => measure(label, 12., font) + 32., Entry::Separator => 0., Entry::Action { label, shortcut, .. } => { measure(label, 13., font) + shortcut .as_ref() .map_or(0., |label| measure(label, 12., font) + 28.) + 40. } }) .fold(240f32, f32::max) .min(480.); let geometry = Geometry::new( self.menu.anchor, width, self.menu.entries.iter().map(Entry::height).sum(), bounds, ); self.state.scroll = self.state.scroll.clamp(0., geometry.max_scroll()); // The overlay covers the whole window so outside presses are captured. layout::Node::with_children( bounds, vec![layout::Node::new(geometry.menu.size()).move_to(geometry.menu.position())], ) } } impl overlay::Overlay for MenuOverlay<'_, Message> { fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { self.layout_with_font(renderer.default_font(), bounds) } fn update( &mut self, event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, _renderer: &Renderer, _clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, ) { let geometry = self.geometry(layout); self.state.update(self.menu, geometry, event, cursor, shell); } fn draw( &self, renderer: &mut Renderer, _theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, _cursor: mouse::Cursor, ) { let geometry = self.geometry(layout); renderer.fill_quad( renderer::Quad { bounds: geometry.menu, border: Border { color: style::LINE, width: 1., radius: 8.into(), }, shadow: Shadow { color: Color::from_rgba(0.1, 0.15, 0.2, 0.17), offset: Vector::new(0., 4.), blur_radius: 12., }, ..Default::default() }, Color::WHITE, ); renderer.with_layer(geometry.inner, |renderer| { let mut top = 0.; for (index, entry) in self.menu.entries.iter().enumerate() { let row = geometry.row(top, entry.height(), self.state.scroll); top += entry.height(); if !row.intersects(&geometry.inner) { continue; } match entry { Entry::Separator => renderer.fill_quad( renderer::Quad { bounds: Rectangle { x: row.x + 8., y: row.center_y(), width: (row.width - 16.).max(0.), height: 1., }, ..Default::default() }, style::LINE, ), Entry::Title(label) => draw_label( renderer, label, 12., style::MUTED, Rectangle { x: row.x + 9., width: (row.width - 18.).max(0.), ..row }, text::Alignment::Left, ), Entry::Action { label, shortcut, message, } => { if self.state.focused == Some(index) && message.is_some() { renderer.fill_quad( renderer::Quad { bounds: row, border: Border { radius: 5.into(), ..Default::default() }, ..Default::default() }, Color::from_rgb8(218, 238, 235), ); } let color = if message.is_some() { style::INK } else { Color { a: 0.65, ..style::MUTED } }; let suffix = shortcut.as_ref().map_or(0., |label| { measure(label, 12., renderer.default_font()).min(row.width * 0.45) + 22. }); draw_label( renderer, label, 13., color, Rectangle { x: row.x + 10., width: (row.width - suffix - 20.).max(0.), ..row }, text::Alignment::Left, ); if let Some(shortcut) = shortcut { draw_label( renderer, shortcut, 12., if message.is_some() { style::MUTED } else { color }, Rectangle { x: row.x + row.width - suffix, width: (suffix - 10.).max(0.), ..row }, text::Alignment::Right, ); } } } } if let Some(thumb) = geometry.scroll_thumb(self.state.scroll) { renderer.fill_quad( renderer::Quad { bounds: thumb, border: Border { radius: 4.into(), ..Default::default() }, ..Default::default() }, style::LINE, ); } }); } fn mouse_interaction( &self, layout: Layout<'_>, cursor: mouse::Cursor, _renderer: &Renderer, ) -> mouse::Interaction { let geometry = self.geometry(layout); if cursor.position().is_some_and(|point| { geometry .hit(&self.menu.entries, self.state.scroll, point) .is_some_and(|index| self.menu.entries[index].enabled()) || geometry .scroll_track() .is_some_and(|track| track.contains(point)) }) { mouse::Interaction::Pointer } else { mouse::Interaction::None } } } fn measure(label: &str, size: f32, font: iced::Font) -> f32 { ::Paragraph::with_text(text::Text { content: label, bounds: Size::INFINITE, size: size.into(), line_height: text::LineHeight::default(), font, align_x: text::Alignment::Left, align_y: alignment::Vertical::Center, shaping: text::Shaping::Advanced, wrapping: text::Wrapping::None, }) .min_width() } fn ellipsize(label: &str, width: f32, size: f32, font: iced::Font) -> String { if width <= 0. { return String::new(); } if measure(label, size, font) <= width { return label.to_owned(); } if measure("…", size, font) > width { return String::new(); } let ends: Vec<_> = label .char_indices() .map(|(index, _)| index) .chain(std::iter::once(label.len())) .collect(); let (mut low, mut high) = (0, ends.len()); while low + 1 < high { let middle = (low + high) / 2; if measure(&format!("{}…", &label[..ends[middle]]), size, font) <= width { low = middle; } else { high = middle; } } format!("{}…", &label[..ends[low]]) } fn draw_label( renderer: &mut Renderer, label: &str, size: f32, color: Color, bounds: Rectangle, align_x: text::Alignment, ) { let font = renderer.default_font(); let content = ellipsize(label, bounds.width, size, font); renderer.fill_text( text::Text { content, bounds: bounds.size(), size: size.into(), line_height: text::LineHeight::default(), font, align_x, align_y: alignment::Vertical::Center, shaping: text::Shaping::Advanced, wrapping: text::Wrapping::None, }, Point::new( if align_x == text::Alignment::Right { bounds.x + bounds.width } else { bounds.x }, bounds.center_y(), ), color, bounds, ); } /// Adds a contextual trigger without changing the child's layout or overlays. /// Right-click anchors at the absolute cursor position. `left` anchors below /// the child and intercepts its left press before a nested button can consume it. pub struct Area<'a, Message> { content: Element<'a, Message>, on_open: Box Message + 'a>, button: mouse::Button, } impl<'a, Message> Area<'a, Message> { pub fn new( content: impl Into>, on_open: impl Fn(Point) -> Message + 'a, ) -> Self { Self { content: content.into(), on_open: Box::new(on_open), button: mouse::Button::Right, } } pub fn left( content: impl Into>, on_open: impl Fn(Point) -> Message + 'a, ) -> Self { Self { content: content.into(), on_open: Box::new(on_open), button: mouse::Button::Left, } } } #[derive(Default)] struct AreaState { release: Option, } impl AreaState { fn consume_release(&mut self, event: &Event) -> bool { // The popup may consume the opening release. A new press belongs to // another click, including one outside this Area after Escape. if matches!( event, Event::Mouse(mouse::Event::ButtonPressed(_)) | Event::Window(window::Event::Unfocused) ) { self.release = None; } if let Event::Mouse(mouse::Event::ButtonReleased(button)) = event && self.release == Some(*button) { self.release = None; return true; } false } } impl Widget for Area<'_, Message> { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { tree::State::new(AreaState::default()) } fn children(&self) -> Vec { vec![Tree::new(&self.content)] } fn diff(&self, tree: &mut Tree) { tree.diff_children(std::slice::from_ref(&self.content)); } fn size(&self) -> Size { self.content.as_widget().size() } fn size_hint(&self) -> Size { self.content.as_widget().size_hint() } fn layout( &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { self.content .as_widget_mut() .layout(&mut tree.children[0], renderer, limits) } fn operate( &mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation, ) { self.content .as_widget_mut() .operate(&mut tree.children[0], layout, renderer, operation); } fn update( &mut self, tree: &mut Tree, event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { let state = tree.state.downcast_mut::(); if state.consume_release(event) { shell.capture_event(); return; } if let Event::Mouse(mouse::Event::ButtonPressed(button)) = event && *button == self.button && cursor.is_over(layout.bounds()) && cursor.is_over(*viewport) && let Some(position) = cursor.position() { state.release = Some(*button); let anchor = if self.button == mouse::Button::Left { Point::new( layout.bounds().x, layout.bounds().y + layout.bounds().height, ) } else { position }; shell.publish((self.on_open)(anchor)); shell.capture_event(); return; } self.content.as_widget_mut().update( &mut tree.children[0], event, layout, cursor, renderer, clipboard, shell, viewport, ); } fn draw( &self, tree: &Tree, renderer: &mut Renderer, theme: &Theme, renderer_style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, ) { self.content.as_widget().draw( &tree.children[0], renderer, theme, renderer_style, layout, cursor, viewport, ); } fn mouse_interaction( &self, tree: &Tree, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { if self.button == mouse::Button::Left && cursor.is_over(layout.bounds()) && cursor.is_over(*viewport) { mouse::Interaction::Pointer } else { self.content.as_widget().mouse_interaction( &tree.children[0], layout, cursor, viewport, renderer, ) } } fn overlay<'b>( &'b mut self, tree: &'b mut Tree, layout: Layout<'b>, renderer: &Renderer, viewport: &Rectangle, translation: Vector, ) -> Option> { self.content.as_widget_mut().overlay( &mut tree.children[0], layout, renderer, viewport, translation, ) } } impl<'a, Message: 'a> From> for Element<'a, Message> { fn from(area: Area<'a, Message>) -> Self { Element::new(area) } } #[cfg(test)] mod tests { use super::*; #[test] fn actions_area_does_not_swallow_first_independent_click_after_overlay_consumed_its_release() { let release = Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)); let mut state = AreaState { release: Some(mouse::Button::Left), }; // Opening release went to the menu; Escape closed it. The following // Revert press is outside the Area, so no new release is armed. assert!( !state.consume_release(&Event::Mouse(mouse::Event::ButtonPressed( mouse::Button::Left ))) ); assert!(!state.consume_release(&release)); // A coalesced release arriving before the popup mounts is still caught. state.release = Some(mouse::Button::Left); assert!(state.consume_release(&release)); assert!(!state.consume_release(&release)); } fn menu() -> Menu { Menu { token: 1, anchor: Point::new(1060., 660.), entries: vec![ Entry::title("Selected element"), Entry::action("Add port", 11).shortcut("P"), Entry::disabled("Remove bend"), Entry::separator(), Entry::action("Properties", 22), Entry::disabled("Unavailable action"), ], on_close: 99, } } fn geometry(menu: &Menu) -> Geometry { Geometry::new( menu.anchor, 320., menu.entries.iter().map(Entry::height).sum(), Size::new(1100., 700.), ) } fn key(named: keyboard::key::Named) -> Event { Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(named), modified_key: keyboard::Key::Named(named), physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space), location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::empty(), text: None, repeat: false, }) } fn dispatch( state: &mut State, menu: &Menu, event: Event, point: Option, ) -> (Vec, bool) { let mut messages = Vec::new(); let mut shell = Shell::new(&mut messages); state.update( menu, geometry(menu), &event, point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available), &mut shell, ); let captured = shell.is_event_captured(); (messages, captured) } fn row_center(menu: &Menu, index: usize, scroll: f32) -> Point { let top = menu.entries[..index].iter().map(Entry::height).sum(); geometry(menu) .row(top, menu.entries[index].height(), scroll) .center() } #[test] fn reconstructed_overlay_uses_retained_layout_for_rows_and_hit_testing() { let menu = menu(); let mut state = State::default(); state.reconcile(Some(&menu)); // Iced lays out one overlay, drops it, then reconstructs the overlay // for update/draw while retaining the layout tree from the first one. let node = MenuOverlay { menu: &menu, state: &mut state, } .layout_with_font(iced::Font::DEFAULT, Size::new(1100., 700.)); let overlay = MenuOverlay { menu: &menu, state: &mut state, }; let geometry = overlay.geometry(Layout::new(&node)); assert_eq!( geometry.menu.height, menu.entries.iter().map(Entry::height).sum::() + 2. * PAD ); assert_eq!(geometry.menu, node.children()[0].bounds()); assert!(geometry.menu.width >= 240.); let top = menu.entries[..4].iter().map(Entry::height).sum(); let point = geometry.row(top, ROW, 0.).center(); assert!(geometry.inner.contains(point)); assert_eq!(geometry.hit(&menu.entries, 0., point), Some(4)); let mut messages = Vec::new(); for event in [ mouse::Event::ButtonPressed(mouse::Button::Left), mouse::Event::ButtonReleased(mouse::Button::Left), ] { // A fresh overlay must not lose geometry even between the two // halves of a click on a visible action near the bottom. let overlay = MenuOverlay { menu: &menu, state: &mut state, }; let mut shell = Shell::new(&mut messages); overlay.state.update( &menu, overlay.geometry(Layout::new(&node)), &Event::Mouse(event), mouse::Cursor::Available(point), &mut shell, ); assert!(shell.is_event_captured()); } assert_eq!(messages, [22]); } #[test] fn retained_overlay_layout_keeps_measured_width_and_scrollbar_after_reconstruction() { let menu = Menu { entries: (0..90) .map(|index| { Entry::action( format!("Action {index} with a longer contextual description"), index, ) .shortcut("Ctrl+Shift+Enter") }) .collect(), ..menu() }; let mut state = State::default(); state.reconcile(Some(&menu)); let node = MenuOverlay { menu: &menu, state: &mut state, } .layout_with_font(iced::Font::DEFAULT, Size::new(1100., 700.)); let overlay = MenuOverlay { menu: &menu, state: &mut state, }; let geometry = overlay.geometry(Layout::new(&node)); assert!( geometry.menu.width > 280., "Retain measured label and shortcut width" ); assert_eq!(geometry.menu.height, 700. - 2. * GAP); assert!(geometry.scroll_track().is_some()); overlay .state .navigate(&menu.entries, Navigation::Last, geometry.inner.height); assert_eq!(overlay.state.scroll, geometry.max_scroll()); let final_row = geometry.row(89. * ROW, ROW, overlay.state.scroll); assert_eq!( geometry.hit(&menu.entries, overlay.state.scroll, final_row.center()), Some(89) ); } #[test] fn keyboard_skips_headers_separators_and_disabled_actions_and_invokes_once() { let menu = menu(); let mut state = State::default(); state.reconcile(Some(&menu)); assert_eq!(state.focused, Some(1)); for (key_name, expected) in [ (keyboard::key::Named::ArrowDown, 4), (keyboard::key::Named::ArrowDown, 1), (keyboard::key::Named::ArrowUp, 4), (keyboard::key::Named::Home, 1), (keyboard::key::Named::End, 4), ] { let (messages, captured) = dispatch(&mut state, &menu, key(key_name), None); assert!(messages.is_empty()); assert!(captured); assert_eq!(state.focused, Some(expected)); } assert_eq!( dispatch(&mut state, &menu, key(keyboard::key::Named::Enter), None), (vec![22], true) ); assert_eq!( dispatch(&mut state, &menu, key(keyboard::key::Named::Enter), None), (vec![], true) ); } #[test] fn empty_and_disabled_only_menus_still_capture_keys_and_escape_closes() { for entries in [ vec![], vec![ Entry::title("No selection"), Entry::separator(), Entry::disabled("Delete"), ], ] { let menu = Menu { entries, ..menu() }; let mut state = State::default(); state.reconcile(Some(&menu)); for direction in [ keyboard::key::Named::ArrowUp, keyboard::key::Named::ArrowDown, keyboard::key::Named::Home, keyboard::key::Named::End, keyboard::key::Named::Enter, keyboard::key::Named::Delete, ] { assert_eq!( dispatch(&mut state, &menu, key(direction), None), (vec![], true) ); assert_eq!(state.focused, None); } assert_eq!( dispatch(&mut state, &menu, key(keyboard::key::Named::Escape), None), (vec![99], true) ); } } #[test] fn outside_dismissal_consumes_press_and_release_across_controlled_close() { let menu = menu(); let mut state = State::default(); state.reconcile(Some(&menu)); let outside = Point::new(20., 20.); assert_eq!( dispatch( &mut state, &menu, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), Some(outside) ), (vec![99], true) ); state.reconcile::(None); assert!( state.swallow_release(&Event::Mouse(mouse::Event::ButtonReleased( mouse::Button::Left ))) ); assert!( !state.swallow_release(&Event::Mouse(mouse::Event::ButtonReleased( mouse::Button::Left ))), "Only the dismissal gesture is suppressed" ); state.reconcile(Some(&menu)); let finger = touch::Finger(4); assert_eq!( dispatch( &mut state, &menu, Event::Touch(touch::Event::FingerPressed { id: finger, position: outside }), None ), (vec![99], true) ); state.reconcile::(None); assert!( state.swallow_release(&Event::Touch(touch::Event::FingerLifted { id: finger, position: outside })) ); } #[test] fn coalesced_dismissal_release_does_not_swallow_the_next_independent_click() { let menu = menu(); let mut state = State::default(); state.reconcile(Some(&menu)); let outside = Some(Point::new(20., 20.)); assert_eq!( dispatch( &mut state, &menu, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), outside ), (vec![99], true) ); let release = Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)); assert_eq!( dispatch(&mut state, &menu, release.clone(), outside), (vec![], true) ); state.reconcile::(None); assert!(!state.swallow_release(&release)); } #[test] fn clicking_disabled_or_dragging_off_an_action_never_executes_it() { let menu = menu(); let mut state = State::default(); state.reconcile(Some(&menu)); let disabled = row_center(&menu, 2, 0.); let enabled = row_center(&menu, 1, 0.); for (press, release) in [(disabled, disabled), (enabled, disabled)] { assert!( dispatch( &mut state, &menu, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), Some(press) ) .0 .is_empty() ); assert!( dispatch( &mut state, &menu, Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), Some(release) ) .0 .is_empty() ); } dispatch( &mut state, &menu, Event::Mouse(mouse::Event::CursorMoved { position: enabled }), Some(enabled), ); assert_eq!(state.focused, Some(1)); dispatch( &mut state, &menu, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), Some(enabled), ); assert_eq!( dispatch( &mut state, &menu, Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), Some(enabled) ), (vec![11], true) ); } #[test] fn a_new_context_token_cannot_reuse_the_previous_mouse_press_or_scroll() { let menu = menu(); let mut state = State::default(); state.reconcile(Some(&menu)); let point = row_center(&menu, 1, 0.); dispatch( &mut state, &menu, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), Some(point), ); state.scroll = 50.; let mut replacement = menu.clone(); replacement.token += 1; replacement.entries[1] = Entry::action("Different target", 44); state.reconcile(Some(&replacement)); assert_eq!(state.scroll, 0.); assert_eq!(state.pressed, None); assert_eq!( dispatch( &mut state, &replacement, Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), Some(point) ), (vec![], true) ); } #[test] fn tall_menu_clamps_to_viewport_and_keyboard_wheel_and_scrollbar_reach_the_end() { let menu = Menu { entries: (0..90) .map(|index| Entry::action(format!("Action {index}"), index)) .collect(), ..menu() }; let geometry = geometry(&menu); assert!(geometry.menu.x >= GAP && geometry.menu.y >= GAP); assert!(geometry.menu.x + geometry.menu.width <= 1100. - GAP); assert!(geometry.menu.y + geometry.menu.height <= 700. - GAP); let mut state = State::default(); state.reconcile(Some(&menu)); dispatch(&mut state, &menu, key(keyboard::key::Named::End), None); assert_eq!(state.focused, Some(89)); assert_eq!(state.scroll, geometry.max_scroll()); let last = row_center(&menu, 89, state.scroll); assert_eq!(geometry.hit(&menu.entries, state.scroll, last), Some(89)); dispatch( &mut state, &menu, Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Lines { x: 0., y: 2. }, }), Some(last), ); assert_eq!(state.scroll, geometry.max_scroll() - 2. * ROW); let track = geometry.scroll_track().unwrap(); let bottom = Point::new(track.center_x(), track.y + track.height - 1.); dispatch( &mut state, &menu, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), Some(bottom), ); assert_eq!(state.scroll, geometry.max_scroll()); dispatch( &mut state, &menu, Event::Mouse(mouse::Event::CursorMoved { position: track.position(), }), Some(track.position()), ); assert_eq!(state.scroll, 0.); assert!( dispatch( &mut state, &menu, Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), Some(last) ) .0 .is_empty() ); } #[test] fn anchors_outside_the_window_and_nonfinite_input_stay_bounded() { for anchor in [ Point::new(-100., -200.), Point::new(10_000., 10_000.), Point::new(f32::NAN, f32::INFINITY), ] { let geometry = Geometry::new(anchor, 480., 4000., Size::new(1100., 700.)); assert!(geometry.menu.x.is_finite() && geometry.menu.y.is_finite()); assert!(geometry.menu.x >= 0. && geometry.menu.y >= 0.); assert!(geometry.menu.x + geometry.menu.width <= 1100.); assert!(geometry.menu.y + geometry.menu.height <= 700.); } } }