Skip to main content

cosmic/widget/
context_menu.rs

1// Copyright 2024 System76 <info@system76.com>
2// SPDX-License-Identifier: MPL-2.0
3
4//! A context menu is a menu in a graphical user interface that appears upon user interaction, such as a right-click mouse operation.
5
6#[cfg(wayland_platform)]
7use crate::app::cosmic::{WINDOWING_SYSTEM, WindowingSystem};
8use crate::widget::menu::{
9    self, CloseCondition, Direction, ItemHeight, ItemWidth, MenuBarState, PathHighlight,
10    init_root_menu, menu_roots_diff,
11};
12use derive_setters::Setters;
13use iced::touch::Finger;
14use iced::{Event, Vector, keyboard, window};
15use iced_core::widget::{Tree, Widget, tree};
16use iced_core::{Length, Point, Size, mouse, touch};
17use std::collections::HashSet;
18use std::sync::Arc;
19
20/// A context menu is a menu in a graphical user interface that appears upon user interaction, such as a right-click mouse operation.
21pub fn context_menu<'a, Message: 'static + Clone>(
22    content: impl Into<crate::Element<'a, Message>>,
23    // on_context: Message,
24    context_menu: Option<Vec<menu::Tree<Message>>>,
25) -> ContextMenu<'a, Message> {
26    let mut this = ContextMenu {
27        content: content.into(),
28        context_menu: context_menu.map(|menus| {
29            vec![menu::Tree::with_children(
30                crate::Element::from(crate::widget::Row::new()),
31                menus,
32            )]
33        }),
34        close_on_escape: true,
35        window_id: window::Id::RESERVED,
36        item_width: ItemWidth::Uniform(240),
37        on_open: None,
38        on_close: None,
39        on_surface_action: None,
40    };
41
42    if let Some(ref mut context_menu) = this.context_menu {
43        context_menu.iter_mut().for_each(menu::Tree::set_index);
44    }
45
46    this
47}
48
49/// A context menu is a menu in a graphical user interface that appears upon user interaction, such as a right-click mouse operation.
50#[derive(Setters)]
51#[must_use]
52pub struct ContextMenu<'a, Message> {
53    #[setters(skip)]
54    content: crate::Element<'a, Message>,
55    #[setters(skip)]
56    context_menu: Option<Vec<menu::Tree<Message>>>,
57    pub window_id: window::Id,
58    pub close_on_escape: bool,
59    /// Width of each menu item, and therefore of the menu.
60    pub item_width: ItemWidth,
61    /// Emitted when the menu opens, so the application can mark what was right-clicked.
62    #[setters(strip_option)]
63    pub on_open: Option<Message>,
64    /// Emitted when the menu closes by any path, including the compositor dismissing it.
65    #[setters(strip_option)]
66    pub on_close: Option<Message>,
67    #[setters(skip)]
68    pub(crate) on_surface_action:
69        Option<Arc<dyn Fn(crate::surface::Action<Message>) -> Message + Send + Sync + 'static>>,
70}
71
72impl<Message: Clone + 'static> ContextMenu<'_, Message> {
73    /// Publish `on_open`/`on_close` when the open state changed since the last report.
74    fn report_open_state(&self, state: &mut LocalState, shell: &mut iced_core::Shell<'_, Message>) {
75        let open = state.menu_bar_state.inner.with_data(|d| d.open);
76        if open == state.reported_open {
77            return;
78        }
79        state.reported_open = open;
80        let message = if open { &self.on_open } else { &self.on_close };
81        if let Some(message) = message.clone() {
82            shell.publish(message);
83        }
84    }
85
86    #[cfg(wayland_platform)]
87    #[allow(clippy::too_many_lines)]
88    fn create_popup(
89        &mut self,
90        layout: iced_core::Layout<'_>,
91        view_cursor: iced_core::mouse::Cursor,
92        renderer: &crate::Renderer,
93        shell: &mut iced_core::Shell<'_, Message>,
94        viewport: &iced::Rectangle,
95        my_state: &mut LocalState,
96    ) {
97        if self.window_id != window::Id::NONE && self.on_surface_action.is_some() {
98            use crate::surface::action::{LiveSettings, destroy_popup};
99            use crate::theme::THEME;
100            use crate::widget::menu::{Menu, StyleSheet as _};
101            use iced_runtime::platform_specific::wayland::CornerRadius;
102            use iced_runtime::platform_specific::wayland::popup::{
103                SctkPopupSettings, SctkPositioner,
104            };
105
106            let mut bounds = layout.bounds();
107            bounds.x = my_state.context_cursor.x;
108            bounds.y = my_state.context_cursor.y;
109
110            let (id, root_list) = my_state.menu_bar_state.inner.with_data_mut(|state| {
111                if let Some(id) = state.popup_id.get(&self.window_id).copied() {
112                    // close existing popups
113                    state.menu_states.clear();
114                    state.active_root.clear();
115
116                    shell.publish(self.on_surface_action.as_ref().unwrap()(destroy_popup(id)));
117                    state.view_cursor = view_cursor;
118                }
119                // A fresh id per popup, so the old popup's Done cannot be mistaken for the new one's
120                (
121                    window::Id::unique(),
122                    layout.children().map(|lo| lo.bounds()).collect::<Vec<_>>(),
123                )
124            });
125            let Some(context_menu) = self.context_menu.as_mut() else {
126                return;
127            };
128
129            let mut popup_menu: Menu<'static, _> = Menu {
130                tree: my_state.menu_bar_state.clone(),
131                menu_roots: std::borrow::Cow::Owned(context_menu.clone()),
132                bounds_expand: 16,
133                menu_overlays_parent: true,
134                close_condition: CloseCondition {
135                    leave: false,
136                    click_outside: true,
137                    click_inside: true,
138                },
139                item_width: self.item_width,
140                item_height: ItemHeight::Dynamic(40),
141                bar_bounds: bounds,
142                main_offset: -(bounds.height as i32),
143                cross_offset: 0,
144                root_bounds_list: vec![bounds],
145                path_highlight: Some(PathHighlight::MenuActive),
146                style: std::borrow::Cow::Owned(crate::theme::menu_bar::MenuBarStyle::Default),
147                position: Point::new(0., 0.),
148                is_overlay: false,
149                window_id: id,
150                depth: 0,
151                on_surface_action: self.on_surface_action.clone(),
152            };
153
154            init_root_menu(
155                &mut popup_menu,
156                renderer,
157                shell,
158                view_cursor.position().unwrap(),
159                viewport.size(),
160                Vector::new(0., 0.),
161                layout.bounds(),
162                -bounds.height,
163            );
164            let (anchor_rect, gravity) = my_state.menu_bar_state.inner.with_data_mut(|state| {
165                use iced::Rectangle;
166
167                state.popup_id.insert(self.window_id, id);
168                ({
169                    let pos = view_cursor.position().unwrap_or_default();
170                    Rectangle {
171                        x: pos.x as i32,
172                        y: pos.y as i32,
173                        width: 1,
174                        height: 1,
175                    }
176                },
177                match (state.horizontal_direction, state.vertical_direction) {
178                    (Direction::Positive, Direction::Positive) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight,
179                    (Direction::Positive, Direction::Negative) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::TopRight,
180                    (Direction::Negative, Direction::Positive) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomLeft,
181                    (Direction::Negative, Direction::Negative) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::TopLeft,
182                })
183            });
184
185            let menu_node =
186                popup_menu.layout(renderer, iced::Limits::NONE.min_width(1.).min_height(1.));
187            let popup_size = menu_node.size();
188            let positioner = SctkPositioner {
189                size: Some((
190                    popup_size.width.ceil() as u32 + 2,
191                    popup_size.height.ceil() as u32 + 2,
192                )),
193                anchor_rect,
194                anchor: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Anchor::None,
195                gravity: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight,
196                reactive: true,
197                ..Default::default()
198            };
199            let parent = self.window_id;
200            let t = THEME.lock().unwrap();
201            let styling = t.appearance(&crate::theme::menu_bar::MenuBarStyle::Default, false);
202            drop(t);
203            let rad = styling.menu_border_radius;
204
205            shell.publish((self.on_surface_action.as_ref().unwrap())(
206                crate::surface::action::simple_popup(
207                    move || LiveSettings {
208                        corners: Some(CornerRadius {
209                            top_left: rad[0] as u32,
210                            top_right: rad[1] as u32,
211                            bottom_left: rad[2] as u32,
212                            bottom_right: rad[3] as u32,
213                        }),
214                        ..Default::default()
215                    },
216                    move || SctkPopupSettings {
217                        parent,
218                        id,
219                        positioner: positioner.clone(),
220                        parent_size: None,
221                        grab: true,
222                        close_with_children: false,
223                        input_zone: None,
224                    },
225                    Some(move || {
226                        crate::Element::from(
227                            crate::widget::container(popup_menu.clone()).center(Length::Fill),
228                        )
229                        .map(crate::action::app)
230                    }),
231                ),
232            ));
233        }
234    }
235
236    pub fn on_surface_action(
237        mut self,
238        handler: impl Fn(crate::surface::Action<Message>) -> Message + Send + Sync + 'static,
239    ) -> Self {
240        self.on_surface_action = Some(Arc::new(handler));
241        self
242    }
243}
244
245impl<Message: 'static + Clone> Widget<Message, crate::Theme, crate::Renderer>
246    for ContextMenu<'_, Message>
247{
248    fn tag(&self) -> tree::Tag {
249        tree::Tag::of::<LocalState>()
250    }
251
252    fn state(&self) -> tree::State {
253        #[allow(clippy::default_trait_access)]
254        tree::State::new(LocalState {
255            context_cursor: Point::default(),
256            fingers_pressed: Default::default(),
257            menu_bar_state: Default::default(),
258            reported_open: false,
259        })
260    }
261
262    fn children(&self) -> Vec<Tree> {
263        let mut children = Vec::with_capacity(if self.context_menu.is_some() { 2 } else { 1 });
264
265        children.push(Tree::new(self.content.as_widget()));
266
267        // Assign the context menu's elements as this widget's children.
268        if let Some(ref context_menu) = self.context_menu {
269            let mut tree = Tree::empty();
270            tree.children = context_menu
271                .iter()
272                .map(|root| {
273                    let mut tree = Tree::empty();
274                    let flat = root
275                        .flattern()
276                        .iter()
277                        .map(|mt| Tree::new(mt.item.clone()))
278                        .collect();
279                    tree.children = flat;
280                    tree
281                })
282                .collect();
283
284            children.push(tree);
285        }
286
287        children
288    }
289
290    fn diff(&mut self, tree: &mut Tree) {
291        tree.diff_children(std::slice::from_mut(&mut self.content));
292        let state = tree.state.downcast_mut::<LocalState>();
293        if let Some(context_menu) = self.context_menu.as_mut() {
294            state.menu_bar_state.inner.with_data_mut(|inner| {
295                menu_roots_diff(context_menu, &mut inner.tree);
296            });
297        }
298
299        // if let Some(ref mut context_menus) = self.context_menu {
300        //     for (menu, tree) in context_menus
301        //         .iter_mut()
302        //         .zip(tree.children[1].children.iter_mut())
303        //     {
304        //         menu.item.as_widget_mut().diff(tree);
305        //     }
306        // }
307    }
308
309    fn size(&self) -> Size<Length> {
310        self.content.as_widget().size()
311    }
312
313    fn layout(
314        &mut self,
315        tree: &mut Tree,
316        renderer: &crate::Renderer,
317        limits: &iced_core::layout::Limits,
318    ) -> iced_core::layout::Node {
319        self.content
320            .as_widget_mut()
321            .layout(&mut tree.children[0], renderer, limits)
322    }
323
324    fn draw(
325        &self,
326        tree: &Tree,
327        renderer: &mut crate::Renderer,
328        theme: &crate::Theme,
329        style: &iced_core::renderer::Style,
330        layout: iced_core::Layout<'_>,
331        cursor: iced_core::mouse::Cursor,
332        viewport: &iced::Rectangle,
333    ) {
334        self.content.as_widget().draw(
335            &tree.children[0],
336            renderer,
337            theme,
338            style,
339            layout,
340            cursor,
341            viewport,
342        );
343    }
344
345    fn mouse_interaction(
346        &self,
347        tree: &Tree,
348        layout: iced_core::Layout<'_>,
349        cursor: iced_core::mouse::Cursor,
350        viewport: &iced::Rectangle,
351        renderer: &crate::Renderer,
352    ) -> mouse::Interaction {
353        self.content.as_widget().mouse_interaction(
354            &tree.children[0],
355            layout,
356            cursor,
357            viewport,
358            renderer,
359        )
360    }
361
362    fn drag_destinations(
363        &self,
364        tree: &Tree,
365        layout: iced_core::Layout<'_>,
366        renderer: &crate::Renderer,
367        dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles,
368    ) {
369        self.content.as_widget().drag_destinations(
370            &tree.children[0],
371            layout,
372            renderer,
373            dnd_rectangles,
374        );
375    }
376
377    fn operate(
378        &mut self,
379        tree: &mut Tree,
380        layout: iced_core::Layout<'_>,
381        renderer: &crate::Renderer,
382        operation: &mut dyn iced_core::widget::Operation<()>,
383    ) {
384        self.content
385            .as_widget_mut()
386            .operate(&mut tree.children[0], layout, renderer, operation);
387    }
388
389    #[allow(clippy::too_many_lines)]
390    fn update(
391        &mut self,
392        tree: &mut Tree,
393        event: &iced::Event,
394        layout: iced_core::Layout<'_>,
395        cursor: iced_core::mouse::Cursor,
396        renderer: &crate::Renderer,
397        clipboard: &mut dyn iced_core::Clipboard,
398        shell: &mut iced_core::Shell<'_, Message>,
399        viewport: &iced::Rectangle,
400    ) {
401        let state = tree.state.downcast_mut::<LocalState>();
402        let bounds = layout.bounds();
403
404        // The compositor dismissed our popup: nothing else tells this state about it.
405        #[cfg(wayland_platform)]
406        if let iced::Event::PlatformSpecific(iced::event::PlatformSpecific::Wayland(
407            iced::event::wayland::Event::Popup(iced::event::wayland::PopupEvent::Done, _, popup),
408        )) = event
409        {
410            state.menu_bar_state.inner.with_data_mut(|d| {
411                if d.popup_id.get(&self.window_id) == Some(popup) {
412                    d.popup_id.remove(&self.window_id);
413                    d.reset();
414                }
415            });
416        }
417
418        // XXX this should reset the state if there are no other copies of the state, which implies no dropdown menus open.
419        let reset = self.window_id != window::Id::NONE
420            && state
421                .menu_bar_state
422                .inner
423                .with_data(|d| !d.open && !d.active_root.is_empty());
424
425        let open = state.menu_bar_state.inner.with_data_mut(|state| {
426            if reset
427                && let Some(popup_id) = state.popup_id.get(&self.window_id).copied()
428                && let Some(handler) = self.on_surface_action.as_ref()
429            {
430                shell.publish((handler)(crate::surface::Action::DestroyPopup(popup_id)));
431                state.reset();
432            }
433            state.open
434        });
435        let mut was_open = false;
436        if matches!(event,
437            Event::Keyboard(keyboard::Event::KeyPressed {
438                key: keyboard::Key::Named(keyboard::key::Named::Escape),
439                ..
440            })
441            | Event::Mouse(mouse::Event::ButtonPressed(
442                mouse::Button::Right | mouse::Button::Left,
443            ))
444            | Event::Touch(touch::Event::FingerPressed { .. })
445                if open )
446        {
447            state.menu_bar_state.inner.with_data_mut(|state| {
448                was_open = true;
449                state.menu_states.clear();
450                state.active_root.clear();
451                state.open = false;
452
453                #[cfg(wayland_platform)]
454                if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland))
455                    && let Some(id) = state.popup_id.remove(&self.window_id)
456                {
457                    {
458                        let surface_action = self.on_surface_action.as_ref().unwrap();
459                        shell.publish(surface_action(crate::surface::action::destroy_popup(id)));
460                    }
461                    state.view_cursor = cursor;
462                }
463            });
464        }
465
466        if !was_open && cursor.is_over(bounds) {
467            let fingers_pressed = state.fingers_pressed.len();
468
469            match event {
470                Event::Touch(touch::Event::FingerPressed { id, .. }) => {
471                    state.fingers_pressed.insert(*id);
472                }
473
474                Event::Touch(touch::Event::FingerLifted { id, .. }) => {
475                    state.fingers_pressed.remove(id);
476                }
477
478                _ => (),
479            }
480
481            // Present a context menu on a right click event.
482            if !was_open
483                && self.context_menu.is_some()
484                && (right_button_released(event) || (touch_lifted(event) && fingers_pressed == 2))
485            {
486                state.context_cursor = cursor.position().unwrap_or_default();
487                let state = tree.state.downcast_mut::<LocalState>();
488                state.menu_bar_state.inner.with_data_mut(|state| {
489                    state.open = true;
490                    state.view_cursor = cursor;
491                });
492                #[cfg(wayland_platform)]
493                if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) {
494                    self.create_popup(layout, cursor, renderer, shell, viewport, state);
495                }
496
497                shell.request_redraw();
498                shell.capture_event();
499                self.report_open_state(tree.state.downcast_mut::<LocalState>(), shell);
500                return;
501            } else if !was_open && right_button_released(event)
502                || (touch_lifted(event))
503                || left_button_released(event)
504            {
505                state.menu_bar_state.inner.with_data_mut(|state| {
506                    was_open = true;
507                    state.menu_states.clear();
508                    state.active_root.clear();
509                    state.open = false;
510
511                    #[cfg(wayland_platform)]
512                    if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland))
513                        && let Some(id) = state.popup_id.remove(&self.window_id)
514                    {
515                        {
516                            let surface_action = self.on_surface_action.as_ref().unwrap();
517                            shell
518                                .publish(surface_action(crate::surface::action::destroy_popup(id)));
519                        }
520                        state.view_cursor = cursor;
521                    }
522                });
523            }
524        }
525        self.content.as_widget_mut().update(
526            &mut tree.children[0],
527            event,
528            layout,
529            cursor,
530            renderer,
531            clipboard,
532            shell,
533            viewport,
534        );
535        self.report_open_state(tree.state.downcast_mut::<LocalState>(), shell);
536    }
537
538    fn overlay<'b>(
539        &'b mut self,
540        tree: &'b mut Tree,
541        layout: iced_core::Layout<'b>,
542        renderer: &crate::Renderer,
543        viewport: &iced::Rectangle,
544        translation: Vector,
545    ) -> Option<iced_core::overlay::Element<'b, Message, crate::Theme, crate::Renderer>> {
546        // The wrapped content's overlays (tooltips, dropdowns, ...) always pass through
547        let content = self.content.as_widget_mut().overlay(
548            &mut tree.children[0],
549            layout,
550            renderer,
551            viewport,
552            translation,
553        );
554
555        #[cfg(wayland_platform)]
556        if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland))
557            && self.window_id != window::Id::NONE
558            && self.on_surface_action.is_some()
559        {
560            return content;
561        }
562
563        let state = tree.state.downcast_ref::<LocalState>();
564        let Some(context_menu) = self.context_menu.as_mut() else {
565            return content;
566        };
567        if !state.menu_bar_state.inner.with_data(|state| state.open) {
568            return content;
569        }
570
571        // Anchor the menu to a 1x1 rectangle at the click, like the popup path does
572        let bounds = iced::Rectangle::new(state.context_cursor, Size::new(1.0, 1.0));
573        let menu = crate::widget::menu::Menu {
574            tree: state.menu_bar_state.clone(),
575            menu_roots: std::borrow::Cow::Owned(context_menu.clone()),
576            bounds_expand: 16,
577            menu_overlays_parent: true,
578            close_condition: CloseCondition {
579                leave: false,
580                click_outside: true,
581                click_inside: true,
582            },
583            item_width: self.item_width,
584            item_height: ItemHeight::Dynamic(40),
585            bar_bounds: bounds,
586            main_offset: 0,
587            cross_offset: 0,
588            root_bounds_list: vec![bounds],
589            path_highlight: Some(PathHighlight::MenuActive),
590            style: std::borrow::Cow::Borrowed(&crate::theme::menu_bar::MenuBarStyle::Default),
591            position: Point::new(translation.x, translation.y),
592            is_overlay: true,
593            window_id: window::Id::NONE,
594            depth: 0,
595            on_surface_action: None,
596        }
597        .overlay();
598
599        Some(match content {
600            Some(content) => {
601                iced_core::overlay::Group::with_children(vec![content, menu]).overlay()
602            }
603            None => menu,
604        })
605    }
606
607    #[cfg(feature = "a11y")]
608    /// get the a11y nodes for the widget
609    fn a11y_nodes(
610        &self,
611        layout: iced_core::Layout<'_>,
612        state: &Tree,
613        p: mouse::Cursor,
614    ) -> iced_accessibility::A11yTree {
615        let c_state = &state.children[0];
616        self.content.as_widget().a11y_nodes(layout, c_state, p)
617    }
618}
619
620impl<'a, Message: Clone + 'static> From<ContextMenu<'a, Message>> for crate::Element<'a, Message> {
621    fn from(widget: ContextMenu<'a, Message>) -> Self {
622        Self::new(widget)
623    }
624}
625
626fn right_button_released(event: &Event) -> bool {
627    matches!(
628        event,
629        Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right,))
630    )
631}
632
633fn left_button_released(event: &Event) -> bool {
634    matches!(
635        event,
636        Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left,))
637    )
638}
639
640fn touch_lifted(event: &Event) -> bool {
641    matches!(event, Event::Touch(touch::Event::FingerLifted { .. }))
642}
643
644pub struct LocalState {
645    context_cursor: Point,
646    fingers_pressed: HashSet<Finger>,
647    menu_bar_state: MenuBarState,
648    reported_open: bool,
649}