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. The
622        // content hears it as lost, so a press it was tracking ends.
623        if let IcedEvent::Touch(
624            touch::Event::FingerLifted { id, position } | touch::Event::FingerLost { id, position },
625        ) = event
626            && press.lift(*id)
627        {
628            let lost = IcedEvent::Touch(touch::Event::FingerLost {
629                id: *id,
630                position: *position,
631            });
632            self.content.as_widget_mut().update(
633                &mut tree.children[0],
634                &lost,
635                layout,
636                cursor,
637                renderer,
638                clipboard,
639                shell,
640                viewport,
641            );
642            shell.capture_event();
643            return;
644        }
645        self.long_press(press, event, layout.bounds(), cursor, shell);
646
647        self.content.as_widget_mut().update(
648            &mut tree.children[0],
649            event,
650            layout,
651            cursor,
652            renderer,
653            clipboard,
654            shell,
655            viewport,
656        );
657        if shell.is_event_captured() {
658            return;
659        }
660        let IcedEvent::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) = event else {
661            return;
662        };
663        let (Some(on_open), Some(position)) = (&self.on_open, cursor.position_in(layout.bounds()))
664        else {
665            return;
666        };
667        shell.publish(on_open(position));
668        shell.capture_event();
669    }
670
671    fn mouse_interaction(
672        &self,
673        tree: &Tree,
674        layout: Layout<'_>,
675        cursor: mouse::Cursor,
676        viewport: &Rectangle,
677        renderer: &Renderer,
678    ) -> mouse::Interaction {
679        self.content.as_widget().mouse_interaction(
680            &tree.children[0],
681            layout,
682            cursor,
683            viewport,
684            renderer,
685        )
686    }
687
688    fn overlay<'b>(
689        &'b mut self,
690        tree: &'b mut Tree,
691        layout: Layout<'b>,
692        renderer: &Renderer,
693        viewport: &Rectangle,
694        translation: Vector,
695    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
696        self.content.as_widget_mut().overlay(
697            &mut tree.children[0],
698            layout,
699            renderer,
700            viewport,
701            translation,
702        )
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use crate::overlay::dropdown_menu::{checkbox_item, item, separator, submenu};
710    use iced::keyboard::{Key, Modifiers};
711    use iced::widget::text;
712
713    fn state() -> State<u8> {
714        State::new([
715            item(1, "Back"),
716            item(2, "Forward").disabled(true),
717            item(3, "Reload"),
718            submenu(4, "More tools", [item(5, "Save page")]),
719            separator(),
720            checkbox_item(6, "Show bookmarks", true),
721        ])
722    }
723
724    fn rows() -> State<u8, &'static str> {
725        State::new([item(1, "Open"), item(3, "Rename"), item(6, "Delete")])
726    }
727
728    #[test]
729    fn starts_closed_with_no_target() {
730        let state = state();
731        assert!(!state.is_open());
732        assert_eq!(state.position(), None);
733        assert_eq!(state.target(), None);
734        assert!(!state.is_open_on(&()));
735        assert_eq!(state.menu().entries().len(), 6);
736    }
737
738    #[test]
739    fn right_click_opens_at_the_point_without_a_highlight() {
740        let mut state = state();
741        assert_eq!(state.update(Event::Open((), Point::new(40.0, 30.0))), None);
742        assert!(state.is_open());
743        assert!(state.is_open_on(&()));
744        assert_eq!(state.position(), Some(Point::new(40.0, 30.0)));
745        assert_eq!(state.menu().highlighted(), None);
746    }
747
748    #[test]
749    fn a_second_right_click_moves_the_menu_and_resets_it() {
750        let mut state = state();
751        let _ = state.update(Event::Open((), Point::new(40.0, 30.0)));
752        let _ = state.update(Event::Menu(dropdown_menu::Event::Highlight(4)));
753        assert_eq!(state.menu().depth(), 2);
754        let _ = state.update(Event::Open((), Point::new(5.0, 6.0)));
755        assert_eq!(state.position(), Some(Point::new(5.0, 6.0)));
756        assert_eq!(state.menu().depth(), 1);
757        assert_eq!(state.menu().highlighted(), None);
758    }
759
760    #[test]
761    fn keyboard_opens_below_the_area_on_the_first_item() {
762        let mut state = state();
763        let _ = state.update(Event::Open((), Point::new(40.0, 30.0)));
764        let _ = state.update(Event::OpenFromKeyboard(()));
765        assert_eq!(state.position(), None);
766        assert_eq!(state.menu().highlighted(), Some(1));
767    }
768
769    #[test]
770    fn keyboard_placement_sits_below_the_area_and_pointer_placement_at_the_point() {
771        let below = placement(None);
772        assert_eq!(
773            below,
774            Placement::new(Side::Bottom, Align::Start).gap(KEYBOARD_GAP)
775        );
776        let at_pointer = placement(Some(Point::new(3.0, 4.0)));
777        assert_eq!(
778            at_pointer,
779            Placement::new(Side::Bottom, Align::Start).gap(0.0)
780        );
781    }
782
783    #[test]
784    fn one_state_serves_many_rows() {
785        let mut state = rows();
786        let _ = state.update(Event::Open("a.txt", Point::new(10.0, 8.0)));
787        assert!(state.is_open_on(&"a.txt"));
788        assert!(!state.is_open_on(&"b.txt"));
789
790        let _ = state.update(Event::OpenFromKeyboard("b.txt"));
791        assert!(state.is_open_on(&"b.txt"));
792        assert!(!state.is_open_on(&"a.txt"));
793        assert_eq!(state.target(), Some(&"b.txt"));
794    }
795
796    #[test]
797    fn the_target_outlives_the_menu_so_a_choice_knows_its_row() {
798        let mut state = rows();
799        let _ = state.update(Event::Open("b.txt", Point::ORIGIN));
800        assert_eq!(
801            state.update(Event::Menu(dropdown_menu::Event::Activate(6))),
802            Some(Output::Activated(6))
803        );
804        assert!(!state.is_open());
805        assert!(!state.is_open_on(&"b.txt"));
806        assert_eq!(state.target(), Some(&"b.txt"));
807    }
808
809    #[test]
810    fn the_opening_chord_opens_on_the_row_it_is_given() {
811        let state = rows();
812        let open = keys::Event {
813            key: Key::Named(Named::F10),
814            modifiers: Modifiers::SHIFT,
815        };
816        assert_eq!(
817            state.key_event(&default_keymap(), &open, "c.txt"),
818            Some(Event::OpenFromKeyboard("c.txt"))
819        );
820    }
821
822    #[test]
823    fn menu_events_are_ignored_while_closed() {
824        let mut state = state();
825        assert_eq!(state.update(Event::Menu(dropdown_menu::Event::Next)), None);
826        assert!(!state.is_open());
827        assert_eq!(
828            state.update(Event::Menu(dropdown_menu::Event::Activate(1))),
829            None
830        );
831    }
832
833    #[test]
834    fn choosing_items_returns_outputs_and_closes() {
835        let mut state = state();
836        let _ = state.update(Event::Open((), Point::ORIGIN));
837        assert_eq!(
838            state.update(Event::Menu(dropdown_menu::Event::Activate(6))),
839            Some(Output::Toggled(6, false))
840        );
841        assert!(!state.is_open());
842        assert!(!state.is_checked(6));
843        state.set_checked(6, true);
844        assert!(state.is_checked(6));
845        state.set_disabled(3, true);
846        assert!(state.menu().item(3).is_some_and(|item| item.disabled));
847    }
848
849    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
850        let chord: Chord = chord.parse().unwrap();
851        keymap.resolve(chord.key(), chord.modifiers())
852    }
853
854    #[test]
855    fn default_keymap_adds_open_to_the_menu_keys() {
856        let keymap = default_keymap();
857        assert_eq!(press(&keymap, "Shift+F10"), Some(Action::Open));
858        assert_eq!(press(&keymap, "Menu"), Some(Action::Open));
859        assert_eq!(press(&keymap, "F10"), None);
860        assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
861        assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
862        assert_eq!(press(&keymap, "Home"), Some(Action::First));
863        assert_eq!(press(&keymap, "End"), Some(Action::Last));
864        assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
865        assert_eq!(press(&keymap, "Space"), Some(Action::Activate));
866        assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
867        assert_eq!(press(&keymap, "ArrowRight"), Some(Action::OpenSubmenu));
868        assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::CloseSubmenu));
869        for action in <Action as keys::Action>::ALL {
870            assert!(!keymap.chords(action).is_empty(), "{action:?}");
871        }
872        assert_eq!(Keymap::<Action>::defaults(), keymap);
873    }
874
875    #[test]
876    fn keymap_overrides_and_unbinding() {
877        let keymap = default_keymap()
878            .unbind(&"Menu".parse().unwrap())
879            .bind("Ctrl+M".parse().unwrap(), Action::Open);
880        assert_eq!(press(&keymap, "Menu"), None);
881        assert_eq!(press(&keymap, "Ctrl+M"), Some(Action::Open));
882        assert_eq!(press(&keymap, "Shift+F10"), Some(Action::Open));
883    }
884
885    #[test]
886    fn open_works_only_while_closed_and_the_rest_only_while_open() {
887        let mut state = state();
888        assert_eq!(
889            Action::Open.event(&state, ()),
890            Some(Event::OpenFromKeyboard(()))
891        );
892        assert_eq!(Action::Next.event(&state, ()), None);
893        assert_eq!(Action::Activate.event(&state, ()), None);
894
895        let _ = state.update(Event::OpenFromKeyboard(()));
896        assert_eq!(Action::Open.event(&state, ()), None);
897        assert_eq!(
898            Action::Next.event(&state, ()),
899            Some(Event::Menu(dropdown_menu::Event::Next))
900        );
901        assert_eq!(
902            Action::Close.event(&state, ()),
903            Some(Event::Menu(dropdown_menu::Event::Close))
904        );
905    }
906
907    #[test]
908    fn every_action_round_trips_through_the_menu_action() {
909        for &action in <Action as keys::Action>::ALL {
910            match action.menu_action() {
911                Some(menu) => assert_eq!(Action::from(menu), action),
912                None => assert_eq!(action, Action::Open),
913            }
914            assert!(keys::Action::description(action).ends_with('.'));
915            assert!(!keys::Action::name(action).is_empty());
916        }
917    }
918
919    #[test]
920    fn key_event_uses_the_keymap_then_typeahead() {
921        let keymap = default_keymap();
922        let mut state = state();
923        let letter = keys::Event {
924            key: Key::Character("r".into()),
925            modifiers: Modifiers::empty(),
926        };
927        assert_eq!(state.key_event(&keymap, &letter, ()), None);
928        let _ = state.update(Event::OpenFromKeyboard(()));
929        assert_eq!(
930            state.key_event(&keymap, &letter, ()),
931            Some(Event::Menu(dropdown_menu::Event::Typeahead('r')))
932        );
933        let menu_key = keys::Event {
934            key: Key::Named(Named::ContextMenu),
935            modifiers: Modifiers::empty(),
936        };
937        assert_eq!(state.key_event(&keymap, &menu_key, ()), None);
938    }
939
940    const FINGER: Finger = Finger(7);
941
942    #[test]
943    fn a_long_press_fires_once_after_the_delay_at_its_origin() {
944        let start = Instant::now();
945        let mut press = LongPress::default();
946        assert_eq!(press.redraw(), None);
947        assert_eq!(press.tick(start), None);
948
949        press.press(FINGER, Point::new(12.0, 8.0));
950        assert_eq!(press.redraw(), Some(window::RedrawRequest::NextFrame));
951        assert_eq!(press.tick(start), None);
952        assert_eq!(
953            press.redraw(),
954            Some(window::RedrawRequest::At(start + LONG_PRESS))
955        );
956        assert_eq!(press.tick(start + Duration::from_millis(499)), None);
957        assert_eq!(press.tick(start + LONG_PRESS), Some(Point::new(12.0, 8.0)));
958        assert_eq!(press.tick(start + LONG_PRESS * 2), None);
959        assert_eq!(press.redraw(), None);
960        assert!(press.lift(FINGER), "the lift after firing is swallowed");
961        assert!(!press.lift(FINGER));
962    }
963
964    #[test]
965    fn lifting_early_is_a_tap() {
966        let start = Instant::now();
967        let mut press = LongPress::default();
968        press.press(FINGER, Point::ORIGIN);
969        let _ = press.tick(start);
970        assert!(!press.lift(FINGER));
971        assert_eq!(press.tick(start + LONG_PRESS), None);
972    }
973
974    #[test]
975    fn drifting_beyond_the_slop_cancels_but_a_wobble_does_not() {
976        let start = Instant::now();
977        let mut press = LongPress::default();
978        press.press(FINGER, Point::new(50.0, 50.0));
979        let _ = press.tick(start);
980        press.moved(FINGER, Point::new(56.0, 54.0));
981        press.moved(Finger(8), Point::new(200.0, 200.0));
982        assert!(press.tick(start + LONG_PRESS).is_some());
983
984        press.press(FINGER, Point::new(50.0, 50.0));
985        let _ = press.tick(start);
986        press.moved(FINGER, Point::new(50.0, 50.0 + LONG_PRESS_SLOP + 1.0));
987        assert_eq!(press.tick(start + LONG_PRESS), None);
988        assert_eq!(press.redraw(), None);
989    }
990
991    #[test]
992    fn another_finger_lifting_leaves_the_press_alone() {
993        let start = Instant::now();
994        let mut press = LongPress::default();
995        press.press(FINGER, Point::ORIGIN);
996        let _ = press.tick(start);
997        assert!(!press.lift(Finger(8)));
998        assert!(press.tick(start + LONG_PRESS).is_some());
999    }
1000
1001    #[test]
1002    fn builder_defaults() {
1003        let state = state();
1004        let menu: ContextMenu<'_, u8, ()> = context_menu(&state, text("Area"));
1005        assert_eq!(menu.width, WIDTH);
1006        assert_eq!(menu.keymap, default_keymap());
1007        assert!(menu.on_event.is_none());
1008        let menu: ContextMenu<'_, u8, ()> = context_menu(&state, text("Area"))
1009            .width(180.0)
1010            .keymap(Keymap::new())
1011            .on_event(|_| ());
1012        assert_eq!(menu.width, 180.0);
1013        assert!(menu.keymap.is_empty());
1014        assert!(menu.on_event.is_some());
1015
1016        let rows = rows();
1017        let row: ContextMenu<'_, u8, (), &str> = keyed(&rows, "a.txt", text("a.txt"));
1018        assert_eq!(row.key, "a.txt");
1019    }
1020}