Skip to main content

iced_cube/overlay/
context_menu.rs

1//! A menu that opens at the pointer on a right-click inside an area, or at
2//! the finger on a long press.
3//!
4//! It uses the dropdown menu's entries, rows and navigation: build the
5//! entries with [`dropdown_menu::item`]
6//! and friends. [`State`] adds where the menu opened. On a touch screen, a
7//! finger held still on the area for [`LONG_PRESS`] opens it. From the
8//! keyboard, Shift+F10 or the Menu key opens it just below the area, aligned
9//! with its start, as desktop apps do.
10//!
11//! One [`State`] can serve many areas, such as every row of a list: give
12//! each area a key with [`keyed`], and the open events carry the key of the
13//! area they came from. [`State::target`] tells the app which row a chosen
14//! item belongs to.
15//!
16//! Keyboard shortcuts resolve through a [`Keymap`] of [`Action`]s; see
17//! [`default_keymap`]. The open menu handles its own keys; the app routes
18//! the opening chord through [`State::key_event`].
19
20use std::rc::Rc;
21
22use iced::advanced::layout::{self, Layout};
23use iced::advanced::overlay;
24use iced::advanced::renderer;
25use iced::advanced::widget::{Operation, Tree, Widget, tree};
26use iced::advanced::{Clipboard, Shell};
27use iced::keyboard::key::Named;
28use iced::time::{Duration, Instant};
29use iced::touch::{self, Finger};
30use iced::{
31    Element, Event as IcedEvent, Length, Point, Rectangle, Renderer, Size, Theme, Vector, mouse,
32    window,
33};
34
35use crate::keys::{self, Chord, Keymap};
36use crate::overlay::anchored::{Align, Placement, Side, anchored};
37use crate::overlay::dropdown_menu::{self, Entry, Output, WIDTH};
38
39/// Changes to a context menu. `Key` names the area an open event came from;
40/// it is `()` for a menu with a single area.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub enum Event<Id, Key = ()> {
43    /// A right-click or a long press on the area `Key` at this point,
44    /// relative to the area's top left corner. Opens the menu there with
45    /// nothing highlighted.
46    Open(Key, Point),
47    /// Opens the menu below the area `Key` on its first item.
48    OpenFromKeyboard(Key),
49    /// Navigation and choosing, as in a dropdown menu. Ignored while closed.
50    Menu(dropdown_menu::Event<Id>),
51}
52
53/// The menu, which area it opened on and where.
54#[derive(Debug, Clone, PartialEq)]
55pub struct State<Id, Key = ()> {
56    menu: dropdown_menu::State<Id>,
57    position: Option<Point>,
58    target: Option<Key>,
59}
60
61impl<Id: Copy + PartialEq, Key: Clone + PartialEq> State<Id, Key> {
62    /// A closed menu. Item ids must be unique across submenus.
63    pub fn new(entries: impl IntoIterator<Item = Entry<Id>>) -> Self {
64        Self {
65            menu: dropdown_menu::State::new(entries),
66            position: None,
67            target: None,
68        }
69    }
70
71    /// The menu entries, highlight and open submenus.
72    pub fn menu(&self) -> &dropdown_menu::State<Id> {
73        &self.menu
74    }
75
76    pub fn is_open(&self) -> bool {
77        self.menu.is_open()
78    }
79
80    /// Whether the menu is open on the area `key`.
81    pub fn is_open_on(&self, key: &Key) -> bool {
82        self.is_open() && self.target.as_ref() == Some(key)
83    }
84
85    /// The area the menu last opened on. It stays set after the menu closes,
86    /// so the app can tell which row a chosen item belongs to.
87    pub fn target(&self) -> Option<&Key> {
88        self.target.as_ref()
89    }
90
91    /// Where a right-click opened the menu, relative to the area's top left
92    /// corner, or `None` when it opened from the keyboard below the area.
93    pub fn position(&self) -> Option<Point> {
94        self.position
95    }
96
97    pub fn is_checked(&self, id: Id) -> bool {
98        self.menu.is_checked(id)
99    }
100
101    /// Checks or unchecks an item. Checking a radio item unchecks its group.
102    pub fn set_checked(&mut self, id: Id, checked: bool) {
103        self.menu.set_checked(id, checked);
104    }
105
106    pub fn set_disabled(&mut self, id: Id, disabled: bool) {
107        self.menu.set_disabled(id, disabled);
108    }
109
110    /// Applies an event and returns what was chosen, if anything. Opening
111    /// on another area moves the menu there and resets it.
112    pub fn update(&mut self, event: Event<Id, Key>) -> Option<Output<Id>> {
113        match event {
114            Event::Open(key, position) => {
115                self.target = Some(key);
116                self.position = Some(position);
117                let _ = self.menu.update(dropdown_menu::Event::Close);
118                self.menu.update(dropdown_menu::Event::Open)
119            }
120            Event::OpenFromKeyboard(key) => {
121                self.target = Some(key);
122                self.position = None;
123                let _ = self.menu.update(dropdown_menu::Event::Close);
124                self.menu.update(dropdown_menu::Event::First)
125            }
126            Event::Menu(_) if !self.is_open() => None,
127            Event::Menu(event) => self.menu.update(event),
128        }
129    }
130
131    /// Turns a key press into an event: first through `keymap`, then, while
132    /// the menu is open, as typeahead for a letter or digit. `target` is the
133    /// area the opening chord opens the menu on, such as the focused row;
134    /// pass `()` for a menu with a single area.
135    pub fn key_event(
136        &self,
137        keymap: &Keymap<Action>,
138        key: &keys::Event,
139        target: Key,
140    ) -> Option<Event<Id, Key>> {
141        if let Some(action) = keymap.resolve_event(key) {
142            return action.event(self, target);
143        }
144        if !self.is_open() {
145            return None;
146        }
147        dropdown_menu::typeahead(key)
148            .map(|character| Event::Menu(dropdown_menu::Event::Typeahead(character)))
149    }
150}
151
152/// What a context menu keyboard shortcut does.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
154pub enum Action {
155    Open,
156    Next,
157    Previous,
158    First,
159    Last,
160    Activate,
161    Close,
162    OpenSubmenu,
163    CloseSubmenu,
164}
165
166impl Action {
167    /// The dropdown menu action this one drives, if it is not [`Action::Open`].
168    pub fn menu_action(self) -> Option<dropdown_menu::Action> {
169        use dropdown_menu::Action as Menu;
170        match self {
171            Action::Open => None,
172            Action::Next => Some(Menu::Next),
173            Action::Previous => Some(Menu::Previous),
174            Action::First => Some(Menu::First),
175            Action::Last => Some(Menu::Last),
176            Action::Activate => Some(Menu::Activate),
177            Action::Close => Some(Menu::Close),
178            Action::OpenSubmenu => Some(Menu::OpenSubmenu),
179            Action::CloseSubmenu => Some(Menu::CloseSubmenu),
180        }
181    }
182
183    /// The [`Event`] this action sends to `state`, or `None` when it does
184    /// nothing: only [`Action::Open`] works while the menu is closed, and
185    /// only the others while it is open. [`Action::Open`] opens on `target`.
186    pub fn event<Id: Copy + PartialEq, Key: Clone + PartialEq>(
187        self,
188        state: &State<Id, Key>,
189        target: Key,
190    ) -> Option<Event<Id, Key>> {
191        let open = state.is_open();
192        match self.menu_action() {
193            None => (!open).then_some(Event::OpenFromKeyboard(target)),
194            Some(_) if !open => None,
195            Some(action) => action.event(state.menu()).map(Event::Menu),
196        }
197    }
198}
199
200impl From<dropdown_menu::Action> for Action {
201    fn from(action: dropdown_menu::Action) -> Self {
202        use dropdown_menu::Action as Menu;
203        match action {
204            Menu::Open => Action::Open,
205            Menu::Next => Action::Next,
206            Menu::Previous => Action::Previous,
207            Menu::First => Action::First,
208            Menu::Last => Action::Last,
209            Menu::Activate => Action::Activate,
210            Menu::Close => Action::Close,
211            Menu::OpenSubmenu => Action::OpenSubmenu,
212            Menu::CloseSubmenu => Action::CloseSubmenu,
213        }
214    }
215}
216
217impl keys::Action for Action {
218    const ALL: &'static [Self] = &[
219        Action::Open,
220        Action::Next,
221        Action::Previous,
222        Action::First,
223        Action::Last,
224        Action::Activate,
225        Action::Close,
226        Action::OpenSubmenu,
227        Action::CloseSubmenu,
228    ];
229
230    fn defaults() -> Keymap<Self> {
231        default_keymap()
232    }
233
234    fn name(self) -> &'static str {
235        match self.menu_action() {
236            None => "Open",
237            Some(action) => keys::Action::name(action),
238        }
239    }
240
241    fn description(self) -> &'static str {
242        match self {
243            Action::Open => "Opens the menu below the area, on its first item.",
244            Action::Next => "Highlights the next enabled item, wrapping.",
245            Action::Previous => "Highlights the previous enabled item, wrapping.",
246            Action::Activate => "Chooses the highlighted item or opens its submenu.",
247            action => action.menu_action().map_or("", keys::Action::description),
248        }
249    }
250}
251
252/// The default context menu shortcuts: the dropdown menu's, plus
253///
254/// | Keys | Action |
255/// | --- | --- |
256/// | `Shift+F10`, `Menu` | [`Action::Open`] |
257///
258/// The open menu resolves its keys itself; pass a changed keymap with
259/// [`ContextMenu::keymap`]. While it is closed, only [`Action::Open`] does
260/// anything, and the app routes it from [`keys::subscription`] through
261/// [`State::key_event`].
262pub fn default_keymap() -> Keymap<Action> {
263    let open = Keymap::new()
264        .bind(Chord::named(Named::F10).shift(), Action::Open)
265        .bind(Chord::named(Named::ContextMenu), Action::Open);
266    dropdown_menu::default_keymap()
267        .bindings()
268        .fold(open, |keymap, (chord, action)| {
269            keymap.bind(chord.clone(), Action::from(*action))
270        })
271}
272
273/// Gap between an area and a menu opened from the keyboard below it.
274pub const KEYBOARD_GAP: f32 = 4.0;
275
276/// How long a finger rests on an area before the menu opens.
277pub const LONG_PRESS: Duration = Duration::from_millis(500);
278
279/// How far, in logical pixels, a finger may drift during a long press. Any
280/// further and it counts as a scroll or a drag.
281pub const LONG_PRESS_SLOP: f32 = 10.0;
282
283/// A finger held on an area. Time only comes in through [`LongPress::tick`],
284/// which the area calls on every redraw, so the timing is testable.
285#[derive(Debug, Clone, Copy, Default, PartialEq)]
286struct LongPress {
287    finger: Option<Finger>,
288    origin: Point,
289    since: Option<Instant>,
290    fired: bool,
291}
292
293impl LongPress {
294    /// Starts timing a finger pressed at `position`, replacing any other.
295    fn press(&mut self, finger: Finger, position: Point) {
296        *self = Self {
297            finger: Some(finger),
298            origin: position,
299            since: None,
300            fired: false,
301        };
302    }
303
304    /// Cancels the press once the finger drifts beyond [`LONG_PRESS_SLOP`].
305    fn moved(&mut self, finger: Finger, position: Point) {
306        if self.finger != Some(finger) || self.fired {
307            return;
308        }
309        if self.origin.distance(position) > LONG_PRESS_SLOP {
310            *self = Self::default();
311        }
312    }
313
314    /// Ends the press. Returns whether it had opened the menu, in which case
315    /// the lift must not reach the content as a tap.
316    fn lift(&mut self, finger: Finger) -> bool {
317        if self.finger != Some(finger) {
318            return false;
319        }
320        let fired = self.fired;
321        *self = Self::default();
322        fired
323    }
324
325    /// Starts the clock on the first redraw after the press, and returns
326    /// where the press began once, when it has been held for [`LONG_PRESS`].
327    fn tick(&mut self, now: Instant) -> Option<Point> {
328        if self.finger.is_none() || self.fired {
329            return None;
330        }
331        let since = *self.since.get_or_insert(now);
332        if now.saturating_duration_since(since) < LONG_PRESS {
333            return None;
334        }
335        self.fired = true;
336        Some(self.origin)
337    }
338
339    /// When the area next needs a redraw to keep timing, if it does.
340    fn redraw(&self) -> Option<window::RedrawRequest> {
341        if self.finger.is_none() || self.fired {
342            return None;
343        }
344        Some(match self.since {
345            None => window::RedrawRequest::NextFrame,
346            Some(since) => window::RedrawRequest::At(since + LONG_PRESS),
347        })
348    }
349}
350
351type OnEvent<'a, Id, Key, Message> = dyn Fn(Event<Id, Key>) -> Message + 'a;
352
353/// A context menu builder. Convert it into an [`Element`] to render.
354pub struct ContextMenu<'a, Id, Message, Key = ()> {
355    state: &'a State<Id, Key>,
356    key: Key,
357    content: Element<'a, Message>,
358    width: f32,
359    keymap: Keymap<Action>,
360    on_event: Option<Box<OnEvent<'a, Id, Key, Message>>>,
361}
362
363impl<Id: std::fmt::Debug, Message, Key: std::fmt::Debug> std::fmt::Debug
364    for ContextMenu<'_, Id, Message, Key>
365{
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        f.debug_struct("ContextMenu")
368            .field("state", self.state)
369            .field("key", &self.key)
370            .field("width", &self.width)
371            .field("keymap", &self.keymap)
372            .finish_non_exhaustive()
373    }
374}
375
376/// Makes `content` an area that opens the menu of `state` on a right-click.
377/// Without [`on_event`](ContextMenu::on_event) right-clicks do nothing.
378pub fn context_menu<'a, Id, Message>(
379    state: &'a State<Id>,
380    content: impl Into<Element<'a, Message>>,
381) -> ContextMenu<'a, Id, Message> {
382    keyed(state, (), content)
383}
384
385/// Makes `content` one of many areas sharing the menu of `state`, such as
386/// a row in a list. The menu shows on this area only while it is open on
387/// `key`, and the open events it sends carry `key`.
388pub fn keyed<'a, Id, Key, Message>(
389    state: &'a State<Id, Key>,
390    key: Key,
391    content: impl Into<Element<'a, Message>>,
392) -> ContextMenu<'a, Id, Message, Key> {
393    ContextMenu {
394        state,
395        key,
396        content: content.into(),
397        width: WIDTH,
398        keymap: default_keymap(),
399        on_event: None,
400    }
401}
402
403impl<'a, Id, Message, Key> ContextMenu<'a, Id, Message, Key> {
404    /// Width of the menu and its submenus.
405    pub fn width(mut self, width: f32) -> Self {
406        self.width = width;
407        self
408    }
409
410    /// Replaces the [`default_keymap`] the open menu resolves its keys with.
411    pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
412        self.keymap = keymap;
413        self
414    }
415
416    pub fn on_event(mut self, on_event: impl Fn(Event<Id, Key>) -> Message + 'a) -> Self {
417        self.on_event = Some(Box::new(on_event));
418        self
419    }
420}
421
422/// Where the panel goes: at the right-click point, or just below the area,
423/// aligned with its start, when the menu opened from the keyboard.
424pub fn placement(position: Option<Point>) -> Placement {
425    let gap = if position.is_some() {
426        0.0
427    } else {
428        KEYBOARD_GAP
429    };
430    Placement::new(Side::Bottom, Align::Start).gap(gap)
431}
432
433impl<'a, Id, Message, Key> From<ContextMenu<'a, Id, Message, Key>> for Element<'a, Message>
434where
435    Id: Copy + PartialEq + 'a,
436    Key: Clone + PartialEq + 'a,
437    Message: Clone + 'a,
438{
439    fn from(menu: ContextMenu<'a, Id, Message, Key>) -> Self {
440        let ContextMenu {
441            state,
442            key,
443            content,
444            width,
445            keymap,
446            on_event,
447        } = menu;
448
449        let open = state.is_open_on(&key);
450        let on_event: Option<Rc<OnEvent<'a, Id, Key, Message>>> = on_event.map(Rc::from);
451
452        let panel = match &on_event {
453            _ if !open => None,
454            Some(on_event) => {
455                let on_menu = |event: dropdown_menu::Event<Id>| on_event(Event::Menu(event));
456                Some(dropdown_menu::panel(state.menu(), Some(&on_menu), width))
457            }
458            None => Some(dropdown_menu::panel(state.menu(), None, width)),
459        };
460
461        let area = Area {
462            content,
463            on_open: on_event.clone().map(|on_event| {
464                let key = key.clone();
465                Box::new(move |point| on_event(Event::Open(key.clone(), point)))
466                    as Box<dyn Fn(Point) -> Message + 'a>
467            }),
468        };
469        let mut anchored = anchored(Element::new(area))
470            .content(panel)
471            .placement(placement(state.position()))
472            .fit_height(true)
473            .dismiss_on_anchor_press(true)
474            .dismiss_keys([]);
475        if let Some(position) = state.position() {
476            anchored = anchored.at(position);
477        }
478        let Some(on_event) = on_event.filter(|_| open) else {
479            return anchored.into();
480        };
481        anchored
482            .on_dismiss(on_event(Event::Menu(dropdown_menu::Event::Close)))
483            .on_key(move |press| state.key_event(&keymap, press, key.clone()).map(&*on_event))
484            .into()
485    }
486}
487
488/// Wraps content and reports right-clicks and long presses on it with their
489/// position.
490struct Area<'a, Message> {
491    content: Element<'a, Message>,
492    on_open: Option<Box<dyn Fn(Point) -> Message + 'a>>,
493}
494
495/// Where the cursor or finger is, relative to the area's top left corner.
496fn relative(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> {
497    let position = cursor.position()?;
498    Some(Point::new(position.x - bounds.x, position.y - bounds.y))
499}
500
501impl<Message> Area<'_, Message> {
502    /// Times a finger held on the area and opens the menu once it has been
503    /// held long enough. The cursor follows the finger, and unlike the
504    /// event's position it is already translated inside scroll areas.
505    fn long_press(
506        &self,
507        press: &mut LongPress,
508        event: &IcedEvent,
509        bounds: Rectangle,
510        cursor: mouse::Cursor,
511        shell: &mut Shell<'_, Message>,
512    ) {
513        let Some(on_open) = &self.on_open else {
514            return;
515        };
516        match event {
517            IcedEvent::Touch(touch::Event::FingerPressed { id, .. }) => {
518                if let Some(position) = cursor.position_in(bounds) {
519                    press.press(*id, position);
520                }
521            }
522            IcedEvent::Touch(touch::Event::FingerMoved { id, .. }) => {
523                if let Some(position) = relative(cursor, bounds) {
524                    press.moved(*id, position);
525                }
526            }
527            IcedEvent::Window(window::Event::RedrawRequested(now)) => {
528                if let Some(position) = press.tick(*now) {
529                    shell.publish(on_open(position));
530                }
531            }
532            _ => {}
533        }
534        if let Some(request) = press.redraw() {
535            shell.request_redraw_at(request);
536        }
537    }
538}
539
540impl<Message> Widget<Message, Theme, Renderer> for Area<'_, Message> {
541    fn size(&self) -> Size<Length> {
542        self.content.as_widget().size()
543    }
544
545    fn size_hint(&self) -> Size<Length> {
546        self.content.as_widget().size_hint()
547    }
548
549    fn tag(&self) -> tree::Tag {
550        tree::Tag::of::<LongPress>()
551    }
552
553    fn state(&self) -> tree::State {
554        tree::State::new(LongPress::default())
555    }
556
557    fn children(&self) -> Vec<Tree> {
558        vec![Tree::new(&self.content)]
559    }
560
561    fn diff(&self, tree: &mut Tree) {
562        tree.diff_children(std::slice::from_ref(&self.content));
563    }
564
565    fn layout(
566        &mut self,
567        tree: &mut Tree,
568        renderer: &Renderer,
569        limits: &layout::Limits,
570    ) -> layout::Node {
571        self.content
572            .as_widget_mut()
573            .layout(&mut tree.children[0], renderer, limits)
574    }
575
576    fn draw(
577        &self,
578        tree: &Tree,
579        renderer: &mut Renderer,
580        theme: &Theme,
581        style: &renderer::Style,
582        layout: Layout<'_>,
583        cursor: mouse::Cursor,
584        viewport: &Rectangle,
585    ) {
586        self.content.as_widget().draw(
587            &tree.children[0],
588            renderer,
589            theme,
590            style,
591            layout,
592            cursor,
593            viewport,
594        );
595    }
596
597    fn operate(
598        &mut self,
599        tree: &mut Tree,
600        layout: Layout<'_>,
601        renderer: &Renderer,
602        operation: &mut dyn Operation,
603    ) {
604        self.content
605            .as_widget_mut()
606            .operate(&mut tree.children[0], layout, renderer, operation);
607    }
608
609    fn update(
610        &mut self,
611        tree: &mut Tree,
612        event: &IcedEvent,
613        layout: Layout<'_>,
614        cursor: mouse::Cursor,
615        renderer: &Renderer,
616        clipboard: &mut dyn Clipboard,
617        shell: &mut Shell<'_, Message>,
618        viewport: &Rectangle,
619    ) {
620        let press = tree.state.downcast_mut::<LongPress>();
621        // The finger that opened the menu lifts over it, not as a tap.
622        if let IcedEvent::Touch(
623            touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
624        ) = event
625            && press.lift(*id)
626        {
627            shell.capture_event();
628            return;
629        }
630        self.long_press(press, event, layout.bounds(), cursor, shell);
631
632        self.content.as_widget_mut().update(
633            &mut tree.children[0],
634            event,
635            layout,
636            cursor,
637            renderer,
638            clipboard,
639            shell,
640            viewport,
641        );
642        if shell.is_event_captured() {
643            return;
644        }
645        let IcedEvent::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) = event else {
646            return;
647        };
648        let (Some(on_open), Some(position)) = (&self.on_open, cursor.position_in(layout.bounds()))
649        else {
650            return;
651        };
652        shell.publish(on_open(position));
653        shell.capture_event();
654    }
655
656    fn mouse_interaction(
657        &self,
658        tree: &Tree,
659        layout: Layout<'_>,
660        cursor: mouse::Cursor,
661        viewport: &Rectangle,
662        renderer: &Renderer,
663    ) -> mouse::Interaction {
664        self.content.as_widget().mouse_interaction(
665            &tree.children[0],
666            layout,
667            cursor,
668            viewport,
669            renderer,
670        )
671    }
672
673    fn overlay<'b>(
674        &'b mut self,
675        tree: &'b mut Tree,
676        layout: Layout<'b>,
677        renderer: &Renderer,
678        viewport: &Rectangle,
679        translation: Vector,
680    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
681        self.content.as_widget_mut().overlay(
682            &mut tree.children[0],
683            layout,
684            renderer,
685            viewport,
686            translation,
687        )
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use crate::overlay::dropdown_menu::{checkbox_item, item, separator, submenu};
695    use iced::keyboard::{Key, Modifiers};
696    use iced::widget::text;
697
698    fn state() -> State<u8> {
699        State::new([
700            item(1, "Back"),
701            item(2, "Forward").disabled(true),
702            item(3, "Reload"),
703            submenu(4, "More tools", [item(5, "Save page")]),
704            separator(),
705            checkbox_item(6, "Show bookmarks", true),
706        ])
707    }
708
709    fn rows() -> State<u8, &'static str> {
710        State::new([item(1, "Open"), item(3, "Rename"), item(6, "Delete")])
711    }
712
713    #[test]
714    fn starts_closed_with_no_target() {
715        let state = state();
716        assert!(!state.is_open());
717        assert_eq!(state.position(), None);
718        assert_eq!(state.target(), None);
719        assert!(!state.is_open_on(&()));
720        assert_eq!(state.menu().entries().len(), 6);
721    }
722
723    #[test]
724    fn right_click_opens_at_the_point_without_a_highlight() {
725        let mut state = state();
726        assert_eq!(state.update(Event::Open((), Point::new(40.0, 30.0))), None);
727        assert!(state.is_open());
728        assert!(state.is_open_on(&()));
729        assert_eq!(state.position(), Some(Point::new(40.0, 30.0)));
730        assert_eq!(state.menu().highlighted(), None);
731    }
732
733    #[test]
734    fn a_second_right_click_moves_the_menu_and_resets_it() {
735        let mut state = state();
736        let _ = state.update(Event::Open((), Point::new(40.0, 30.0)));
737        let _ = state.update(Event::Menu(dropdown_menu::Event::Highlight(4)));
738        assert_eq!(state.menu().depth(), 2);
739        let _ = state.update(Event::Open((), Point::new(5.0, 6.0)));
740        assert_eq!(state.position(), Some(Point::new(5.0, 6.0)));
741        assert_eq!(state.menu().depth(), 1);
742        assert_eq!(state.menu().highlighted(), None);
743    }
744
745    #[test]
746    fn keyboard_opens_below_the_area_on_the_first_item() {
747        let mut state = state();
748        let _ = state.update(Event::Open((), Point::new(40.0, 30.0)));
749        let _ = state.update(Event::OpenFromKeyboard(()));
750        assert_eq!(state.position(), None);
751        assert_eq!(state.menu().highlighted(), Some(1));
752    }
753
754    #[test]
755    fn keyboard_placement_sits_below_the_area_and_pointer_placement_at_the_point() {
756        let below = placement(None);
757        assert_eq!(
758            below,
759            Placement::new(Side::Bottom, Align::Start).gap(KEYBOARD_GAP)
760        );
761        let at_pointer = placement(Some(Point::new(3.0, 4.0)));
762        assert_eq!(
763            at_pointer,
764            Placement::new(Side::Bottom, Align::Start).gap(0.0)
765        );
766    }
767
768    #[test]
769    fn one_state_serves_many_rows() {
770        let mut state = rows();
771        let _ = state.update(Event::Open("a.txt", Point::new(10.0, 8.0)));
772        assert!(state.is_open_on(&"a.txt"));
773        assert!(!state.is_open_on(&"b.txt"));
774
775        let _ = state.update(Event::OpenFromKeyboard("b.txt"));
776        assert!(state.is_open_on(&"b.txt"));
777        assert!(!state.is_open_on(&"a.txt"));
778        assert_eq!(state.target(), Some(&"b.txt"));
779    }
780
781    #[test]
782    fn the_target_outlives_the_menu_so_a_choice_knows_its_row() {
783        let mut state = rows();
784        let _ = state.update(Event::Open("b.txt", Point::ORIGIN));
785        assert_eq!(
786            state.update(Event::Menu(dropdown_menu::Event::Activate(6))),
787            Some(Output::Activated(6))
788        );
789        assert!(!state.is_open());
790        assert!(!state.is_open_on(&"b.txt"));
791        assert_eq!(state.target(), Some(&"b.txt"));
792    }
793
794    #[test]
795    fn the_opening_chord_opens_on_the_row_it_is_given() {
796        let state = rows();
797        let open = keys::Event {
798            key: Key::Named(Named::F10),
799            modifiers: Modifiers::SHIFT,
800        };
801        assert_eq!(
802            state.key_event(&default_keymap(), &open, "c.txt"),
803            Some(Event::OpenFromKeyboard("c.txt"))
804        );
805    }
806
807    #[test]
808    fn menu_events_are_ignored_while_closed() {
809        let mut state = state();
810        assert_eq!(state.update(Event::Menu(dropdown_menu::Event::Next)), None);
811        assert!(!state.is_open());
812        assert_eq!(
813            state.update(Event::Menu(dropdown_menu::Event::Activate(1))),
814            None
815        );
816    }
817
818    #[test]
819    fn choosing_items_returns_outputs_and_closes() {
820        let mut state = state();
821        let _ = state.update(Event::Open((), Point::ORIGIN));
822        assert_eq!(
823            state.update(Event::Menu(dropdown_menu::Event::Activate(6))),
824            Some(Output::Toggled(6, false))
825        );
826        assert!(!state.is_open());
827        assert!(!state.is_checked(6));
828        state.set_checked(6, true);
829        assert!(state.is_checked(6));
830        state.set_disabled(3, true);
831        assert!(state.menu().item(3).is_some_and(|item| item.disabled));
832    }
833
834    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
835        let chord: Chord = chord.parse().unwrap();
836        keymap.resolve(chord.key(), chord.modifiers())
837    }
838
839    #[test]
840    fn default_keymap_adds_open_to_the_menu_keys() {
841        let keymap = default_keymap();
842        assert_eq!(press(&keymap, "Shift+F10"), Some(Action::Open));
843        assert_eq!(press(&keymap, "Menu"), Some(Action::Open));
844        assert_eq!(press(&keymap, "F10"), None);
845        assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
846        assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
847        assert_eq!(press(&keymap, "Home"), Some(Action::First));
848        assert_eq!(press(&keymap, "End"), Some(Action::Last));
849        assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
850        assert_eq!(press(&keymap, "Space"), Some(Action::Activate));
851        assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
852        assert_eq!(press(&keymap, "ArrowRight"), Some(Action::OpenSubmenu));
853        assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::CloseSubmenu));
854        for action in <Action as keys::Action>::ALL {
855            assert!(!keymap.chords(action).is_empty(), "{action:?}");
856        }
857        assert_eq!(Keymap::<Action>::defaults(), keymap);
858    }
859
860    #[test]
861    fn keymap_overrides_and_unbinding() {
862        let keymap = default_keymap()
863            .unbind(&"Menu".parse().unwrap())
864            .bind("Ctrl+M".parse().unwrap(), Action::Open);
865        assert_eq!(press(&keymap, "Menu"), None);
866        assert_eq!(press(&keymap, "Ctrl+M"), Some(Action::Open));
867        assert_eq!(press(&keymap, "Shift+F10"), Some(Action::Open));
868    }
869
870    #[test]
871    fn open_works_only_while_closed_and_the_rest_only_while_open() {
872        let mut state = state();
873        assert_eq!(
874            Action::Open.event(&state, ()),
875            Some(Event::OpenFromKeyboard(()))
876        );
877        assert_eq!(Action::Next.event(&state, ()), None);
878        assert_eq!(Action::Activate.event(&state, ()), None);
879
880        let _ = state.update(Event::OpenFromKeyboard(()));
881        assert_eq!(Action::Open.event(&state, ()), None);
882        assert_eq!(
883            Action::Next.event(&state, ()),
884            Some(Event::Menu(dropdown_menu::Event::Next))
885        );
886        assert_eq!(
887            Action::Close.event(&state, ()),
888            Some(Event::Menu(dropdown_menu::Event::Close))
889        );
890    }
891
892    #[test]
893    fn every_action_round_trips_through_the_menu_action() {
894        for &action in <Action as keys::Action>::ALL {
895            match action.menu_action() {
896                Some(menu) => assert_eq!(Action::from(menu), action),
897                None => assert_eq!(action, Action::Open),
898            }
899            assert!(keys::Action::description(action).ends_with('.'));
900            assert!(!keys::Action::name(action).is_empty());
901        }
902    }
903
904    #[test]
905    fn key_event_uses_the_keymap_then_typeahead() {
906        let keymap = default_keymap();
907        let mut state = state();
908        let letter = keys::Event {
909            key: Key::Character("r".into()),
910            modifiers: Modifiers::empty(),
911        };
912        assert_eq!(state.key_event(&keymap, &letter, ()), None);
913        let _ = state.update(Event::OpenFromKeyboard(()));
914        assert_eq!(
915            state.key_event(&keymap, &letter, ()),
916            Some(Event::Menu(dropdown_menu::Event::Typeahead('r')))
917        );
918        let menu_key = keys::Event {
919            key: Key::Named(Named::ContextMenu),
920            modifiers: Modifiers::empty(),
921        };
922        assert_eq!(state.key_event(&keymap, &menu_key, ()), None);
923    }
924
925    const FINGER: Finger = Finger(7);
926
927    #[test]
928    fn a_long_press_fires_once_after_the_delay_at_its_origin() {
929        let start = Instant::now();
930        let mut press = LongPress::default();
931        assert_eq!(press.redraw(), None);
932        assert_eq!(press.tick(start), None);
933
934        press.press(FINGER, Point::new(12.0, 8.0));
935        assert_eq!(press.redraw(), Some(window::RedrawRequest::NextFrame));
936        assert_eq!(press.tick(start), None);
937        assert_eq!(
938            press.redraw(),
939            Some(window::RedrawRequest::At(start + LONG_PRESS))
940        );
941        assert_eq!(press.tick(start + Duration::from_millis(499)), None);
942        assert_eq!(press.tick(start + LONG_PRESS), Some(Point::new(12.0, 8.0)));
943        assert_eq!(press.tick(start + LONG_PRESS * 2), None);
944        assert_eq!(press.redraw(), None);
945        assert!(press.lift(FINGER), "the lift after firing is swallowed");
946        assert!(!press.lift(FINGER));
947    }
948
949    #[test]
950    fn lifting_early_is_a_tap() {
951        let start = Instant::now();
952        let mut press = LongPress::default();
953        press.press(FINGER, Point::ORIGIN);
954        let _ = press.tick(start);
955        assert!(!press.lift(FINGER));
956        assert_eq!(press.tick(start + LONG_PRESS), None);
957    }
958
959    #[test]
960    fn drifting_beyond_the_slop_cancels_but_a_wobble_does_not() {
961        let start = Instant::now();
962        let mut press = LongPress::default();
963        press.press(FINGER, Point::new(50.0, 50.0));
964        let _ = press.tick(start);
965        press.moved(FINGER, Point::new(56.0, 54.0));
966        press.moved(Finger(8), Point::new(200.0, 200.0));
967        assert!(press.tick(start + LONG_PRESS).is_some());
968
969        press.press(FINGER, Point::new(50.0, 50.0));
970        let _ = press.tick(start);
971        press.moved(FINGER, Point::new(50.0, 50.0 + LONG_PRESS_SLOP + 1.0));
972        assert_eq!(press.tick(start + LONG_PRESS), None);
973        assert_eq!(press.redraw(), None);
974    }
975
976    #[test]
977    fn another_finger_lifting_leaves_the_press_alone() {
978        let start = Instant::now();
979        let mut press = LongPress::default();
980        press.press(FINGER, Point::ORIGIN);
981        let _ = press.tick(start);
982        assert!(!press.lift(Finger(8)));
983        assert!(press.tick(start + LONG_PRESS).is_some());
984    }
985
986    #[test]
987    fn builder_defaults() {
988        let state = state();
989        let menu: ContextMenu<'_, u8, ()> = context_menu(&state, text("Area"));
990        assert_eq!(menu.width, WIDTH);
991        assert_eq!(menu.keymap, default_keymap());
992        assert!(menu.on_event.is_none());
993        let menu: ContextMenu<'_, u8, ()> = context_menu(&state, text("Area"))
994            .width(180.0)
995            .keymap(Keymap::new())
996            .on_event(|_| ());
997        assert_eq!(menu.width, 180.0);
998        assert!(menu.keymap.is_empty());
999        assert!(menu.on_event.is_some());
1000
1001        let rows = rows();
1002        let row: ContextMenu<'_, u8, (), &str> = keyed(&rows, "a.txt", text("a.txt"));
1003        assert_eq!(row.key, "a.txt");
1004    }
1005}