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