Skip to main content

gpui_kit_grid/state/
menu.rs

1//! The context menu: its target, what the delegate knows when it builds the
2//! menu, the standard items, and the menu's life under live data.
3//!
4//! One path for the pointer and the keyboard: the grid builds its own
5//! `PopupMenu` in `window.defer` after the press (or the key) and shows it in
6//! a `deferred(anchored(..))` layer of its view, not through `ContextMenuExt`.
7//! So the grid knows while the menu is open, holds the row order meanwhile
8//! (`ReorderContext::is_menu_open`), and closes the menu when live data
9//! removes its target row. Items dispatch Actions to the grid's focus
10//! (`action_context`), so they pass through the `LiveGrid` key context and
11//! the grid's ancestors in the application.
12
13use std::sync::Arc;
14
15use gpui_kit::base::actions::Confirm;
16use gpui_kit::base::{POPUP_PRIORITY, StyledExt as _, TestSupportExt as _, h_flex};
17use gpui_kit::component::kbd::Kbd;
18use gpui_kit::component::menu::{PopupMenu, PopupMenuItem};
19use gpui_kit::{
20    AnyElement, App, Context, DismissEvent, Entity, FocusHandle, Focusable as _,
21    InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Role, SharedString,
22    StatefulInteractiveElement as _, Styled as _, Subscription, Window, anchored, deferred, div,
23    point, prelude::FluentBuilder as _, px, transparent_white,
24};
25use rust_i18n::t;
26
27use super::{
28    CELL_PADDING, ColumnOf, ColumnZone, KeyOf, LiveGridDelegate, LiveGridState, ScopedSelection,
29    SortOrigin, columns::column_of, selection::LineTarget,
30};
31// The grouping items of the column menu.
32use super::group_panel::{MenuGrouping, grouping_items};
33use crate::{
34    actions,
35    engine::{ExpandDepth, GroupInfo, GroupPath, Projection, SortDirection},
36    model::{GridColumns, GridRow},
37    store::{PinnedEdge, StoreSnapshot},
38};
39
40/// What a press or the keyboard pointed at. Captured when the button goes
41/// down, so a re-sort before the menu is built cannot retarget it.
42///
43/// A value type: its variants' fields are its definition.
44#[derive(Clone, Debug, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum GridTarget<K, C> {
47    /// A data cell.
48    Cell {
49        /// The row.
50        key: K,
51        /// The column.
52        column: C,
53    },
54    /// A cell of a pinned row. Its key is of its band's own key space: a
55    /// data row or the other band may have the same key.
56    Pinned {
57        /// The band.
58        edge: PinnedEdge,
59        /// The pinned row.
60        key: K,
61        /// The column.
62        column: C,
63    },
64    /// A column header cell, a band (its first visible column) or a resize
65    /// handle.
66    Header {
67        /// The column.
68        column: C,
69    },
70    /// The body or header outside rows and columns; the keyboard menu with
71    /// no focused cell.
72    Empty,
73    /// A group caption.
74    GroupCaption {
75        /// The group.
76        group: GroupPath,
77        /// The aggregate cell under the pointer, if any; `None` from the
78        /// keyboard.
79        column: Option<C>,
80    },
81    /// A cell of a group footer.
82    GroupFooter {
83        /// The group the footer sums up.
84        group: GroupPath,
85        /// The column.
86        column: C,
87    },
88    /// A cell of the totals row.
89    Totals {
90        /// The band the totals row is in.
91        edge: PinnedEdge,
92        /// The column.
93        column: C,
94    },
95}
96
97impl<K, C: Copy> GridTarget<K, C> {
98    /// The data row key of a cell target. A pinned row's key is of its
99    /// band's key space, so it is not returned here, where it could be
100    /// taken for a data row's (match [`GridTarget::Pinned`]).
101    pub fn key(&self) -> Option<&K> {
102        match self {
103            Self::Cell { key, .. } => Some(key),
104            Self::Pinned { .. }
105            | Self::Header { .. }
106            | Self::Empty
107            | Self::GroupCaption { .. }
108            | Self::GroupFooter { .. }
109            | Self::Totals { .. } => None,
110        }
111    }
112
113    /// The column of a cell, pinned cell, header, footer or totals target,
114    /// and of a caption's aggregate cell.
115    pub fn column(&self) -> Option<C> {
116        match self {
117            Self::Cell { column, .. }
118            | Self::Pinned { column, .. }
119            | Self::Header { column }
120            | Self::GroupFooter { column, .. }
121            | Self::Totals { column, .. } => Some(*column),
122            Self::GroupCaption { column, .. } => *column,
123            Self::Empty => None,
124        }
125    }
126
127    /// The group of a caption or footer target.
128    pub fn group(&self) -> Option<&GroupPath> {
129        match self {
130            Self::GroupCaption { group, .. } | Self::GroupFooter { group, .. } => Some(group),
131            Self::Cell { .. }
132            | Self::Pinned { .. }
133            | Self::Header { .. }
134            | Self::Empty
135            | Self::Totals { .. } => None,
136        }
137    }
138
139    /// The line a command from this target acts on: a data row, or a
140    /// caption's group. A footer and the totals take no command of their
141    /// own: Copy over them copies the scoped selection.
142    pub(crate) fn line(&self) -> Option<LineTarget<K>>
143    where
144        K: Clone,
145    {
146        match self {
147            Self::Cell { key, .. } => Some(LineTarget::Row(key.clone())),
148            Self::GroupCaption { group, .. } => Some(LineTarget::Group(group.clone())),
149            Self::Pinned { .. }
150            | Self::Header { .. }
151            | Self::Empty
152            | Self::GroupFooter { .. }
153            | Self::Totals { .. } => None,
154        }
155    }
156}
157
158/// The state of the target column when the menu opened, for the standard
159/// items' enabled and checked states.
160#[derive(Clone, Copy, Debug)]
161struct MenuColumn {
162    sortable: bool,
163    /// Its direction, if it is in the sort.
164    direction: Option<SortDirection>,
165    zone: ColumnZone,
166    /// The only visible column: it cannot be hidden.
167    last_visible: bool,
168    /// The user may hide it.
169    hideable: bool,
170}
171
172/// What [`LiveGridDelegate::context_menu`] and
173/// [`LiveGridDelegate::primary_command`] know: the target, the command scope
174/// and the rows of the snapshot shown when the menu opened.
175///
176/// [`LiveGridDelegate::context_menu`]: crate::LiveGridDelegate::context_menu
177/// [`LiveGridDelegate::primary_command`]: crate::LiveGridDelegate::primary_command
178pub struct GridMenuContext<'a, R: GridRow> {
179    target: GridTarget<R::Key, R::Column>,
180    selection: ScopedSelection<R::Key>,
181    snapshot: &'a StoreSnapshot<R>,
182    projection: &'a Projection,
183    /// The expansion state of a caption target, by the view's expansion.
184    expanded: Option<bool>,
185    /// The view's default depth.
186    expand_depth: ExpandDepth,
187    column: Option<MenuColumn>,
188    any_hidden: bool,
189    /// Some column is in the sort: Clear all sorting is enabled.
190    sorted: bool,
191    /// The grouping when the menu opened, for `grouping_items`.
192    pub(super) grouping: MenuGrouping,
193    /// The grid's focus: items dispatch their Actions from it.
194    focus_handle: FocusHandle,
195}
196
197impl<'a, R: GridRow> GridMenuContext<'a, R> {
198    /// Where the menu was opened.
199    pub fn target(&self) -> &GridTarget<R::Key, R::Column> {
200        &self.target
201    }
202
203    /// The command scope of the target when the menu opened, in display
204    /// order: a caption's group rows, else `scoped_selection()`, as the
205    /// selection policy decides. For labels and enabled states only: a
206    /// command handler reads the scope again when it runs.
207    pub fn selection(&self) -> &ScopedSelection<R::Key> {
208        &self.selection
209    }
210
211    /// The group of a caption or footer target, in the projection shown
212    /// when the menu opened.
213    pub fn group(&self) -> Option<GroupInfo<'a>> {
214        self.projection.groups().find(self.target.group()?)
215    }
216
217    /// A data row of the snapshot shown when the menu opened.
218    pub fn row(&self, key: &R::Key) -> Option<&'a Arc<R>> {
219        self.snapshot.get(key)
220    }
221
222    /// The row the target is in, as the snapshot shown when the menu opened
223    /// has it: the data row of a cell target, the pinned row of a pinned
224    /// target; `None` for a header, the empty area and the lines the grid
225    /// makes (captions, footers, totals).
226    pub fn target_row(&self) -> Option<&'a Arc<R>> {
227        match &self.target {
228            GridTarget::Cell { key, .. } => self.snapshot.get(key),
229            GridTarget::Pinned { edge, key, .. } => self
230                .snapshot
231                .pinned_rows(*edge)
232                .iter()
233                .find(|row| row.key() == key),
234            GridTarget::Header { .. }
235            | GridTarget::Empty
236            | GridTarget::GroupCaption { .. }
237            | GridTarget::GroupFooter { .. }
238            | GridTarget::Totals { .. } => None,
239        }
240    }
241
242    /// The built-in items for the target, in the standard order:
243    ///
244    /// - a cell: Copy, Copy all; the Sort and Column submenus for its
245    ///   column, so the keyboard reaches the header's commands (Shift+F10
246    ///   on the focused cell);
247    /// - a header: the sort items, the column items, Copy, Copy all;
248    /// - a group caption: the group items, Copy (the group's rows), Copy
249    ///   all;
250    /// - a pinned row, a group footer, the totals row and elsewhere: Copy,
251    ///   Copy all (Copy copies the selection of data rows; Copy all takes
252    ///   the bands and the totals too).
253    ///
254    /// The grid puts the primary command above them.
255    pub fn standard_items(
256        &self,
257        menu: PopupMenu,
258        window: &mut Window,
259        cx: &mut Context<PopupMenu>,
260    ) -> PopupMenu {
261        match &self.target {
262            GridTarget::Cell { .. } => {
263                // The keyboard path to sorting and to the column commands:
264                // Shift+F10 on a cell, then Sort or Column. Submenus do not
265                // inherit the action context: each sets its own.
266                let (column, any_hidden, sorted) = (self.column, self.any_hidden, self.sorted);
267                let grouping = self.grouping;
268                let sort = PopupMenu::build(window, cx, {
269                    let focus = self.focus_handle.clone();
270                    move |menu, _, _| sort_items(column, sorted, menu.action_context(focus))
271                });
272                let columns = PopupMenu::build(window, cx, {
273                    let focus = self.focus_handle.clone();
274                    move |menu, _, _| {
275                        column_items(column, any_hidden, grouping, menu.action_context(focus))
276                    }
277                });
278                // Clear all sorting is reachable from a column that is
279                // not sortable too.
280                let sortable = column.is_some_and(|column| column.sortable);
281                // A tree row with children expands, as a caption.
282                let menu = menu.when(self.expanded.is_some(), |menu| {
283                    self.group_items(menu).separator()
284                });
285                self.copy_items(menu)
286                    .separator()
287                    .item(
288                        PopupMenuItem::submenu(label("live_grid.menu.sort"), sort)
289                            .disabled(!sortable && !sorted),
290                    )
291                    .item(PopupMenuItem::submenu(
292                        label("live_grid.menu.column"),
293                        columns,
294                    ))
295            }
296            GridTarget::Header { .. } => {
297                let menu = self.sort_items(menu).separator();
298                self.copy_items(self.column_items(menu).separator())
299            }
300            GridTarget::GroupCaption { .. } => self.copy_items(self.group_items(menu).separator()),
301            GridTarget::Pinned { .. }
302            | GridTarget::GroupFooter { .. }
303            | GridTarget::Totals { .. }
304            | GridTarget::Empty => self.copy_items(menu),
305        }
306    }
307
308    /// Expand or Collapse for a caption target (by its state), then Expand
309    /// all, Collapse all and Captions only (checked while the default depth
310    /// is `ExpandDepth::Captions`). They dispatch `ExpandRow`,
311    /// `CollapseRow`, `ExpandAll`, `CollapseAll` and `ShowCaptionsOnly` to
312    /// the grid, whose focus is on the caption a press opened the menu for.
313    pub fn group_items(&self, menu: PopupMenu) -> PopupMenu {
314        menu.when_some(self.expanded, |menu, expanded| {
315            if expanded {
316                menu.menu(
317                    label("live_grid.menu.collapse"),
318                    Box::new(actions::CollapseRow),
319                )
320            } else {
321                menu.menu(label("live_grid.menu.expand"), Box::new(actions::ExpandRow))
322            }
323        })
324        .menu(
325            label("live_grid.menu.expand_all"),
326            Box::new(actions::ExpandAll),
327        )
328        .menu(
329            label("live_grid.menu.collapse_all"),
330            Box::new(actions::CollapseAll),
331        )
332        // A tree has no captions.
333        .when(!self.projection.is_tree(), |menu| {
334            menu.menu_with_check(
335                label("live_grid.menu.captions_only"),
336                self.expand_depth == ExpandDepth::Captions,
337                Box::new(actions::ShowCaptionsOnly),
338            )
339        })
340    }
341
342    /// Copy (`Copy`, disabled when the command scope is empty) and Copy all
343    /// (`CopyAll`).
344    pub fn copy_items(&self, menu: PopupMenu) -> PopupMenu {
345        menu.menu_with_disabled(
346            label("live_grid.menu.copy"),
347            Box::new(actions::Copy),
348            self.selection.is_empty(),
349        )
350        .menu(label("live_grid.menu.copy_all"), Box::new(actions::CopyAll))
351    }
352
353    /// Sort ascending, Sort descending and Clear sort for the target column,
354    /// then Clear all sorting (`ClearAllSorting`); nothing without a target
355    /// column. The first three are disabled for a column that is not
356    /// sortable; the current direction is checked; Clear sort is disabled
357    /// for a column outside the sort, Clear all sorting when nothing is
358    /// sorted.
359    pub fn sort_items(&self, menu: PopupMenu) -> PopupMenu {
360        sort_items(self.column, self.sorted, menu)
361    }
362
363    /// Autofit, hide, show all, choose (`ShowColumnChooser`), freeze,
364    /// unfreeze and reset columns for the target column, then the grouping items ([`Self::grouping_items`]).
365    /// Without a target column, only the items for every column. "Hide
366    /// column" is disabled for the last visible column and for a column the
367    /// user may not hide (`GridColumn::with_hideable`).
368    pub fn column_items(&self, menu: PopupMenu) -> PopupMenu {
369        column_items(self.column, self.any_hidden, self.grouping, menu)
370    }
371}
372
373/// A menu label from this crate's catalog.
374fn label(key: &str) -> SharedString {
375    SharedString::from(t!(key).into_owned())
376}
377
378/// [`GridMenuContext::column_items`]; `any_hidden` enables Show all columns.
379fn column_items(
380    column: Option<MenuColumn>,
381    any_hidden: bool,
382    grouping: MenuGrouping,
383    menu: PopupMenu,
384) -> PopupMenu {
385    menu.when(column.is_some(), |menu| {
386        menu.menu(
387            label("live_grid.menu.autofit_column"),
388            Box::new(actions::AutofitColumn),
389        )
390    })
391    .menu(
392        label("live_grid.menu.autofit_columns"),
393        Box::new(actions::AutofitColumns),
394    )
395    .separator()
396    .when_some(column, |menu, column| {
397        menu.menu_with_disabled(
398            label("live_grid.menu.hide_column"),
399            Box::new(actions::HideColumn),
400            column.last_visible || !column.hideable,
401        )
402    })
403    .menu_with_disabled(
404        label("live_grid.menu.show_all_columns"),
405        Box::new(actions::ShowAllColumns),
406        !any_hidden,
407    )
408    // Every column in one place.
409    .menu(
410        label("live_grid.menu.choose_columns"),
411        Box::new(actions::ShowColumnChooser),
412    )
413    .separator()
414    // Only the moves that change the column's zone.
415    .when_some(column, |menu, column| {
416        menu.when(column.zone != ColumnZone::FrozenLeft, |menu| {
417            menu.menu(
418                label("live_grid.menu.freeze_left"),
419                Box::new(actions::FreezeColumnLeft),
420            )
421        })
422        .when(column.zone != ColumnZone::FrozenRight, |menu| {
423            menu.menu(
424                label("live_grid.menu.freeze_right"),
425                Box::new(actions::FreezeColumnRight),
426            )
427        })
428        .when(column.zone != ColumnZone::Scrolling, |menu| {
429            menu.menu(
430                label("live_grid.menu.unfreeze"),
431                Box::new(actions::UnfreezeColumn),
432            )
433        })
434        .separator()
435    })
436    .menu(
437        label("live_grid.menu.reset_columns"),
438        Box::new(actions::ResetLayout),
439    )
440    // The grouping commands, after a separator.
441    .map(|menu| grouping_items(grouping, menu.separator()))
442}
443
444/// [`GridMenuContext::sort_items`]; `sorted` enables Clear all sorting.
445fn sort_items(column: Option<MenuColumn>, sorted: bool, menu: PopupMenu) -> PopupMenu {
446    let Some(column) = column else {
447        return menu;
448    };
449    let disabled = !column.sortable;
450    menu.menu_with_check_and_disabled(
451        label("live_grid.menu.sort_ascending"),
452        column.direction == Some(SortDirection::Ascending),
453        Box::new(actions::SortAscending),
454        disabled,
455    )
456    .menu_with_check_and_disabled(
457        label("live_grid.menu.sort_descending"),
458        column.direction == Some(SortDirection::Descending),
459        Box::new(actions::SortDescending),
460        disabled,
461    )
462    .menu_with_disabled(
463        label("live_grid.menu.clear_sort"),
464        Box::new(actions::ClearSort),
465        disabled || column.direction.is_none(),
466    )
467    .menu_with_disabled(
468        label("live_grid.menu.clear_all_sorting"),
469        Box::new(actions::ClearAllSorting),
470        !sorted,
471    )
472}
473
474/// The element id of the primary command's label.
475const PRIMARY_LABEL_ID: &str = "live-grid-primary-command";
476
477/// The primary command: its label in bold, with Enter's key from the
478/// keymap (Design Guides: the emphasized command is the one Enter runs).
479///
480/// An element item has no accessible name of its own. AccessKit names a
481/// menu item after the values of its `Label` descendants, so the label is
482/// one: a screen reader reads the command's name.
483fn primary_item(
484    label: SharedString,
485    focus: FocusHandle,
486) -> impl Fn(&mut Window, &mut App) -> AnyElement + 'static {
487    move |window, _| {
488        let key = Kbd::binding_for_action_in(&Confirm { secondary: false }, &focus, window);
489        h_flex()
490            .w_full()
491            .gap_3()
492            .items_center()
493            .justify_between()
494            .child(
495                div()
496                    .id(PRIMARY_LABEL_ID)
497                    .test_support()
498                    .role(Role::Label)
499                    .aria_value(label.clone())
500                    .font_semibold()
501                    .child(label.clone()),
502            )
503            .children(key.map(|key| key.p_0().flex_nowrap().border_0().bg(transparent_white())))
504            .into_any_element()
505    }
506}
507
508/// The open context menu.
509pub(super) struct OpenMenu<K, C> {
510    entity: Entity<PopupMenu>,
511    target: GridTarget<K, C>,
512    /// Where it opened, in window coordinates.
513    position: Point<Pixels>,
514    _dismiss: Subscription,
515}
516
517impl<D: LiveGridDelegate> LiveGridState<D> {
518    /// Returns true while the context menu is open.
519    pub fn is_menu_open(&self) -> bool {
520        self.menu.is_some()
521    }
522
523    /// Where the open context menu is, in window coordinates ("Choose
524    /// columns…" opens the chooser there).
525    pub(super) fn menu_position(&self) -> Option<Point<Pixels>> {
526        self.menu.as_ref().map(|menu| menu.position)
527    }
528
529    /// Opens the context menu for the focused cell, below it, scrolled into
530    /// view (`ShowContextMenu`); with no focused cell, at the top left of the
531    /// rows, for the whole grid. The menu is built after the current event.
532    pub fn show_context_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
533        self.reveal_focus();
534        let target = self
535            .selection
536            .focus()
537            .and_then(|cell| {
538                if let Some(group) = cell.group() {
539                    return Some(GridTarget::GroupCaption {
540                        group: group.clone(),
541                        column: None,
542                    });
543                }
544                let key = cell.row()?;
545                self.snapshot.get(key)?;
546                Some(GridTarget::Cell {
547                    key: key.clone(),
548                    column: cell.column(),
549                })
550            })
551            .unwrap_or(GridTarget::Empty);
552        let position = self
553            .cell_menu_position(&target)
554            .unwrap_or_else(|| self.rows_bounds().origin + point(CELL_PADDING, CELL_PADDING));
555        self.open_menu(target, position, window, cx);
556        cx.notify();
557    }
558
559    /// The bottom left corner of a cell target on screen, or of a caption
560    /// target's text, within the data rows' area.
561    fn cell_menu_position(
562        &self,
563        target: &GridTarget<KeyOf<D>, ColumnOf<D>>,
564    ) -> Option<Point<Pixels>> {
565        if let GridTarget::GroupCaption { group, .. } = target {
566            let found = self.projection.groups().find(group)?;
567            let display_ix = self.projection.group_line(found.id())?;
568            let line = display_ix.checked_sub(self.scroll_row)?;
569            let body = self.rows_bounds();
570            let bottom = body.origin.y + self.row_height * (line + 1) as f32 - self.scroll_offset;
571            return Some(point(
572                self.caption_lane_x(group.depth()),
573                bottom.clamp(body.origin.y, body.bottom()),
574            ));
575        }
576        let GridTarget::Cell { key, column } = target else {
577            return None;
578        };
579        let display_ix = self
580            .snapshot
581            .row_id(key)
582            .and_then(|id| self.projection.display_ix(id))?;
583        let line = display_ix.checked_sub(self.scroll_row)?;
584        let body = self.rows_bounds();
585        let bottom = body.origin.y + self.row_height * (line + 1) as f32 - self.scroll_offset;
586        let geometry = self.columns.geometry(body.size.width, self.scroll_x);
587        let (zone, i) = self.columns.zone_position(column.ix())?;
588        let frame = geometry.zone(zone);
589        let x = frame
590            .column_x(self.columns.zone_columns(zone), i)
591            .clamp(frame.x, (frame.x + frame.width).max(frame.x));
592        Some(point(
593            body.origin.x + x,
594            bottom.clamp(body.origin.y, body.bottom()),
595        ))
596    }
597
598    /// The menu of a context press on the body, at the target captured when
599    /// the button went down (`pointer_target()`). A row removed since the
600    /// frame was painted opens no menu: it leaves the screen next frame.
601    pub(super) fn open_body_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
602        let Some(pointer) = self.pointer_target() else {
603            return;
604        };
605        let position = pointer.position();
606        if let Some(target) = self.pressed_target() {
607            self.open_menu(target, position, window, cx);
608        }
609    }
610
611    /// The target of the last press on the body (`pointer_target()`), for
612    /// its menu and its click; `None` with no press, or for a row removed
613    /// since the frame was painted. Called while the button goes down: a
614    /// press on the totals row or a group footer is resolved against the
615    /// frame on screen then.
616    pub(super) fn pressed_target(&self) -> Option<GridTarget<KeyOf<D>, ColumnOf<D>>> {
617        let pointer = self.pointer_target()?;
618        if let Some(group) = pointer.group() {
619            return Some(GridTarget::GroupCaption {
620                group: group.clone(),
621                column: pointer.column().map(column_of),
622            });
623        }
624        if pointer.row().is_none()
625            && let Some(target) = self.summary_target(pointer.position())
626        {
627            return Some(target);
628        }
629        Some(match (pointer.pinned(), pointer.row(), pointer.column()) {
630            (Some(edge), Some(key), Some(column)) => GridTarget::Pinned {
631                edge,
632                key: key.clone(),
633                column: column_of(column),
634            },
635            (None, Some(key), Some(column)) => GridTarget::Cell {
636                key: key.clone(),
637                column: column_of(column),
638            },
639            (_, Some(_), None) => GridTarget::Empty,
640            (_, None, _) if self.is_removed_row_at(pointer.position()) => return None,
641            (_, None, _) => GridTarget::Empty,
642        })
643    }
644
645    /// Returns true if the frame painted a row at `position` that the store
646    /// no longer has.
647    fn is_removed_row_at(&self, position: Point<Pixels>) -> bool {
648        self.line_at(position)
649            .and_then(|line| self.painted.get(line)?.row())
650            .is_some_and(|id| self.snapshot.row(id).is_none())
651    }
652
653    /// Opens the menu for `target` at `position` (window coordinates) after
654    /// the current event, when the press has updated the selection. A
655    /// target with a column makes it the current column, so the column
656    /// Actions of the items act on it.
657    pub(super) fn open_menu(
658        &mut self,
659        target: GridTarget<KeyOf<D>, ColumnOf<D>>,
660        position: Point<Pixels>,
661        window: &mut Window,
662        cx: &mut Context<Self>,
663    ) {
664        if let Some(column) = target.column() {
665            self.set_current_column(column.ix());
666        }
667        let this = cx.weak_entity();
668        window.defer(cx, move |window, cx| {
669            let _ = this.update(cx, |state, cx| {
670                state.build_menu(target, position, window, cx)
671            });
672        });
673    }
674
675    /// Builds and shows the menu: the primary command first, then what the
676    /// delegate adds. The hooks run here, inside the grid's update: they
677    /// must not read the grid entity. An empty menu is not shown.
678    fn build_menu(
679        &mut self,
680        target: GridTarget<KeyOf<D>, ColumnOf<D>>,
681        position: Point<Pixels>,
682        window: &mut Window,
683        cx: &mut Context<Self>,
684    ) {
685        // A newer menu replaces an open one, and the column chooser (one
686        // overlay at a time).
687        let replaced = self.menu.take().is_some();
688        self.drop_column_chooser();
689        let removed = self.is_target_gone(&target);
690        if !removed {
691            self.show_menu(target, position, window, cx);
692        }
693        if replaced && self.menu.is_none() {
694            self.reconsider_held(cx);
695        }
696        cx.notify();
697    }
698
699    fn show_menu(
700        &mut self,
701        target: GridTarget<KeyOf<D>, ColumnOf<D>>,
702        position: Point<Pixels>,
703        window: &mut Window,
704        cx: &mut Context<Self>,
705    ) {
706        // The scope of the target's line: a caption's group rows, a cell's
707        // as the selection policy says, else the scoped selection.
708        let selection = self.command_scope(target.line().as_ref());
709        // A caption's or a tree row's state.
710        let expanded = self.target_expansion(&target);
711        let column = target.column().map(|column| {
712            let ix = column.ix();
713            MenuColumn {
714                sortable: self.columns.column(ix).sortable,
715                direction: self
716                    .view
717                    .sort()
718                    .iter()
719                    .find(|(sorted, _)| *sorted == column)
720                    .map(|(_, direction)| *direction),
721                zone: self.columns.zone_of(ix),
722                last_visible: self.columns.is_visible(ix) && self.columns.visible_set().len() <= 1,
723                hideable: self.columns.column(ix).hideable,
724            }
725        });
726        // Internal columns are never shown, so they count as none.
727        let any_hidden = self.columns.any_hidden();
728        let sorted = !self.view.sort().is_empty();
729        let grouping = self.menu_grouping(target.column());
730        let snapshot = self.snapshot.clone();
731        let projection = self.projection.clone();
732        let focus = self.focus_handle.clone();
733        let context = GridMenuContext {
734            target,
735            selection,
736            snapshot: &snapshot,
737            projection: &projection,
738            expanded,
739            expand_depth: self.expand_depth(),
740            column,
741            any_hidden,
742            sorted,
743            grouping,
744            focus_handle: focus.clone(),
745        };
746        let primary = match &context.target {
747            GridTarget::Cell { .. } | GridTarget::GroupCaption { .. }
748                if !context.selection.is_empty() =>
749            {
750                self.delegate.primary_command(&context, cx)
751            }
752            GridTarget::Cell { .. }
753            | GridTarget::GroupCaption { .. }
754            | GridTarget::Pinned { .. }
755            | GridTarget::Header { .. }
756            | GridTarget::GroupFooter { .. }
757            | GridTarget::Totals { .. }
758            | GridTarget::Empty => None,
759        };
760        let delegate = &self.delegate;
761        let menu = PopupMenu::build(window, cx, |menu, window, cx| {
762            let menu = menu
763                .action_context(focus.clone())
764                .when_some(primary, |menu, label| {
765                    menu.menu_element(
766                        Box::new(Confirm { secondary: false }),
767                        primary_item(label, focus.clone()),
768                    )
769                    .separator()
770                });
771            delegate.context_menu(&context, menu, window, cx)
772        });
773        if menu.read(cx).is_empty() {
774            return;
775        }
776        self.install_menu(menu, context.target, position, window, cx);
777    }
778
779    /// Shows a built menu as the open menu: focuses it and closes it on its
780    /// dismissal. The group panel opens its own menu here too, with the
781    /// `Empty` target, so `is_menu_open()` holds the order while it is open.
782    pub(super) fn install_menu(
783        &mut self,
784        menu: Entity<PopupMenu>,
785        target: GridTarget<KeyOf<D>, ColumnOf<D>>,
786        position: Point<Pixels>,
787        window: &mut Window,
788        cx: &mut Context<Self>,
789    ) {
790        let menu_focus = menu.read(cx).focus_handle(cx);
791        window.focus(&menu_focus, cx);
792        let dismiss = cx.subscribe_in(&menu, window, Self::on_menu_dismiss);
793        self.menu = Some(OpenMenu {
794            entity: menu,
795            target,
796            position,
797            _dismiss: dismiss,
798        });
799    }
800
801    /// Escape, an item or a press outside closed the menu (`PopupMenu`
802    /// gives the focus back to the grid): the reorder policy is asked again,
803    /// as when the pointer leaves.
804    fn on_menu_dismiss(
805        &mut self,
806        menu: &Entity<PopupMenu>,
807        _: &DismissEvent,
808        _: &mut Window,
809        cx: &mut Context<Self>,
810    ) {
811        if self.menu.as_ref().is_some_and(|open| open.entity == *menu) {
812            self.menu = None;
813            self.reconsider_held(cx);
814            cx.notify();
815        }
816    }
817
818    /// Returns true if the row of a target left the store: a data row its
819    /// rows, a pinned row its band; if a caption's group left the shown
820    /// projection; or if a footer or the totals row is no longer shown, or
821    /// its column is hidden.
822    pub(super) fn is_target_gone(&self, target: &GridTarget<KeyOf<D>, ColumnOf<D>>) -> bool {
823        match target {
824            GridTarget::Cell { key, .. } => self.snapshot.get(key).is_none(),
825            GridTarget::Pinned { edge, key, .. } => !self.is_pinned_key(*edge, key),
826            GridTarget::GroupCaption { group, .. } => {
827                self.projection.groups().find(group).is_none()
828            }
829            GridTarget::GroupFooter { group, column } => {
830                !self.columns.is_visible(column.ix()) || !self.is_footer_shown(group)
831            }
832            GridTarget::Totals { edge, column } => {
833                !self.columns.is_visible(column.ix()) || self.totals() != Some(*edge)
834            }
835            GridTarget::Header { .. } | GridTarget::Empty => false,
836        }
837    }
838
839    /// Closes the menu whose target row left the store, or left its band,
840    /// the menu of a caption whose group left the shown projection, and the
841    /// menu of a footer or the totals that are no longer shown: an overlay
842    /// action must refer to an object the overlay shows (Design Guides).
843    /// Called in `render`, which has the window to give the focus back. A
844    /// row hidden by a filter is still in the store: its menu stays.
845    pub(super) fn close_stale_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
846        let stale = self
847            .menu
848            .as_ref()
849            .is_some_and(|open| self.is_target_gone(&open.target));
850        if !stale {
851            return;
852        }
853        if let Some(open) = self.menu.take()
854            && open
855                .entity
856                .read(cx)
857                .focus_handle(cx)
858                .contains_focused(window, cx)
859        {
860            window.focus(&self.focus_handle, cx);
861        }
862        // Not while rendering: a held order may be shown now.
863        cx.defer_in(window, |state, _, cx| state.reconsider_held(cx));
864    }
865
866    /// The layer of the open menu: over the whole window, so a wheel over
867    /// the rows does not scroll the target away, with the menu at its
868    /// position, kept 8 px inside the window.
869    pub(super) fn render_menu(&self, window: &Window) -> Option<impl IntoElement + use<D>> {
870        let open = self.menu.as_ref()?;
871        let viewport = window.viewport_size();
872        Some(
873            deferred(
874                anchored().position(point(px(0.), px(0.))).child(
875                    div()
876                        .w(viewport.width)
877                        .h(viewport.height)
878                        .on_scroll_wheel(|_, _, cx| cx.stop_propagation())
879                        .child(
880                            anchored()
881                                .position(open.position)
882                                .snap_to_window_with_margin(px(8.))
883                                .child(open.entity.clone()),
884                        ),
885                ),
886            )
887            .with_priority(POPUP_PRIORITY),
888        )
889    }
890
891    /// `ShowContextMenu`.
892    pub(super) fn on_show_context_menu(
893        &mut self,
894        _: &actions::ShowContextMenu,
895        window: &mut Window,
896        cx: &mut Context<Self>,
897    ) {
898        self.show_context_menu(window, cx);
899    }
900
901    /// `SortAscending`: the current column alone, ascending.
902    pub(super) fn on_sort_ascending(
903        &mut self,
904        _: &actions::SortAscending,
905        _: &mut Window,
906        cx: &mut Context<Self>,
907    ) {
908        self.sort_current_column(Some(SortDirection::Ascending), cx);
909    }
910
911    /// `SortDescending`: the current column alone, descending.
912    pub(super) fn on_sort_descending(
913        &mut self,
914        _: &actions::SortDescending,
915        _: &mut Window,
916        cx: &mut Context<Self>,
917    ) {
918        self.sort_current_column(Some(SortDirection::Descending), cx);
919    }
920
921    /// `ClearSort`: the current column leaves the sort; the others stay.
922    pub(super) fn on_clear_sort(
923        &mut self,
924        _: &actions::ClearSort,
925        _: &mut Window,
926        cx: &mut Context<Self>,
927    ) {
928        self.sort_current_column(None, cx);
929    }
930
931    /// `ClearAllSorting`: no column stays in the sort.
932    pub(super) fn on_clear_all_sorting(
933        &mut self,
934        _: &actions::ClearAllSorting,
935        _: &mut Window,
936        cx: &mut Context<Self>,
937    ) {
938        self.apply_sort([], SortOrigin::User, cx);
939    }
940
941    /// The sort commands of the menu: a user action, like a header click,
942    /// so a column that is not sortable is left alone.
943    fn sort_current_column(&mut self, direction: Option<SortDirection>, cx: &mut Context<Self>) {
944        let Some(column) = self.current_column() else {
945            return;
946        };
947        if !self.columns.column(column.ix()).sortable {
948            return;
949        }
950        let sort: Vec<(ColumnOf<D>, SortDirection)> = match direction {
951            Some(direction) => vec![(column, direction)],
952            None => self
953                .view
954                .sort()
955                .iter()
956                .copied()
957                .filter(|(sorted, _)| *sorted != column)
958                .collect(),
959        };
960        self.apply_sort(sort, SortOrigin::User, cx);
961    }
962}