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