Skip to main content

gpui_kit_grid/state/
group_panel.rs

1//! The group panel: a strip above the header with the grouping levels as
2//! chips. The user drags a column header into it to group by the column,
3//! drags a chip to reorder the levels or out onto the grid to remove one,
4//! clicks a chip to reverse its groups and removes it with its button; the
5//! same commands are in the chip's menu, in the column menu
6//! (`grouping_items`) and Actions.
7//!
8//! The panel shows only when `LiveGrid::group_panel(true)` asks for it. It
9//! is one header row tall, inside the grid root and above the header, in
10//! the key context [`GROUP_PANEL_KEY_CONTEXT`]. Every change goes through
11//! `apply_group_by(.., GroupOrigin::User)`: one `GroupChange` per gesture.
12//!
13//! Element ids: the panel `"live-grid-group-panel"`; a chip
14//! `NamedChild("live-grid-group-chip", level id)` and its remove button
15//! `NamedChild("live-grid-group-chip-remove", level id)`; the hint
16//! `"live-grid-group-panel-hint"`, the insertion line
17//! `"live-grid-group-panel-gap"`.
18//!
19//! Accessibility: the panel is a `Toolbar` named "Grouping" (the hint is its
20//! description while nothing is grouped); a chip is a `Button` named "Title,
21//! Sorted ascending" with its level's position; the remove button is a
22//! `Button` named "Remove grouping by Title"; separators have no node.
23//!
24//! Keyboard: chips and their remove buttons are Tab stops, after the grid's
25//! own. Enter and Space press them (the grid's Enter and Space are unbound
26//! in the panel); Delete and Backspace remove the focused chip's level
27//! (`UngroupColumn`) and pass the focus to the next chip; Shift+F10 and the
28//! Menu key open the chip's menu (`ShowContextMenu`).
29
30use std::sync::Arc;
31
32use gpui_kit::base::TestSupportExt as _;
33use gpui_kit::component::{
34    ActiveTheme as _, Icon, IconName, Sizable as _, Size as ComponentSize, ThemeStyled as _,
35    button::{Button, ButtonVariants as _},
36    menu::{PopupMenu, PopupMenuItem},
37    tag::Tag,
38};
39use gpui_kit::{
40    AnyElement, AppContext as _, Bounds, ClickEvent, Context, CursorStyle, Div, DragMoveEvent,
41    ElementId, EntityId, FocusHandle, InteractiveElement, IntoElement, MouseButton, MouseDownEvent,
42    ParentElement as _, Pixels, Point, Render, Role, SharedString, StatefulInteractiveElement as _,
43    Styled as _, WeakEntity, Window, div, point, prelude::FluentBuilder as _, px, size,
44};
45use rust_i18n::t;
46
47use super::{
48    CELL_PADDING, ColumnOf, GridMenuContext, GridTarget, LiveGridDelegate, LiveGridState,
49    columns::column_of, events::GroupOrigin, ghost::GhostTextStyle, header::DragSource,
50};
51use crate::{
52    actions::{
53        ClearGrouping, CollapseAll, ExpandAll, GROUP_PANEL_KEY_CONTEXT, GroupByColumn,
54        ShowCaptionsOnly, ShowContextMenu, UngroupColumn,
55    },
56    engine::{ExpandDepth, GroupBy, SortDirection},
57    model::{ColumnId, ColumnIx, GridColumns, GridRow},
58    policy::GridArea,
59};
60
61/// The id of the panel element.
62const PANEL_ID: &str = "live-grid-group-panel";
63/// The id prefix of a chip, named by its level id.
64const CHIP_ID: &str = "live-grid-group-chip";
65/// The id prefix of a chip's remove button, named by its level id.
66const CHIP_REMOVE_ID: &str = "live-grid-group-chip-remove";
67/// The id of the hint shown while nothing is grouped.
68const HINT_ID: &str = "live-grid-group-panel-hint";
69/// The id of the insertion line of a drag over the panel.
70const GAP_ID: &str = "live-grid-group-panel-gap";
71/// The room between a chip and a separator.
72const CHIP_GAP: Pixels = px(4.);
73/// The insertion line's width, as the header's.
74const GAP_LINE: Pixels = px(2.);
75
76/// The group panel's state.
77#[derive(Default)]
78pub(super) struct GroupPanelState {
79    /// `LiveGrid::group_panel` shows the panel.
80    shown: bool,
81    /// The grid's size; chips are one step smaller.
82    size: ComponentSize,
83    /// The chips' focus handles by level id, for the levels of the last
84    /// frame.
85    chips: Vec<(ColumnId, FocusHandle)>,
86    /// The chips' bounds in the last frame (window coordinates), left to
87    /// right: the drop gaps and the press targets.
88    chip_bounds: Vec<Bounds<Pixels>>,
89    /// What a drop at the pointer would do while a drag is over the panel.
90    target: Option<PanelTarget>,
91    /// The chip drag in progress.
92    dragged: Option<LevelDrag>,
93    /// The last primary press on the panel closed the context menu or
94    /// opened one: its click changes nothing (as a header press).
95    menu_press: bool,
96}
97
98impl GroupPanelState {
99    /// Whether `LiveGrid::group_panel` shows the panel.
100    pub(super) fn is_shown(&self) -> bool {
101        self.shown
102    }
103
104    fn chip_handle(&self, level: &ColumnId) -> Option<&FocusHandle> {
105        self.chips
106            .iter()
107            .find(|(id, _)| id == level)
108            .map(|(_, handle)| handle)
109    }
110}
111
112/// What a drop on the panel would do.
113#[derive(Clone, Copy, Debug, PartialEq)]
114pub(super) enum PanelTarget {
115    /// The level lands in gap `gap` between the chips (0 before the first);
116    /// the insertion line is at `x` from the panel's left edge.
117    Gap { gap: usize, x: Pixels },
118    /// Accepted, but nothing changes: a level dropped where it is.
119    Here,
120    /// Refused: a band, a column that is not groupable, a tree view. The
121    /// "not allowed" cursor shows.
122    Refused,
123}
124
125impl PanelTarget {
126    /// Whether the panel takes the drag (the cursor is not "not allowed").
127    pub(super) fn is_accepted(self) -> bool {
128        self != Self::Refused
129    }
130}
131
132/// A chip drag in progress.
133struct LevelDrag {
134    /// The cursor shown.
135    cursor: Option<CursorStyle>,
136}
137
138/// The payload of a chip drag: renders the ghost as a chip, in the chip's
139/// font.
140#[derive(Clone)]
141pub(crate) struct DraggedGroupLevel {
142    grid: EntityId,
143    level: ColumnId,
144    /// The column's header icon, before the title.
145    icon: Option<SharedString>,
146    title: SharedString,
147    direction: SortDirection,
148    size: ComponentSize,
149    text: GhostTextStyle,
150}
151
152impl Render for DraggedGroupLevel {
153    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
154        let theme = cx.theme();
155        self.text.root().child(
156            Tag::new()
157                .outline()
158                .with_size(self.size)
159                .gap_1()
160                .bg(theme.table_head)
161                .text_color(theme.table_head_foreground)
162                .shadow_md()
163                .opacity(0.9)
164                .children(self.icon.clone().map(level_icon))
165                .child(self.title.clone())
166                .child(Icon::new(direction_icon(self.direction)).size_3()),
167        )
168    }
169}
170
171/// What the grouping items of a column menu know (`grouping_items`).
172#[derive(Clone, Copy, Debug, Default)]
173pub(super) struct MenuGrouping {
174    /// The target column's grouping; `None` without a target column.
175    column: Option<ColumnGrouping>,
176    /// Some level groups the view.
177    grouped: bool,
178}
179
180#[derive(Clone, Copy, Debug)]
181struct ColumnGrouping {
182    /// The user may group by it: it is groupable and the view is no tree.
183    groupable: bool,
184    /// It is a grouping level.
185    level: bool,
186}
187
188impl<'a, R: GridRow> GridMenuContext<'a, R> {
189    /// "Group by this column" (`GroupByColumn`), "Ungroup" (`UngroupColumn`)
190    /// for the target column, then "Ungroup all" (`ClearGrouping`). Items
191    /// that would do nothing are disabled: grouping by a column that is not
192    /// groupable or already a level, or in a tree view (a tree and a
193    /// grouping exclude each other, as in AG Grid and DevExpress);
194    /// ungrouping a column that is no level; ungrouping all without a
195    /// grouping. Without a target column, only "Ungroup all".
196    /// [`Self::column_items`] ends with them.
197    pub fn grouping_items(&self, menu: PopupMenu) -> PopupMenu {
198        grouping_items(self.grouping, menu)
199    }
200}
201
202/// [`GridMenuContext::grouping_items`].
203pub(super) fn grouping_items(grouping: MenuGrouping, menu: PopupMenu) -> PopupMenu {
204    menu.when_some(grouping.column, |menu, column| {
205        menu.menu_with_disabled(
206            label("live_grid.menu.group_by"),
207            Box::new(GroupByColumn),
208            !column.groupable || column.level,
209        )
210        .menu_with_disabled(
211            label("live_grid.menu.ungroup"),
212            Box::new(UngroupColumn),
213            !column.level,
214        )
215    })
216    .menu_with_disabled(
217        label("live_grid.menu.clear_grouping"),
218        Box::new(ClearGrouping),
219        !grouping.grouped,
220    )
221}
222
223/// A text from this crate's catalog.
224fn label(key: &str) -> SharedString {
225    SharedString::from(t!(key).into_owned())
226}
227
228/// The icon of a level's direction, as the header's sort indicator.
229fn direction_icon(direction: SortDirection) -> IconName {
230    match direction {
231        SortDirection::Ascending => IconName::SortAscending,
232        SortDirection::Descending => IconName::SortDescending,
233    }
234}
235
236fn reversed(direction: SortDirection) -> SortDirection {
237    match direction {
238        SortDirection::Ascending => SortDirection::Descending,
239        SortDirection::Descending => SortDirection::Ascending,
240    }
241}
242
243/// A column's header icon on a chip, at the size of the direction icon.
244fn level_icon(path: SharedString) -> Icon {
245    Icon::empty().path(path).size_3()
246}
247
248/// The accessible name of a chip: "Instrument, Sorted ascending".
249fn chip_label(title: &str, direction: SortDirection) -> SharedString {
250    let direction = match direction {
251        SortDirection::Ascending => t!("live_grid.sort.ascending"),
252        SortDirection::Descending => t!("live_grid.sort.descending"),
253    };
254    let text = t!(
255        "live_grid.group_panel.chip",
256        title = title,
257        direction = direction
258    );
259    SharedString::from(text.into_owned())
260}
261
262/// The name and tooltip of a chip's remove button.
263fn remove_label(title: &str) -> SharedString {
264    SharedString::from(t!("live_grid.group_panel.remove", title = title).into_owned())
265}
266
267/// The id of a chip part, named by its level: identity by level, never by
268/// position.
269fn chip_part_id(part: &'static str, level: &ColumnId) -> ElementId {
270    ElementId::NamedChild(
271        Arc::new(ElementId::Name(part.into())),
272        SharedString::from(level.as_str().to_owned()),
273    )
274}
275
276/// The remove button's size inside a chip of `size`: it fits the chip's
277/// text line, so the chip stays inside the panel's row.
278fn remove_button_size(size: ComponentSize) -> ComponentSize {
279    match size {
280        ComponentSize::Large | ComponentSize::Medium => ComponentSize::Small,
281        _ => ComponentSize::XSmall,
282    }
283}
284
285impl<D: LiveGridDelegate> LiveGridState<D> {
286    /// Shows or hides the group panel, for chips of one step below `size`
287    /// (from the [`crate::LiveGrid`] flag and size).
288    pub(crate) fn set_group_panel_shown(
289        &mut self,
290        shown: bool,
291        size: ComponentSize,
292        cx: &mut Context<Self>,
293    ) {
294        let panel = &mut self.group_panel;
295        if panel.shown == shown && panel.size == size {
296            return;
297        }
298        panel.shown = shown;
299        panel.size = size;
300        panel.target = None;
301        // The body gets another height: the next frame lays it out.
302        cx.notify();
303    }
304
305    /// Whether the group panel shows (`LiveGrid::group_panel`), as of the
306    /// last `LiveGrid` render.
307    #[cfg(any(test, feature = "test-support"))]
308    pub fn is_group_panel_shown(&self) -> bool {
309        self.group_panel.shown
310    }
311
312    /// The group panel's height: one header row, or zero when hidden.
313    pub(super) fn group_panel_height(&self) -> Pixels {
314        if self.group_panel.shown {
315            self.row_height
316        } else {
317            px(0.)
318        }
319    }
320
321    /// The panel's window bounds: above the header, as wide as the body;
322    /// arithmetic from the body's bounds, as the header's hit test.
323    fn group_panel_bounds(&self) -> Option<Bounds<Pixels>> {
324        let height = self.group_panel_height();
325        (height > px(0.)).then(|| {
326            let body = self.body_bounds;
327            Bounds::new(
328                point(body.origin.x, body.origin.y - self.header_height() - height),
329                size(body.size.width, height),
330            )
331        })
332    }
333
334    /// Whether a window position is over the shown group panel.
335    pub(super) fn is_over_group_panel(&self, position: Point<Pixels>) -> bool {
336        self.group_panel_bounds()
337            .is_some_and(|bounds| bounds.contains(&position))
338    }
339
340    /// Whether the user may group by a column now: it is groupable and the
341    /// view is no tree. `set_group_by` groups by any column.
342    fn accepts_user_grouping(&self, column: ColumnIx) -> bool {
343        self.tree().is_none()
344            && self
345                .columns
346                .get(column)
347                .is_some_and(|column| column.groupable)
348    }
349
350    /// The position of a level among the grouping levels.
351    fn level_ix(&self, level: &ColumnId) -> Option<usize> {
352        self.view
353            .group_by()
354            .iter()
355            .position(|kept| kept.id() == *level)
356    }
357
358    /// The grouping of a column menu's target column, for `show_menu`.
359    pub(super) fn menu_grouping(&self, column: Option<ColumnOf<D>>) -> MenuGrouping {
360        MenuGrouping {
361            column: column.map(|column| ColumnGrouping {
362                groupable: self.accepts_user_grouping(column.ix()),
363                level: self.level_ix(&column.column_id()).is_some(),
364            }),
365            grouped: !self.view.group_by().is_empty(),
366        }
367    }
368
369    /// Edits a copy of the levels and applies it as a user action; `edit`
370    /// returns false to leave them. Returns true if the grouping changed
371    /// (one `GroupChange { origin: User }`).
372    fn edit_levels(
373        &mut self,
374        edit: impl FnOnce(&mut Vec<GroupBy<D::Row>>) -> bool,
375        cx: &mut Context<Self>,
376    ) -> bool {
377        let mut levels = self.view.group_by().to_vec();
378        edit(&mut levels) && self.apply_group_by(levels, GroupOrigin::User, cx)
379    }
380
381    /// Puts `column` into gap `gap` of the levels, ascending; a column that
382    /// is a level already moves there with its direction.
383    fn insert_column_level(&mut self, column: ColumnOf<D>, gap: usize, cx: &mut Context<Self>) {
384        let id = column.column_id();
385        self.edit_levels(
386            |levels| {
387                let (level, gap) = match levels.iter().position(|level| level.id() == id) {
388                    Some(ix) => (levels.remove(ix), if gap > ix { gap - 1 } else { gap }),
389                    None => (GroupBy::column(column), gap),
390                };
391                levels.insert(gap.min(levels.len()), level);
392                true
393            },
394            cx,
395        );
396    }
397
398    /// Moves a level into gap `gap` of the levels as they are now.
399    fn move_level_to(&mut self, level: &ColumnId, gap: usize, cx: &mut Context<Self>) {
400        let Some(ix) = self.level_ix(level) else {
401            return;
402        };
403        self.edit_levels(
404            |levels| {
405                let moved = levels.remove(ix);
406                let gap = if gap > ix { gap - 1 } else { gap };
407                levels.insert(gap.min(levels.len()), moved);
408                true
409            },
410            cx,
411        );
412    }
413
414    /// Moves a level one place left (`-1`) or right (`1`).
415    fn move_level_by(&mut self, level: &ColumnId, step: isize, cx: &mut Context<Self>) {
416        let Some(ix) = self.level_ix(level) else {
417            return;
418        };
419        let Some(other) = ix.checked_add_signed(step) else {
420            return;
421        };
422        self.edit_levels(
423            |levels| {
424                let swapped = other < levels.len();
425                if swapped {
426                    levels.swap(ix, other);
427                }
428                swapped
429            },
430            cx,
431        );
432    }
433
434    /// Sets the direction of a level's groups; `None` reverses it.
435    fn set_level_direction(
436        &mut self,
437        level: &ColumnId,
438        direction: Option<SortDirection>,
439        cx: &mut Context<Self>,
440    ) {
441        let Some(ix) = self.level_ix(level) else {
442            return;
443        };
444        self.edit_levels(
445            |levels| {
446                let current = levels[ix].direction();
447                let direction = direction.unwrap_or_else(|| reversed(current));
448                levels[ix] = levels[ix].clone().with_direction(direction);
449                direction != current
450            },
451            cx,
452        );
453    }
454
455    /// Takes a level out of the grouping; returns false if it was none.
456    fn remove_level(&mut self, level: &ColumnId, cx: &mut Context<Self>) -> bool {
457        self.edit_levels(
458            |levels| {
459                let count = levels.len();
460                levels.retain(|kept| kept.id() != *level);
461                levels.len() != count
462            },
463            cx,
464        )
465    }
466
467    /// [`Self::remove_level`] from the panel. With `refocus`, the focus passes
468    /// to the chip on the right, else on the left, else to the grid: the
469    /// removed chip had it (a key on the chip or its remove button).
470    fn ungroup_level(
471        &mut self,
472        level: &ColumnId,
473        refocus: bool,
474        window: &mut Window,
475        cx: &mut Context<Self>,
476    ) {
477        let Some(ix) = self.level_ix(level) else {
478            return;
479        };
480        let levels = self.view.group_by();
481        let neighbour = levels
482            .get(ix + 1)
483            .or_else(|| ix.checked_sub(1).and_then(|left| levels.get(left)))
484            .map(GroupBy::id);
485        let removed = self.remove_level(level, cx);
486        if removed && refocus {
487            let handle = neighbour
488                .and_then(|id| self.group_panel.chip_handle(&id))
489                .unwrap_or(&self.focus_handle)
490                .clone();
491            window.focus(&handle, cx);
492        }
493    }
494
495    // The grouping Actions on the grid root: over `current_column()`. On a
496    // chip, its own `UngroupColumn` handler runs first and keeps the Action.
497
498    fn on_group_by_column(&mut self, _: &GroupByColumn, _: &mut Window, cx: &mut Context<Self>) {
499        let Some(column) = self.current_column() else {
500            return;
501        };
502        if !self.accepts_user_grouping(column.ix()) {
503            return;
504        }
505        let id = column.column_id();
506        self.edit_levels(
507            |levels| {
508                let added = levels.iter().all(|level| level.id() != id);
509                if added {
510                    levels.push(GroupBy::column(column));
511                }
512                added
513            },
514            cx,
515        );
516    }
517
518    fn on_ungroup_column(&mut self, _: &UngroupColumn, _: &mut Window, cx: &mut Context<Self>) {
519        let Some(column) = self.current_column() else {
520            return;
521        };
522        self.remove_level(&column.column_id(), cx);
523    }
524
525    fn on_clear_grouping(&mut self, _: &ClearGrouping, _: &mut Window, cx: &mut Context<Self>) {
526        self.edit_levels(
527            |levels| {
528                let grouped = !levels.is_empty();
529                levels.clear();
530                grouped
531            },
532            cx,
533        );
534    }
535
536    /// Binds the grouping Actions and the chip drags to the grid root: a
537    /// chip dropped on the header or the body removes its level.
538    pub(super) fn bind_group_panel<E: InteractiveElement>(root: E, cx: &Context<Self>) -> E {
539        root.on_action(cx.listener(Self::on_group_by_column))
540            .on_action(cx.listener(Self::on_ungroup_column))
541            .on_action(cx.listener(Self::on_clear_grouping))
542            .on_drag_move(cx.listener(Self::on_level_drag_move))
543            .on_drop(cx.listener(Self::on_level_drop))
544    }
545
546    // Drops on the panel.
547
548    /// The gap nearest to window `x` between the chips (by their centres)
549    /// and its insertion line from the panel's left edge. Without chips (or
550    /// before they are painted) the one gap is at the end.
551    fn panel_gap_at(&self, x: Pixels) -> (usize, Pixels) {
552        let count = self.view.group_by().len();
553        let chips = &self.group_panel.chip_bounds;
554        let left = self.body_bounds.origin.x;
555        if chips.len() != count || count == 0 {
556            let x = chips
557                .last()
558                .map_or(CELL_PADDING, |chip| chip.right() + CHIP_GAP / 2. - left);
559            return (count, x);
560        }
561        let gap = chips.iter().take_while(|chip| x >= chip.center().x).count();
562        let line = match gap {
563            0 => chips[0].left() - CHIP_GAP / 2.,
564            gap if gap == count => chips[count - 1].right() + CHIP_GAP / 2.,
565            gap => (chips[gap - 1].right() + chips[gap].left()) / 2.,
566        };
567        (gap, line - left)
568    }
569
570    /// What dropping a header drag at `position` over the panel does: a
571    /// column the user may group by lands in a gap; a band, a column that
572    /// is not groupable and a tree view are refused.
573    fn panel_column_target(&self, source: DragSource, position: Point<Pixels>) -> PanelTarget {
574        let DragSource::Column(ix) = source else {
575            return PanelTarget::Refused;
576        };
577        if !self.accepts_user_grouping(ix) {
578            return PanelTarget::Refused;
579        }
580        let (gap, x) = self.panel_gap_at(position.x);
581        match self.level_ix(&column_of::<ColumnOf<D>>(ix).column_id()) {
582            Some(level) if gap == level || gap == level + 1 => PanelTarget::Here,
583            _ => PanelTarget::Gap { gap, x },
584        }
585    }
586
587    /// What dropping a chip at `position` over the panel does: its level
588    /// moves to a gap; its own place changes nothing.
589    fn panel_level_target(&self, level: &ColumnId, position: Point<Pixels>) -> PanelTarget {
590        let Some(ix) = self.level_ix(level) else {
591            return PanelTarget::Refused;
592        };
593        let (gap, x) = self.panel_gap_at(position.x);
594        if gap == ix || gap == ix + 1 {
595            PanelTarget::Here
596        } else {
597            PanelTarget::Gap { gap, x }
598        }
599    }
600
601    fn set_panel_target(&mut self, target: Option<PanelTarget>, cx: &mut Context<Self>) {
602        if self.group_panel.target != target {
603            self.group_panel.target = target;
604            cx.notify();
605        }
606    }
607
608    /// Follows a header drag at `position` (`None`: outside the grid).
609    /// Returns what a drop there would do on the panel, or `None` if the
610    /// pointer is not over it: then the header drag moves the column.
611    pub(super) fn refresh_panel_column_target(
612        &mut self,
613        source: DragSource,
614        position: Option<Point<Pixels>>,
615        cx: &mut Context<Self>,
616    ) -> Option<PanelTarget> {
617        let target = position
618            .filter(|position| self.is_over_group_panel(*position))
619            .map(|position| self.panel_column_target(source, position));
620        self.set_panel_target(target, cx);
621        target
622    }
623
624    /// A header dropped on the panel: its column becomes a level in the gap
625    /// (or its level moves there); the column itself does not move.
626    pub(super) fn drop_column_on_group_panel(
627        &mut self,
628        source: DragSource,
629        position: Point<Pixels>,
630        cx: &mut Context<Self>,
631    ) {
632        if let (PanelTarget::Gap { gap, .. }, DragSource::Column(ix)) =
633            (self.panel_column_target(source, position), source)
634        {
635            self.insert_column_level(column_of(ix), gap, cx);
636        }
637    }
638
639    /// No drag is over the panel any more.
640    pub(super) fn clear_panel_target(&mut self) {
641        self.group_panel.target = None;
642    }
643
644    // Chip drags.
645
646    /// A chip drag started (the payload's constructor): the pointer stops
647    /// counting as over the rows, as in a header drag.
648    fn begin_level_drag(&mut self, cx: &mut Context<Self>) {
649        self.group_panel.dragged = Some(LevelDrag { cursor: None });
650        self.column_gesture_started(cx);
651        cx.notify();
652    }
653
654    fn end_level_drag(&mut self) {
655        self.group_panel.dragged = None;
656        self.group_panel.target = None;
657    }
658
659    /// Whether a chip is being dragged.
660    pub(super) fn is_level_dragged(&self) -> bool {
661        self.group_panel.dragged.is_some()
662    }
663
664    fn on_level_drag_move(
665        &mut self,
666        event: &DragMoveEvent<DraggedGroupLevel>,
667        window: &mut Window,
668        cx: &mut Context<Self>,
669    ) {
670        let drag = event.drag(cx);
671        if drag.grid != cx.entity_id() {
672            return;
673        }
674        let level = drag.level.clone();
675        if self.group_panel.dragged.is_none() {
676            self.begin_level_drag(cx);
677        }
678        let position = event.event.position;
679        let inside = event.bounds.contains(&position);
680        let target = (inside && self.is_over_group_panel(position))
681            .then(|| self.panel_level_target(&level, position));
682        self.set_panel_target(target, cx);
683        // On the grid the chip moves or goes; outside it nothing happens.
684        let cursor = if inside {
685            CursorStyle::ClosedHand
686        } else {
687            CursorStyle::OperationNotAllowed
688        };
689        if let Some(dragged) = &mut self.group_panel.dragged
690            && dragged.cursor != Some(cursor)
691        {
692            dragged.cursor = Some(cursor);
693            cx.set_active_drag_cursor_style(cursor, window);
694        }
695    }
696
697    /// A chip dropped on its grid: in the panel its level moves to the gap;
698    /// on the header or the body its level goes (as in Syncfusion). Dropped
699    /// outside the grid, nothing happens.
700    fn on_level_drop(
701        &mut self,
702        drag: &DraggedGroupLevel,
703        window: &mut Window,
704        cx: &mut Context<Self>,
705    ) {
706        if drag.grid != cx.entity_id() {
707            return;
708        }
709        let position = window.mouse_position();
710        self.end_level_drag();
711        if self.is_over_group_panel(position) {
712            if let PanelTarget::Gap { gap, .. } = self.panel_level_target(&drag.level, position) {
713                self.move_level_to(&drag.level, gap, cx);
714            }
715        } else {
716            self.ungroup_level(&drag.level, false, window, cx);
717        }
718        cx.notify();
719    }
720
721    /// The left button went up anywhere: a chip drag ends in the drop
722    /// handler, which runs later in this dispatch, or here after it.
723    pub(super) fn on_level_drag_mouse_up(&mut self, cx: &mut Context<Self>) {
724        if self.group_panel.dragged.is_none() {
725            return;
726        }
727        let this = cx.weak_entity();
728        cx.defer(move |cx| {
729            let _ = this.update(cx, |state, cx| {
730                if state.group_panel.dragged.is_some() {
731                    state.end_level_drag();
732                    cx.notify();
733                }
734            });
735        });
736    }
737
738    // Chip gestures and the panel's menu.
739
740    /// A click on a chip (or Enter and Space on it) reverses its level.
741    fn on_chip_click(&mut self, level: &ColumnId, event: &ClickEvent, cx: &mut Context<Self>) {
742        if !event.standard_click() || (self.group_panel.menu_press && !event.is_keyboard()) {
743            return;
744        }
745        self.set_level_direction(level, None, cx);
746    }
747
748    /// The remove button: from the keyboard, the focus moves on.
749    fn on_chip_remove(
750        &mut self,
751        level: &ColumnId,
752        event: &ClickEvent,
753        window: &mut Window,
754        cx: &mut Context<Self>,
755    ) {
756        if self.group_panel.menu_press && !event.is_keyboard() {
757            return;
758        }
759        self.ungroup_level(level, event.is_keyboard(), window, cx);
760    }
761
762    /// A press on the panel. One the [`ContextMenuGesture`] takes (the
763    /// panel counts as the header) opens the menu of the chip under it, or
764    /// the panel's; a primary one focuses the grid, so a chip never takes
765    /// the focus from a click (desktop buttons do not).
766    ///
767    /// [`ContextMenuGesture`]: crate::ContextMenuGesture
768    fn on_group_panel_mouse_down(
769        &mut self,
770        event: &MouseDownEvent,
771        window: &mut Window,
772        cx: &mut Context<Self>,
773    ) {
774        let primary = event.button == MouseButton::Left;
775        if primary {
776            self.group_panel.menu_press = self.is_menu_open();
777            if self.group_panel.menu_press {
778                return;
779            }
780        }
781        if self.is_context_press(
782            event.button,
783            event.modifiers,
784            event.click_count,
785            GridArea::Header,
786        ) {
787            self.group_panel.menu_press = primary;
788            window.focus(&self.focus_handle, cx);
789            let level = self.chip_at(event.position);
790            self.open_group_panel_menu(level, event.position, window, cx);
791            return;
792        }
793        if primary && !self.is_menu_open() {
794            window.focus(&self.focus_handle, cx);
795        }
796    }
797
798    /// The level of the chip at a window position in the last frame.
799    fn chip_at(&self, position: Point<Pixels>) -> Option<ColumnId> {
800        let levels = self.view.group_by();
801        let chips = &self.group_panel.chip_bounds;
802        if chips.len() != levels.len() {
803            return None;
804        }
805        chips
806            .iter()
807            .position(|chip| chip.contains(&position))
808            .map(|ix| levels[ix].id())
809    }
810
811    /// Shift+F10 or the Menu key on a chip: its menu, below it.
812    fn show_chip_menu(&mut self, level: &ColumnId, window: &mut Window, cx: &mut Context<Self>) {
813        let position = self
814            .level_ix(level)
815            .and_then(|ix| self.group_panel.chip_bounds.get(ix))
816            .map(Bounds::bottom_left)
817            .or_else(|| self.group_panel_bounds().map(|panel| panel.bottom_left()))
818            .unwrap_or_default();
819        self.open_group_panel_menu(Some(level.clone()), position, window, cx);
820    }
821
822    /// Opens the menu of a chip (`Some` level) or of the panel after the
823    /// current event, in the grid's menu layer.
824    fn open_group_panel_menu(
825        &mut self,
826        level: Option<ColumnId>,
827        position: Point<Pixels>,
828        window: &mut Window,
829        cx: &mut Context<Self>,
830    ) {
831        let this = cx.weak_entity();
832        window.defer(cx, move |window, cx| {
833            let _ = this.update(cx, |state, cx| {
834                state.build_group_panel_menu(level, position, window, cx)
835            });
836        });
837    }
838
839    /// Builds the panel's own menu, not the delegate's `context_menu`: for a
840    /// chip, its direction, order and removal; for both, Ungroup all and the
841    /// expansion of every group. A chip whose level went meanwhile opens no
842    /// menu.
843    fn build_group_panel_menu(
844        &mut self,
845        level: Option<ColumnId>,
846        position: Point<Pixels>,
847        window: &mut Window,
848        cx: &mut Context<Self>,
849    ) {
850        let replaced = self.menu.take().is_some();
851        let levels = self.view.group_by();
852        let chip = match level {
853            Some(level) => match self.level_ix(&level) {
854                Some(ix) => Some((ix, level, levels[ix].direction())),
855                None => {
856                    if replaced {
857                        self.reconsider_held(cx);
858                    }
859                    cx.notify();
860                    return;
861                }
862            },
863            None => None,
864        };
865        let count = levels.len();
866        let captions_only = self.expand_depth() == ExpandDepth::Captions;
867        let focus = self.focus_handle.clone();
868        let this = cx.weak_entity();
869        let menu = PopupMenu::build(window, cx, move |menu, _, _| {
870            panel_menu_items(
871                menu.action_context(focus),
872                &this,
873                chip,
874                count,
875                captions_only,
876            )
877        });
878        self.install_menu(menu, GridTarget::Empty, position, window, cx);
879        cx.notify();
880    }
881
882    // Rendering.
883
884    /// Keeps a focus handle per level, created once and dropped with the
885    /// level, so the Tab order and the focus survive frames.
886    fn sync_chip_handles(&mut self, cx: &mut Context<Self>) {
887        let levels = self.view.group_by();
888        let chips = &mut self.group_panel.chips;
889        chips.retain(|(id, _)| levels.iter().any(|level| level.id() == *id));
890        for level in levels {
891            let id = level.id();
892            if chips.iter().all(|(kept, _)| *kept != id) {
893                chips.push((id, cx.focus_handle().tab_stop(true)));
894            }
895        }
896    }
897
898    /// A level's header icon: its column's, if the header shows one.
899    fn level_icon(&self, level: &GroupBy<D::Row>) -> Option<SharedString> {
900        let column = level.as_column()?;
901        self.columns.get(column.ix())?.icon().cloned()
902    }
903
904    /// A level's title: its column's, or its key's (`GroupKey::title`).
905    fn level_title(&self, level: &GroupBy<D::Row>) -> SharedString {
906        match (level.as_column(), level.as_key()) {
907            (Some(column), _) => self.columns.column(column.ix()).title.clone(),
908            (None, Some(key)) => SharedString::from(key.title().to_owned()),
909            (None, None) => SharedString::default(),
910        }
911    }
912
913    /// The group panel, if `LiveGrid` shows it.
914    pub(super) fn render_group_panel(
915        &mut self,
916        window: &mut Window,
917        cx: &mut Context<Self>,
918    ) -> Option<impl IntoElement + use<D>> {
919        if !self.group_panel.shown {
920            return None;
921        }
922        self.sync_chip_handles(cx);
923        let theme = cx.theme();
924        let levels = self.view.group_by();
925        let count = levels.len();
926        let height = self.group_panel_height();
927        let hint = (count == 0).then(|| {
928            if self.tree().is_some() {
929                label("live_grid.group_panel.tree")
930            } else {
931                label("live_grid.group_panel.hint")
932            }
933        });
934        let mut chips: Vec<AnyElement> = Vec::with_capacity(count * 2);
935        for (ix, level) in levels.iter().enumerate() {
936            if ix > 0 {
937                // The level on the right is nested in the one on the left.
938                chips.push(
939                    Icon::new(IconName::ChevronRight)
940                        .size_3()
941                        .text_color(theme.muted_foreground)
942                        .into_any_element(),
943                );
944            }
945            chips.push(
946                self.render_chip(ix, level, count, window, cx)
947                    .into_any_element(),
948            );
949        }
950        let this = cx.weak_entity();
951        let row = div()
952            .flex()
953            .flex_none()
954            .items_center()
955            .gap(CHIP_GAP)
956            // Every other child is a chip: their bounds place the drop gaps
957            // and the presses of the next events.
958            .on_children_prepainted(move |bounds, _, cx| {
959                let chips: Vec<Bounds<Pixels>> = bounds.into_iter().step_by(2).collect();
960                let _ = this.update(cx, |state, _| state.group_panel.chip_bounds = chips);
961            })
962            .children(chips);
963        let gap_line = self.panel_gap_line(cx);
964        Some(
965            div()
966                .id(PANEL_ID)
967                .test_support()
968                .key_context(GROUP_PANEL_KEY_CONTEXT)
969                .role(Role::Toolbar)
970                .aria_label(label("live_grid.group_panel.label"))
971                .when_some(hint.clone(), |this, hint| this.aria_description(hint))
972                .relative()
973                .flex_none()
974                .w_full()
975                .h(height)
976                .overflow_hidden()
977                .flex()
978                .items_center()
979                .pl(CELL_PADDING)
980                .bg(theme.table_head)
981                .text_color(theme.table_head_foreground)
982                // The panel owns the line between it and the header (Design
983                // Guides, "Hairlines belong on the boundary owner").
984                .border_b_1()
985                .border_color(theme.border)
986                .on_any_mouse_down(cx.listener(Self::on_group_panel_mouse_down))
987                .child(row)
988                .when_some(hint, |this, hint| {
989                    this.child(
990                        div()
991                            .id(HINT_ID)
992                            .test_support()
993                            .aria_label(hint.clone())
994                            .min_w(px(0.))
995                            .truncate()
996                            .text_color(theme.muted_foreground)
997                            .child(hint),
998                    )
999                })
1000                .when_some(gap_line, |this, (x, top, line_height)| {
1001                    this.child(
1002                        div()
1003                            .id(GAP_ID)
1004                            .test_support()
1005                            .absolute()
1006                            .left(x - GAP_LINE / 2.)
1007                            .top(top)
1008                            .w(GAP_LINE)
1009                            .h(line_height)
1010                            .bg(theme.drag_border),
1011                    )
1012                }),
1013        )
1014    }
1015
1016    /// The insertion line of a drag over the panel, from the panel's top
1017    /// left: `x`, `top`, height, as tall as the chips.
1018    fn panel_gap_line(&self, cx: &gpui_kit::App) -> Option<(Pixels, Pixels, Pixels)> {
1019        let Some(PanelTarget::Gap { x, .. }) = self.group_panel.target else {
1020            return None;
1021        };
1022        if !cx.has_active_drag() {
1023            return None;
1024        }
1025        let panel = self.group_panel_bounds()?;
1026        let inset = px(4.);
1027        let (top, height) = self
1028            .group_panel
1029            .chip_bounds
1030            .first()
1031            .map(|chip| (chip.top() - panel.top(), chip.size.height))
1032            .unwrap_or((inset, panel.size.height - inset * 2.));
1033        Some((x, top, height))
1034    }
1035
1036    /// A chip: a `Tag` one step smaller than the grid with the level's
1037    /// title and direction, focusable and draggable, and its remove button.
1038    /// The chip's actions are on the wrapper, so they reach the level from
1039    /// the remove button too.
1040    fn render_chip(
1041        &self,
1042        ix: usize,
1043        level: &GroupBy<D::Row>,
1044        count: usize,
1045        window: &Window,
1046        cx: &Context<Self>,
1047    ) -> Div {
1048        let theme = cx.theme();
1049        let id = level.id();
1050        let title = self.level_title(level);
1051        let icon = self.level_icon(level);
1052        let direction = level.direction();
1053        let size = self.group_panel.size.smaller();
1054        let handle = self.group_panel.chip_handle(&id).cloned();
1055        let ring = window.last_input_was_keyboard()
1056            && handle
1057                .as_ref()
1058                .is_some_and(|handle| handle.is_focused(window));
1059        let drag = DraggedGroupLevel {
1060            grid: cx.entity_id(),
1061            level: id.clone(),
1062            icon: icon.clone(),
1063            title: title.clone(),
1064            direction,
1065            size,
1066            text: GhostTextStyle::capture(window),
1067        };
1068        let this = cx.weak_entity();
1069        let chip = div()
1070            .id(chip_part_id(CHIP_ID, &id))
1071            .test_support()
1072            .when_some(handle, |this, handle| this.track_focus(&handle))
1073            .role(Role::Button)
1074            .aria_label(chip_label(&title, direction))
1075            .aria_position_in_set(ix + 1)
1076            .aria_size_of_set(count)
1077            .flex()
1078            .items_center()
1079            .gap_1()
1080            // A click leaves the focus where it is (on the grid).
1081            .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
1082            .on_click(cx.listener({
1083                let id = id.clone();
1084                move |state, event: &ClickEvent, _, cx| state.on_chip_click(&id, event, cx)
1085            }))
1086            .on_drag(drag, move |drag: &DraggedGroupLevel, _, _, cx| {
1087                let _ = this.update(cx, |state, cx| state.begin_level_drag(cx));
1088                cx.new(|_| drag.clone())
1089            })
1090            .children(icon.map(level_icon))
1091            .child(title.clone())
1092            .child(Icon::new(direction_icon(direction)).size_3());
1093        let remove = Button::new(chip_part_id(CHIP_REMOVE_ID, &id))
1094            .ghost()
1095            .icon(IconName::Close)
1096            .with_size(remove_button_size(size))
1097            .accessibility_label(remove_label(&title))
1098            .tooltip(remove_label(&title))
1099            .on_click(cx.listener({
1100                let id = id.clone();
1101                move |state, event: &ClickEvent, window, cx| {
1102                    state.on_chip_remove(&id, event, window, cx)
1103                }
1104            }));
1105        div()
1106            .flex_none()
1107            .on_action(cx.listener({
1108                let id = id.clone();
1109                move |state, _: &UngroupColumn, window, cx| {
1110                    state.ungroup_level(&id, true, window, cx)
1111                }
1112            }))
1113            .on_action(cx.listener(move |state, _: &ShowContextMenu, window, cx| {
1114                state.show_chip_menu(&id, window, cx)
1115            }))
1116            .child(
1117                Tag::new()
1118                    .outline()
1119                    .with_size(size)
1120                    .py_0()
1121                    .gap_0p5()
1122                    .text_color(theme.table_head_foreground)
1123                    .when(ring, |this| this.focus_ring_style(window, cx))
1124                    .child(chip)
1125                    .child(remove),
1126            )
1127    }
1128}
1129
1130/// A menu item that runs `command` on the grid.
1131fn command<D: LiveGridDelegate>(
1132    label: SharedString,
1133    this: &WeakEntity<LiveGridState<D>>,
1134    command: impl Fn(&mut LiveGridState<D>, &mut Context<LiveGridState<D>>) + 'static,
1135) -> PopupMenuItem {
1136    let this = this.clone();
1137    PopupMenuItem::new(label).on_click(move |_, _, cx| {
1138        let _ = this.update(cx, |state, cx| command(state, cx));
1139    })
1140}
1141
1142/// The panel's menu: for a chip (`ix`, level, direction), Sort ascending
1143/// and descending (the current one checked), Move left and right, Ungroup;
1144/// then Ungroup all and the expansion of every group, which need a
1145/// grouping. Grouping items run on the grid, `GroupOrigin::User`.
1146///
1147/// Expand all, Collapse all and Captions only (checked while the default
1148/// depth is `ExpandDepth::Captions`) dispatch `ExpandAll`, `CollapseAll` and
1149/// `ShowCaptionsOnly` to the grid, as the caption's menu does.
1150fn panel_menu_items<D: LiveGridDelegate>(
1151    menu: PopupMenu,
1152    this: &WeakEntity<LiveGridState<D>>,
1153    chip: Option<(usize, ColumnId, SortDirection)>,
1154    count: usize,
1155    captions_only: bool,
1156) -> PopupMenu {
1157    menu.when_some(chip, |menu, (ix, level, direction)| {
1158        let directed = |key: &str, to: SortDirection| {
1159            let level = level.clone();
1160            command(label(key), this, move |state, cx| {
1161                state.set_level_direction(&level, Some(to), cx)
1162            })
1163            .checked(direction == to)
1164        };
1165        let moved = |key: &str, step: isize| {
1166            let level = level.clone();
1167            command(label(key), this, move |state, cx| {
1168                state.move_level_by(&level, step, cx)
1169            })
1170        };
1171        let ungroup = {
1172            let level = level.clone();
1173            command(label("live_grid.menu.ungroup"), this, move |state, cx| {
1174                state.remove_level(&level, cx);
1175            })
1176        };
1177        menu.item(directed(
1178            "live_grid.menu.sort_ascending",
1179            SortDirection::Ascending,
1180        ))
1181        .item(directed(
1182            "live_grid.menu.sort_descending",
1183            SortDirection::Descending,
1184        ))
1185        .separator()
1186        .item(moved("live_grid.menu.move_left", -1).disabled(ix == 0))
1187        .item(moved("live_grid.menu.move_right", 1).disabled(ix + 1 >= count))
1188        .separator()
1189        .item(ungroup)
1190    })
1191    .menu_with_disabled(
1192        label("live_grid.menu.clear_grouping"),
1193        Box::new(ClearGrouping),
1194        count == 0,
1195    )
1196    .separator()
1197    .menu_with_disabled(
1198        label("live_grid.menu.expand_all"),
1199        Box::new(ExpandAll),
1200        count == 0,
1201    )
1202    .menu_with_disabled(
1203        label("live_grid.menu.collapse_all"),
1204        Box::new(CollapseAll),
1205        count == 0,
1206    )
1207    .menu_with_check_and_disabled(
1208        label("live_grid.menu.captions_only"),
1209        captions_only,
1210        Box::new(ShowCaptionsOnly),
1211        count == 0,
1212    )
1213}