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(all(feature = "wayland", feature = "winit", feature = "surface-message"))]
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, event, 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::<'static, Message>()),
31                menus,
32            )]
33        }),
34        close_on_escape: true,
35        window_id: window::Id::RESERVED,
36        on_surface_action: None,
37    };
38
39    if let Some(ref mut context_menu) = this.context_menu {
40        context_menu.iter_mut().for_each(menu::Tree::set_index);
41    }
42
43    this
44}
45
46/// A context menu is a menu in a graphical user interface that appears upon user interaction, such as a right-click mouse operation.
47#[derive(Setters)]
48#[must_use]
49pub struct ContextMenu<'a, Message> {
50    #[setters(skip)]
51    content: crate::Element<'a, Message>,
52    #[setters(skip)]
53    context_menu: Option<Vec<menu::Tree<Message>>>,
54    pub window_id: window::Id,
55    pub close_on_escape: bool,
56    #[setters(skip)]
57    pub(crate) on_surface_action:
58        Option<Arc<dyn Fn(crate::surface::Action) -> Message + Send + Sync + 'static>>,
59}
60
61impl<Message: Clone + 'static> ContextMenu<'_, Message> {
62    #[cfg(all(feature = "wayland", feature = "winit", feature = "surface-message"))]
63    #[allow(clippy::too_many_lines)]
64    fn create_popup(
65        &mut self,
66        layout: iced_core::Layout<'_>,
67        view_cursor: iced_core::mouse::Cursor,
68        renderer: &crate::Renderer,
69        shell: &mut iced_core::Shell<'_, Message>,
70        viewport: &iced::Rectangle,
71        my_state: &mut LocalState,
72    ) {
73        if self.window_id != window::Id::NONE && self.on_surface_action.is_some() {
74            use crate::{surface::action::destroy_popup, widget::menu::Menu};
75            use iced_runtime::platform_specific::wayland::popup::{
76                SctkPopupSettings, SctkPositioner,
77            };
78
79            let mut bounds = layout.bounds();
80            bounds.x = my_state.context_cursor.x;
81            bounds.y = my_state.context_cursor.y;
82
83            let (id, root_list) = my_state.menu_bar_state.inner.with_data_mut(|state| {
84                if let Some(id) = state.popup_id.get(&self.window_id).copied() {
85                    // close existing popups
86                    state.menu_states.clear();
87                    state.active_root.clear();
88                    shell.publish(self.on_surface_action.as_ref().unwrap()(destroy_popup(id)));
89                    state.view_cursor = view_cursor;
90                    (
91                        id,
92                        layout.children().map(|lo| lo.bounds()).collect::<Vec<_>>(),
93                    )
94                } else {
95                    (
96                        window::Id::unique(),
97                        layout.children().map(|lo| lo.bounds()).collect(),
98                    )
99                }
100            });
101            let Some(context_menu) = self.context_menu.as_mut() else {
102                return;
103            };
104
105            let mut popup_menu: Menu<'static, _> = Menu {
106                tree: my_state.menu_bar_state.clone(),
107                menu_roots: std::borrow::Cow::Owned(context_menu.clone()),
108                bounds_expand: 16,
109                menu_overlays_parent: true,
110                close_condition: CloseCondition {
111                    leave: false,
112                    click_outside: true,
113                    click_inside: true,
114                },
115                item_width: ItemWidth::Uniform(240),
116                item_height: ItemHeight::Dynamic(40),
117                bar_bounds: bounds,
118                main_offset: -(bounds.height as i32),
119                cross_offset: 0,
120                root_bounds_list: vec![bounds],
121                path_highlight: Some(PathHighlight::MenuActive),
122                style: std::borrow::Cow::Owned(crate::theme::menu_bar::MenuBarStyle::Default),
123                position: Point::new(0., 0.),
124                is_overlay: false,
125                window_id: id,
126                depth: 0,
127                on_surface_action: self.on_surface_action.clone(),
128            };
129
130            init_root_menu(
131                &mut popup_menu,
132                renderer,
133                shell,
134                view_cursor.position().unwrap(),
135                viewport.size(),
136                Vector::new(0., 0.),
137                layout.bounds(),
138                -bounds.height,
139            );
140            let (anchor_rect, gravity) = my_state.menu_bar_state.inner.with_data_mut(|state| {
141                use iced::Rectangle;
142
143                state.popup_id.insert(self.window_id, id);
144                ({
145                    let pos = view_cursor.position().unwrap_or_default();
146                    Rectangle {
147                        x: pos.x as i32,
148                        y: pos.y as i32,
149                        width: 1,
150                        height: 1,
151                    }
152                },
153                match (state.horizontal_direction, state.vertical_direction) {
154                    (Direction::Positive, Direction::Positive) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight,
155                    (Direction::Positive, Direction::Negative) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::TopRight,
156                    (Direction::Negative, Direction::Positive) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomLeft,
157                    (Direction::Negative, Direction::Negative) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::TopLeft,
158                })
159            });
160
161            let menu_node =
162                popup_menu.layout(renderer, iced::Limits::NONE.min_width(1.).min_height(1.));
163            let popup_size = menu_node.size();
164            let positioner = SctkPositioner {
165                size: Some((
166                    popup_size.width.ceil() as u32 + 2,
167                    popup_size.height.ceil() as u32 + 2,
168                )),
169                anchor_rect,
170                anchor: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Anchor::None,
171                gravity: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight,
172                reactive: true,
173                ..Default::default()
174            };
175            let parent = self.window_id;
176            shell.publish((self.on_surface_action.as_ref().unwrap())(
177                crate::surface::action::simple_popup(
178                    move || SctkPopupSettings {
179                        parent,
180                        id,
181                        positioner: positioner.clone(),
182                        parent_size: None,
183                        grab: true,
184                        close_with_children: false,
185                        input_zone: None,
186                    },
187                    Some(move || {
188                        crate::Element::from(
189                            crate::widget::container(popup_menu.clone()).center(Length::Fill),
190                        )
191                        .map(crate::action::app)
192                    }),
193                ),
194            ));
195        }
196    }
197
198    pub fn on_surface_action(
199        mut self,
200        handler: impl Fn(crate::surface::Action) -> Message + Send + Sync + 'static,
201    ) -> Self {
202        self.on_surface_action = Some(Arc::new(handler));
203        self
204    }
205}
206
207impl<Message: 'static + Clone> Widget<Message, crate::Theme, crate::Renderer>
208    for ContextMenu<'_, Message>
209{
210    fn tag(&self) -> tree::Tag {
211        tree::Tag::of::<LocalState>()
212    }
213
214    fn state(&self) -> tree::State {
215        #[allow(clippy::default_trait_access)]
216        tree::State::new(LocalState {
217            context_cursor: Point::default(),
218            fingers_pressed: Default::default(),
219            menu_bar_state: Default::default(),
220        })
221    }
222
223    fn children(&self) -> Vec<Tree> {
224        let mut children = Vec::with_capacity(if self.context_menu.is_some() { 2 } else { 1 });
225
226        children.push(Tree::new(self.content.as_widget()));
227
228        // Assign the context menu's elements as this widget's children.
229        if let Some(ref context_menu) = self.context_menu {
230            let mut tree = Tree::empty();
231            tree.children = context_menu
232                .iter()
233                .map(|root| {
234                    let mut tree = Tree::empty();
235                    let flat = root
236                        .flattern()
237                        .iter()
238                        .map(|mt| Tree::new(mt.item.clone()))
239                        .collect();
240                    tree.children = flat;
241                    tree
242                })
243                .collect();
244
245            children.push(tree);
246        }
247
248        children
249    }
250
251    fn diff(&mut self, tree: &mut Tree) {
252        tree.children[0].diff(self.content.as_widget_mut());
253        let state = tree.state.downcast_mut::<LocalState>();
254        state.menu_bar_state.inner.with_data_mut(|inner| {
255            menu_roots_diff(self.context_menu.as_mut().unwrap(), &mut inner.tree);
256        });
257
258        // if let Some(ref mut context_menus) = self.context_menu {
259        //     for (menu, tree) in context_menus
260        //         .iter_mut()
261        //         .zip(tree.children[1].children.iter_mut())
262        //     {
263        //         menu.item.as_widget_mut().diff(tree);
264        //     }
265        // }
266    }
267
268    fn size(&self) -> Size<Length> {
269        self.content.as_widget().size()
270    }
271
272    fn layout(
273        &self,
274        tree: &mut Tree,
275        renderer: &crate::Renderer,
276        limits: &iced_core::layout::Limits,
277    ) -> iced_core::layout::Node {
278        self.content
279            .as_widget()
280            .layout(&mut tree.children[0], renderer, limits)
281    }
282
283    fn draw(
284        &self,
285        tree: &Tree,
286        renderer: &mut crate::Renderer,
287        theme: &crate::Theme,
288        style: &iced_core::renderer::Style,
289        layout: iced_core::Layout<'_>,
290        cursor: iced_core::mouse::Cursor,
291        viewport: &iced::Rectangle,
292    ) {
293        self.content.as_widget().draw(
294            &tree.children[0],
295            renderer,
296            theme,
297            style,
298            layout,
299            cursor,
300            viewport,
301        );
302    }
303
304    fn operate(
305        &self,
306        tree: &mut Tree,
307        layout: iced_core::Layout<'_>,
308        renderer: &crate::Renderer,
309        operation: &mut dyn iced_core::widget::Operation<()>,
310    ) {
311        self.content
312            .as_widget()
313            .operate(&mut tree.children[0], layout, renderer, operation);
314    }
315
316    #[allow(clippy::too_many_lines)]
317    fn on_event(
318        &mut self,
319        tree: &mut Tree,
320        event: iced::Event,
321        layout: iced_core::Layout<'_>,
322        cursor: iced_core::mouse::Cursor,
323        renderer: &crate::Renderer,
324        clipboard: &mut dyn iced_core::Clipboard,
325        shell: &mut iced_core::Shell<'_, Message>,
326        viewport: &iced::Rectangle,
327    ) -> iced_core::event::Status {
328        let state = tree.state.downcast_mut::<LocalState>();
329        let bounds = layout.bounds();
330
331        // XXX this should reset the state if there are no other copies of the state, which implies no dropdown menus open.
332        let reset = self.window_id != window::Id::NONE
333            && state
334                .menu_bar_state
335                .inner
336                .with_data(|d| !d.open && !d.active_root.is_empty());
337
338        let open = state.menu_bar_state.inner.with_data_mut(|state| {
339            if reset {
340                if let Some(popup_id) = state.popup_id.get(&self.window_id).copied() {
341                    if let Some(handler) = self.on_surface_action.as_ref() {
342                        shell.publish((handler)(crate::surface::Action::DestroyPopup(popup_id)));
343                        state.reset();
344                    }
345                }
346            }
347            state.open
348        });
349        let mut was_open = false;
350        if matches!(event,
351            Event::Keyboard(keyboard::Event::KeyPressed {
352                key: keyboard::Key::Named(keyboard::key::Named::Escape),
353                ..
354            })
355            | Event::Mouse(mouse::Event::ButtonPressed(
356                mouse::Button::Right | mouse::Button::Left,
357            ))
358            | Event::Touch(touch::Event::FingerPressed { .. })
359            | Event::Window(window::Event::Focused)
360                if open )
361        {
362            state.menu_bar_state.inner.with_data_mut(|state| {
363                was_open = true;
364                state.menu_states.clear();
365                state.active_root.clear();
366                state.open = false;
367
368                #[cfg(all(feature = "wayland", feature = "winit", feature = "surface-message"))]
369                if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) {
370                    if let Some(id) = state.popup_id.remove(&self.window_id) {
371                        {
372                            let surface_action = self.on_surface_action.as_ref().unwrap();
373                            shell
374                                .publish(surface_action(crate::surface::action::destroy_popup(id)));
375                        }
376                        state.view_cursor = cursor;
377                    }
378                }
379            });
380        }
381
382        if !was_open && cursor.is_over(bounds) {
383            let fingers_pressed = state.fingers_pressed.len();
384
385            match event {
386                Event::Touch(touch::Event::FingerPressed { id, .. }) => {
387                    state.fingers_pressed.insert(id);
388                }
389
390                Event::Touch(touch::Event::FingerLifted { id, .. }) => {
391                    state.fingers_pressed.remove(&id);
392                }
393
394                _ => (),
395            }
396
397            // Present a context menu on a right click event.
398            if !was_open
399                && self.context_menu.is_some()
400                && (right_button_released(&event) || (touch_lifted(&event) && fingers_pressed == 2))
401            {
402                state.context_cursor = cursor.position().unwrap_or_default();
403                let state = tree.state.downcast_mut::<LocalState>();
404                state.menu_bar_state.inner.with_data_mut(|state| {
405                    state.open = true;
406                    state.view_cursor = cursor;
407                });
408                #[cfg(all(feature = "wayland", feature = "winit", feature = "surface-message"))]
409                if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) {
410                    self.create_popup(layout, cursor, renderer, shell, viewport, state);
411                }
412
413                return event::Status::Captured;
414            } else if !was_open && right_button_released(&event)
415                || (touch_lifted(&event))
416                || left_button_released(&event)
417            {
418                state.menu_bar_state.inner.with_data_mut(|state| {
419                    was_open = true;
420                    state.menu_states.clear();
421                    state.active_root.clear();
422                    state.open = false;
423
424                    #[cfg(all(
425                        feature = "wayland",
426                        feature = "winit",
427                        feature = "surface-message"
428                    ))]
429                    if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) {
430                        if let Some(id) = state.popup_id.remove(&self.window_id) {
431                            {
432                                let surface_action = self.on_surface_action.as_ref().unwrap();
433                                shell.publish(surface_action(
434                                    crate::surface::action::destroy_popup(id),
435                                ));
436                            }
437                            state.view_cursor = cursor;
438                        }
439                    }
440                });
441            }
442        }
443        self.content.as_widget_mut().on_event(
444            &mut tree.children[0],
445            event,
446            layout,
447            cursor,
448            renderer,
449            clipboard,
450            shell,
451            viewport,
452        )
453    }
454
455    fn overlay<'b>(
456        &'b mut self,
457        tree: &'b mut Tree,
458        layout: iced_core::Layout<'_>,
459        _renderer: &crate::Renderer,
460        translation: Vector,
461    ) -> Option<iced_core::overlay::Element<'b, Message, crate::Theme, crate::Renderer>> {
462        #[cfg(all(feature = "wayland", feature = "winit", feature = "surface-message"))]
463        if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland))
464            && self.window_id != window::Id::NONE
465            && self.on_surface_action.is_some()
466        {
467            return None;
468        }
469
470        let state = tree.state.downcast_ref::<LocalState>();
471
472        let context_menu = self.context_menu.as_mut()?;
473
474        if !state.menu_bar_state.inner.with_data(|state| state.open) {
475            return None;
476        }
477
478        let mut bounds = layout.bounds();
479        bounds.x = state.context_cursor.x;
480        bounds.y = state.context_cursor.y;
481        Some(
482            crate::widget::menu::Menu {
483                tree: state.menu_bar_state.clone(),
484                menu_roots: std::borrow::Cow::Owned(context_menu.clone()),
485                bounds_expand: 16,
486                menu_overlays_parent: true,
487                close_condition: CloseCondition {
488                    leave: false,
489                    click_outside: true,
490                    click_inside: true,
491                },
492                item_width: ItemWidth::Uniform(240),
493                item_height: ItemHeight::Dynamic(40),
494                bar_bounds: bounds,
495                main_offset: -(bounds.height as i32),
496                cross_offset: 0,
497                root_bounds_list: vec![bounds],
498                path_highlight: Some(PathHighlight::MenuActive),
499                style: std::borrow::Cow::Borrowed(&crate::theme::menu_bar::MenuBarStyle::Default),
500                position: Point::new(translation.x, translation.y),
501                is_overlay: true,
502                window_id: window::Id::NONE,
503                depth: 0,
504                on_surface_action: None,
505            }
506            .overlay(),
507        )
508    }
509
510    #[cfg(feature = "a11y")]
511    /// get the a11y nodes for the widget
512    fn a11y_nodes(
513        &self,
514        layout: iced_core::Layout<'_>,
515        state: &Tree,
516        p: mouse::Cursor,
517    ) -> iced_accessibility::A11yTree {
518        let c_state = &state.children[0];
519        self.content.as_widget().a11y_nodes(layout, c_state, p)
520    }
521}
522
523impl<'a, Message: Clone + 'static> From<ContextMenu<'a, Message>> for crate::Element<'a, Message> {
524    fn from(widget: ContextMenu<'a, Message>) -> Self {
525        Self::new(widget)
526    }
527}
528
529fn right_button_released(event: &Event) -> bool {
530    matches!(
531        event,
532        Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right,))
533    )
534}
535
536fn left_button_released(event: &Event) -> bool {
537    matches!(
538        event,
539        Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left,))
540    )
541}
542
543fn touch_lifted(event: &Event) -> bool {
544    matches!(event, Event::Touch(touch::Event::FingerLifted { .. }))
545}
546
547pub struct LocalState {
548    context_cursor: Point,
549    fingers_pressed: HashSet<Finger>,
550    menu_bar_state: MenuBarState,
551}