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