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