Skip to main content

gpui_kit_grid/state/
chooser.rs

1//! The column chooser: a searchable list of the grid's columns by zone and
2//! band, with a checkbox to show or hide each and a grip to reorder
3//! ([`ColumnChooserPanel`]), and the grid's popover layer that shows it for
4//! "Choose columns…" ([`ColumnChooserFactory`], [`PopoverColumnChooser`]).
5//!
6//! The panel works on a [`LiveGridHandle`]: it reads `columns()` and follows
7//! `LayoutChange`, and its changes go to the grid with the `User` origin, one
8//! `LayoutChange` each, at once. The grid's layer is a sibling of the grid
9//! root outside the `LiveGrid` key context, as the menu layer is: a press
10//! outside the panel closes it and does nothing else, and Escape closes it,
11//! from the search field too.
12//!
13//! Element ids: the panel is `"live-grid-column-chooser"`; a column's row is
14//! `NamedChild("chooser-column", <column id>)`, a band's
15//! `NamedChild("chooser-band", <band id>)`, a section header
16//! `NamedChild("chooser-zone", <zone>)`; a row's grip is `"chooser-grip"` and
17//! its checkbox `"chooser-check"` inside it.
18
19use std::{rc::Rc, sync::Arc};
20
21use gpui_kit::base::actions::Cancel;
22use gpui_kit::base::{POPUP_PRIORITY, TestSupportExt as _};
23use gpui_kit::component::{
24    ActiveTheme as _, Disableable as _, Icon, IconName, IndexPath, Sizable as _, ThemeStyled as _,
25    button::{Button, ButtonVariants as _},
26    checkbox::Checkbox,
27    input::{Escape, Input, InputEvent, InputState},
28    list::{List, ListDelegate, ListItem, ListState},
29};
30use gpui_kit::{
31    AnyView, App, AppContext as _, Context, DismissEvent, ElementId, Entity, EventEmitter,
32    FocusHandle, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
33    Point, Render, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription,
34    Window, anchored, deferred, div, point, prelude::FluentBuilder as _, px,
35};
36use rust_i18n::t;
37
38use super::{
39    ColumnInfo, LiveGridDelegate, LiveGridHandle, LiveGridState, WeakLiveGridHandle,
40    columns::{ColumnZone, LayoutDelta},
41    events::{LayoutOrigin, LayoutPart},
42    ghost::GhostTextStyle,
43    handle::GridHandleEvent,
44};
45use crate::{
46    actions::{COLUMN_CHOOSER_KEY_CONTEXT, MoveColumnLeft, MoveColumnRight, ResetLayout},
47    model::{ColumnId, ColumnIx, ColumnSet, GridColumns as _},
48};
49
50/// The width of the column chooser.
51pub(crate) const CHOOSER_WIDTH: Pixels = px(280.);
52
53/// The tallest the list grows before it scrolls.
54const LIST_MAX_HEIGHT: Pixels = px(360.);
55
56/// The indent of a band's members, per level.
57const BAND_INDENT: Pixels = px(16.);
58
59/// The columns from which the panel shows its search field.
60const DEFAULT_SEARCH_THRESHOLD: usize = 10;
61
62/// What a row of the chooser stands for, by identity: a column by id, a
63/// band by id.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub(crate) enum ChooserItem {
66    Column(ColumnId),
67    Band(&'static str),
68}
69
70/// A change the chooser asks the grid for, with the `User` origin.
71pub(crate) enum ChooserCommand<'a> {
72    /// Shows a hidden column, hides a shown one; shows every member of a
73    /// band not fully shown, else hides those the user may hide.
74    Toggle(&'a ChooserItem),
75    /// "Show all".
76    ShowAll,
77    /// "Reset columns", as the menu item.
78    Reset,
79    /// Puts the item in `zone` before the column `before` (`None`: at the
80    /// end of the zone), as the move policy allows; with `dry_run` only
81    /// tells whether it would.
82    Move {
83        item: &'a ChooserItem,
84        zone: ColumnZone,
85        before: Option<&'a ColumnId>,
86        dry_run: bool,
87    },
88    /// Moves the item one place up (`-1`) or down (`1`) in its zone.
89    Step { item: &'a ChooserItem, step: isize },
90}
91
92/// One row of the chooser's list.
93#[derive(Clone, Debug)]
94enum ChooserRow {
95    Column {
96        info: ColumnInfo,
97        /// Its band nesting: the indent.
98        depth: usize,
99        /// Its checkbox is disabled: not hideable, or the last visible one.
100        locked: bool,
101        /// Shows a grip: movable, and not while searching.
102        grip: bool,
103    },
104    Band {
105        id: &'static str,
106        title: SharedString,
107        depth: usize,
108        shown: usize,
109        count: usize,
110        grip: bool,
111    },
112}
113
114impl ChooserRow {
115    fn item(&self) -> ChooserItem {
116        match self {
117            Self::Column { info, .. } => ChooserItem::Column(info.id().clone()),
118            Self::Band { id, .. } => ChooserItem::Band(id),
119        }
120    }
121
122    fn title(&self) -> &SharedString {
123        match self {
124            Self::Column { info, .. } => info.title(),
125            Self::Band { title, .. } => title,
126        }
127    }
128}
129
130/// The rows of one zone; without frozen columns, one section without a
131/// header.
132#[derive(Clone, Debug)]
133struct ChooserSection {
134    zone: Option<ColumnZone>,
135    rows: Vec<ChooserRow>,
136    /// The first column of the zone: where a drop on the header goes.
137    first: Option<ColumnId>,
138}
139
140/// The chooser's rows from the grid's columns: by zone and band, or a flat
141/// list of the columns whose title holds `query` (lowercase).
142fn chooser_sections(columns: &[ColumnInfo], query: &str) -> Vec<ChooserSection> {
143    // Internal columns are never offered.
144    let columns: Vec<ColumnInfo> = columns
145        .iter()
146        .filter(|column| !column.is_internal())
147        .cloned()
148        .collect();
149    let columns = columns.as_slice();
150    let visible = columns.iter().filter(|column| column.is_visible()).count();
151    let column_row = |info: &ColumnInfo, depth: usize, grip: bool| ChooserRow::Column {
152        locked: !info.is_hideable() || (info.is_visible() && visible <= 1),
153        grip: grip && info.is_movable(),
154        info: info.clone(),
155        depth,
156    };
157    if !query.is_empty() {
158        let rows = columns
159            .iter()
160            .filter(|column| column.title().to_lowercase().contains(query))
161            .map(|column| column_row(column, 0, false))
162            .collect();
163        return vec![ChooserSection {
164            zone: None,
165            rows,
166            first: None,
167        }];
168    }
169    let sectioned = columns
170        .iter()
171        .any(|column| column.zone() != ColumnZone::Scrolling);
172    let mut sections = Vec::new();
173    for zone in ColumnZone::ALL {
174        let zone_columns: Vec<&ColumnInfo> = columns
175            .iter()
176            .filter(|column| column.zone() == zone)
177            .collect();
178        if zone_columns.is_empty() {
179            continue;
180        }
181        let mut rows = Vec::with_capacity(zone_columns.len());
182        let mut open: Vec<&'static str> = Vec::new();
183        for (position, column) in zone_columns.iter().enumerate() {
184            let chain: Vec<&'static str> = column.bands().iter().map(|band| band.id()).collect();
185            let common = open
186                .iter()
187                .zip(&chain)
188                .take_while(|(open, band)| open == band)
189                .count();
190            open.truncate(common);
191            for (level, band) in column.bands().iter().enumerate().skip(common) {
192                // The band's members: the columns from here while they are
193                // under it at this level.
194                let members: Vec<&&ColumnInfo> = zone_columns[position..]
195                    .iter()
196                    .take_while(|member| {
197                        member
198                            .bands()
199                            .get(level)
200                            .is_some_and(|each| each.id() == band.id())
201                    })
202                    .collect();
203                rows.push(ChooserRow::Band {
204                    id: band.id(),
205                    title: band.title().clone(),
206                    depth: level,
207                    shown: members.iter().filter(|member| member.is_visible()).count(),
208                    count: members.len(),
209                    grip: members.iter().all(|member| member.is_movable()),
210                });
211                open.push(band.id());
212            }
213            rows.push(column_row(column, chain.len(), true));
214        }
215        sections.push(ChooserSection {
216            zone: Some(zone),
217            first: zone_columns.first().map(|column| column.id().clone()),
218            rows,
219        });
220    }
221    if !sectioned {
222        for section in &mut sections {
223            section.zone = None;
224        }
225    }
226    sections
227}
228
229/// The label of a zone's section.
230fn zone_label(zone: ColumnZone) -> SharedString {
231    let key = match zone {
232        ColumnZone::FrozenLeft => "live_grid.chooser.frozen_left",
233        ColumnZone::Scrolling => "live_grid.chooser.scrolling",
234        ColumnZone::FrozenRight => "live_grid.chooser.frozen_right",
235    };
236    SharedString::from(t!(key).into_owned())
237}
238
239/// The id of a zone in element ids.
240fn zone_id(zone: ColumnZone) -> &'static str {
241    match zone {
242        ColumnZone::FrozenLeft => "frozen-left",
243        ColumnZone::Scrolling => "scrolling",
244        ColumnZone::FrozenRight => "frozen-right",
245    }
246}
247
248/// The payload of a grip drag: renders the ghost in the row's font.
249#[derive(Clone)]
250struct ChooserDrag {
251    item: ChooserItem,
252    title: SharedString,
253    text: GhostTextStyle,
254}
255
256impl Render for ChooserDrag {
257    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
258        let theme = cx.theme();
259        self.text.root().child(
260            div()
261                .px_2()
262                .py_1()
263                .popover_style(cx)
264                .text_color(theme.popover_foreground)
265                .opacity(0.9)
266                .child(self.title.clone()),
267        )
268    }
269}
270
271/// The list delegate of a [`ColumnChooserPanel`].
272pub(crate) struct ChooserDelegate {
273    grid: WeakLiveGridHandle,
274    sections: Vec<ChooserSection>,
275    query: String,
276    selected: Option<IndexPath>,
277}
278
279impl ChooserDelegate {
280    fn row(&self, ix: IndexPath) -> Option<&ChooserRow> {
281        self.sections.get(ix.section)?.rows.get(ix.row)
282    }
283
284    /// The index of the row of `item`.
285    fn position(&self, item: &ChooserItem) -> Option<IndexPath> {
286        self.sections
287            .iter()
288            .enumerate()
289            .find_map(|(section, rows)| {
290                rows.rows
291                    .iter()
292                    .position(|row| row.item() == *item)
293                    .map(|row| IndexPath::default().section(section).row(row))
294            })
295    }
296
297    /// The item of the highlighted row.
298    fn selected_item(&self) -> Option<ChooserItem> {
299        self.row(self.selected?).map(ChooserRow::item)
300    }
301
302    /// Where a drop on the row at `ix` puts the dragged item: the row's zone
303    /// and the column it goes before (a band's first member).
304    fn drop_place(&self, ix: IndexPath) -> Option<(ColumnZone, Option<ColumnId>)> {
305        let section = self.sections.get(ix.section)?;
306        let zone = section.zone.unwrap_or(ColumnZone::Scrolling);
307        let before = section.rows[ix.row..].iter().find_map(|row| match row {
308            ChooserRow::Column { info, .. } => Some(info.id().clone()),
309            ChooserRow::Band { .. } => None,
310        });
311        Some((zone, before))
312    }
313
314    /// A row; a grip drag's ghost draws in `ghost_text`.
315    fn render_row(
316        &self,
317        ix: IndexPath,
318        row: &ChooserRow,
319        ghost_text: GhostTextStyle,
320        cx: &mut Context<ListState<Self>>,
321    ) -> ListItem {
322        let theme = cx.theme();
323        let (id, depth, grip, checked, disabled, icon, suffix) = match row {
324            ChooserRow::Column {
325                info,
326                depth,
327                locked,
328                grip,
329            } => (
330                ElementId::NamedChild(
331                    Arc::new(ElementId::Name("chooser-column".into())),
332                    SharedString::from(info.id().as_ref().to_owned()),
333                ),
334                *depth,
335                *grip,
336                info.is_visible(),
337                *locked,
338                info.icon().cloned(),
339                None,
340            ),
341            ChooserRow::Band {
342                id,
343                depth,
344                shown,
345                count,
346                grip,
347                ..
348            } => (
349                ElementId::NamedChild(
350                    Arc::new(ElementId::Name("chooser-band".into())),
351                    SharedString::new_static(id),
352                ),
353                *depth,
354                *grip,
355                shown == count,
356                false,
357                None,
358                (shown != count).then(|| {
359                    SharedString::from(
360                        t!("live_grid.chooser.band_shown", shown = shown, count = count)
361                            .into_owned(),
362                    )
363                }),
364            ),
365        };
366        let title = row.title().clone();
367        let drag = ChooserDrag {
368            item: row.item(),
369            title: title.clone(),
370            text: ghost_text,
371        };
372        let place = self.drop_place(ix);
373        let grid = self.grid.clone();
374        let can_drop_grid = self.grid.clone();
375        let can_drop_place = place.clone();
376        ListItem::new(id)
377            .py_0p5()
378            .px_2()
379            .child(
380                div()
381                    .flex()
382                    .items_center()
383                    .gap_1p5()
384                    .pl(BAND_INDENT * depth as f32)
385                    .min_w(px(0.))
386                    .child(
387                        div()
388                            .id("chooser-grip")
389                            .test_support()
390                            .flex_none()
391                            .size_4()
392                            .when(grip, |this| {
393                                this.role(Role::Button)
394                                    .aria_label(SharedString::from(
395                                        t!("live_grid.chooser.move", title = title.as_ref())
396                                            .into_owned(),
397                                    ))
398                                    .cursor_grab()
399                                    .child(
400                                        Icon::new(IconName::Menu)
401                                            .size_4()
402                                            .text_color(theme.muted_foreground),
403                                    )
404                                    .on_drag(drag, |drag, _, _, cx| cx.new(|_| drag.clone()))
405                                    // A press on the grip is no click on the
406                                    // row: it does not toggle the column.
407                                    .on_click(|_, _, cx| cx.stop_propagation())
408                            }),
409                    )
410                    .child(
411                        Checkbox::new("chooser-check")
412                            .checked(checked)
413                            .disabled(disabled)
414                            .tab_stop(false)
415                            .accessibility_label(title.clone())
416                            .small(),
417                    )
418                    .children(icon.map(|icon| {
419                        Icon::empty()
420                            .path(icon)
421                            .size_4()
422                            .text_color(theme.muted_foreground)
423                    }))
424                    .child(div().flex_1().min_w(px(0.)).truncate().child(title)),
425            )
426            .children(suffix.map(|suffix| {
427                div()
428                    .flex_none()
429                    .text_xs()
430                    .text_color(theme.muted_foreground)
431                    .child(suffix)
432            }))
433            .when_some(place, |this, (zone, before)| {
434                let drag_border = theme.drag_border;
435                this.drag_over::<ChooserDrag>(move |style, _, _, _| {
436                    style.border_t_2().border_color(drag_border)
437                })
438                .can_drop(move |drag, _, cx| {
439                    let (Some(drag), Some(grid), Some((zone, before))) = (
440                        drag.downcast_ref::<ChooserDrag>(),
441                        can_drop_grid.upgrade(),
442                        can_drop_place.as_ref(),
443                    ) else {
444                        return false;
445                    };
446                    grid.chooser_command_in_app(
447                        ChooserCommand::Move {
448                            item: &drag.item,
449                            zone: *zone,
450                            before: before.as_ref(),
451                            dry_run: true,
452                        },
453                        cx,
454                    )
455                })
456                .on_drop(move |drag: &ChooserDrag, window, cx| {
457                    if let Some(grid) = grid.upgrade() {
458                        grid.chooser_command(
459                            ChooserCommand::Move {
460                                item: &drag.item,
461                                zone,
462                                before: before.as_ref(),
463                                dry_run: false,
464                            },
465                            window,
466                            cx,
467                        );
468                    }
469                })
470            })
471    }
472}
473
474impl ListDelegate for ChooserDelegate {
475    type Item = ListItem;
476
477    fn sections_count(&self, _: &App) -> usize {
478        self.sections.len().max(1)
479    }
480
481    fn items_count(&self, section: usize, _: &App) -> usize {
482        self.sections
483            .get(section)
484            .map_or(0, |section| section.rows.len())
485    }
486
487    fn render_item(
488        &mut self,
489        ix: IndexPath,
490        window: &mut Window,
491        cx: &mut Context<ListState<Self>>,
492    ) -> Option<ListItem> {
493        let row = self.row(ix)?.clone();
494        Some(self.render_row(ix, &row, GhostTextStyle::capture(window), cx))
495    }
496
497    fn render_section_header(
498        &mut self,
499        section: usize,
500        _: &mut Window,
501        cx: &mut Context<ListState<Self>>,
502    ) -> Option<impl IntoElement> {
503        let section = self.sections.get(section)?;
504        let zone = section.zone?;
505        let theme = cx.theme();
506        let first = section.first.clone();
507        let grid = self.grid.clone();
508        let drag_border = theme.drag_border;
509        Some(
510            div()
511                .id(ElementId::NamedChild(
512                    Arc::new(ElementId::Name("chooser-zone".into())),
513                    SharedString::new_static(zone_id(zone)),
514                ))
515                .test_support()
516                .px_2()
517                .pt_2()
518                .pb_1()
519                .text_xs()
520                .text_color(theme.muted_foreground)
521                .child(zone_label(zone))
522                // A drop on the header puts the item first in the zone.
523                .drag_over::<ChooserDrag>(move |style, _, _, _| {
524                    style.border_b_2().border_color(drag_border)
525                })
526                .on_drop(move |drag: &ChooserDrag, window, cx| {
527                    if let Some(grid) = grid.upgrade() {
528                        grid.chooser_command(
529                            ChooserCommand::Move {
530                                item: &drag.item,
531                                zone,
532                                before: first.as_ref(),
533                                dry_run: false,
534                            },
535                            window,
536                            cx,
537                        );
538                    }
539                }),
540        )
541    }
542
543    fn render_empty(
544        &mut self,
545        _: &mut Window,
546        cx: &mut Context<ListState<Self>>,
547    ) -> impl IntoElement {
548        div()
549            .id("chooser-no-match")
550            .test_support()
551            .p_3()
552            .text_sm()
553            .text_color(cx.theme().muted_foreground)
554            .child(SharedString::from(
555                t!("live_grid.chooser.no_match").into_owned(),
556            ))
557    }
558
559    fn set_selected_index(
560        &mut self,
561        ix: Option<IndexPath>,
562        _: &mut Window,
563        _: &mut Context<ListState<Self>>,
564    ) {
565        self.selected = ix;
566    }
567
568    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
569        if let (Some(item), Some(grid)) = (self.selected_item(), self.grid.upgrade()) {
570            grid.chooser_command(ChooserCommand::Toggle(&item), window, cx);
571        }
572    }
573}
574
575/// The column chooser: a searchable list of the grid's columns, by zone and
576/// band, with a checkbox to show or hide each and a grip to reorder.
577/// Changes apply at once, one `LayoutChange { origin: User }` each.
578///
579/// The grid shows it in its own popover (`LiveGridState::show_column_chooser`);
580/// an application may host it in its own `Popover`, dialog or sheet: create
581/// it once over a [`LiveGridHandle`] and render the entity. It emits
582/// `DismissEvent` on Escape, and when the grid went away.
583///
584/// ```ignore
585/// // Once, when the toolbar is built:
586/// let chooser = cx.new(|cx| ColumnChooserPanel::new(&LiveGridHandle::new(&grid), window, cx));
587/// // In the toolbar's render:
588/// Popover::new("columns")
589///     .trigger(Button::new("columns").label("Columns…"))
590///     .content(move |_, _| chooser.clone())
591/// ```
592pub struct ColumnChooserPanel {
593    grid: WeakLiveGridHandle,
594    list: Entity<ListState<ChooserDelegate>>,
595    search: Entity<InputState>,
596    search_threshold: usize,
597    /// The columns the grid has.
598    column_count: usize,
599    /// Every column is shown: "Show all" is disabled.
600    all_shown: bool,
601    /// A layout change arrived: the rows are rebuilt in the next render.
602    stale: bool,
603    _subscriptions: Vec<Subscription>,
604}
605
606impl ColumnChooserPanel {
607    /// A chooser of the columns of `grid`.
608    pub fn new(grid: &LiveGridHandle, window: &mut Window, cx: &mut Context<Self>) -> Self {
609        let columns = grid.columns(cx);
610        let delegate = ChooserDelegate {
611            grid: grid.downgrade(),
612            sections: chooser_sections(&columns, ""),
613            query: String::new(),
614            selected: None,
615        };
616        let list = cx.new(|cx| ListState::new(delegate, window, cx));
617        let search = cx.new(|cx| {
618            InputState::new(window, cx).placeholder(SharedString::from(
619                t!("live_grid.chooser.search").into_owned(),
620            ))
621        });
622        let this = cx.weak_entity();
623        let layout = grid.subscribe(cx, move |event, cx| {
624            if matches!(event, GridHandleEvent::LayoutChange { .. }) {
625                let _ = this.update(cx, |panel: &mut Self, cx| {
626                    panel.stale = true;
627                    cx.notify();
628                });
629            }
630        });
631        let search_changes = cx.subscribe_in(&search, window, Self::on_search_event);
632        Self {
633            grid: grid.downgrade(),
634            list,
635            search,
636            search_threshold: DEFAULT_SEARCH_THRESHOLD,
637            all_shown: columns.iter().all(ColumnInfo::is_visible),
638            column_count: columns.len(),
639            stale: false,
640            _subscriptions: vec![layout, search_changes],
641        }
642    }
643
644    /// Shows the search field from this many columns (default 10): 0
645    /// always, `usize::MAX` never.
646    pub fn with_search_threshold(mut self, columns: usize) -> Self {
647        self.search_threshold = columns;
648        self
649    }
650
651    /// Returns true if the search field shows.
652    fn is_searchable(&self) -> bool {
653        self.column_count >= self.search_threshold
654    }
655
656    fn on_search_event(
657        &mut self,
658        search: &Entity<InputState>,
659        event: &InputEvent,
660        window: &mut Window,
661        cx: &mut Context<Self>,
662    ) {
663        if matches!(event, InputEvent::Change) {
664            let query = search.read(cx).value().trim().to_lowercase();
665            let changed = self.list.read(cx).delegate().query != query;
666            if changed {
667                self.list
668                    .update(cx, |list, _| list.delegate_mut().query = query);
669                self.refresh(window, cx);
670            }
671        }
672    }
673
674    /// Rebuilds the rows from the grid's columns; the highlight stays on the
675    /// same column or band.
676    fn refresh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
677        self.stale = false;
678        let Some(grid) = self.grid.upgrade() else {
679            return;
680        };
681        let columns = grid.columns(cx);
682        self.column_count = columns.len();
683        self.all_shown = columns.iter().all(ColumnInfo::is_visible);
684        self.list.update(cx, |list, cx| {
685            let selected = list.delegate().selected_item();
686            let query = list.delegate().query.clone();
687            list.delegate_mut().sections = chooser_sections(&columns, &query);
688            let ix = selected.and_then(|item| list.delegate().position(&item));
689            list.set_selected_index(ix, window, cx);
690            cx.notify();
691        });
692        cx.notify();
693    }
694
695    /// Alt+↑ / Alt+↓: the highlighted column or band one place up or down
696    /// in its zone.
697    fn step(&mut self, step: isize, window: &mut Window, cx: &mut Context<Self>) {
698        let (Some(item), Some(grid)) = (
699            self.list.read(cx).delegate().selected_item(),
700            self.grid.upgrade(),
701        ) else {
702            return;
703        };
704        grid.chooser_command(ChooserCommand::Step { item: &item, step }, window, cx);
705    }
706
707    fn dismiss(&mut self, cx: &mut Context<Self>) {
708        cx.emit(DismissEvent);
709    }
710}
711
712impl EventEmitter<DismissEvent> for ColumnChooserPanel {}
713
714impl Focusable for ColumnChooserPanel {
715    /// The search field if it shows, else the list.
716    fn focus_handle(&self, cx: &App) -> FocusHandle {
717        if self.is_searchable() {
718            self.search.focus_handle(cx)
719        } else {
720            self.list.focus_handle(cx)
721        }
722    }
723}
724
725impl Render for ColumnChooserPanel {
726    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
727        if self.grid.upgrade().is_none() {
728            // The grid went away: nothing left to choose.
729            cx.defer_in(window, |panel, _, cx| panel.dismiss(cx));
730        } else if self.stale {
731            self.refresh(window, cx);
732        }
733        let theme = cx.theme();
734        let searchable = self.is_searchable();
735        let grid = self.grid.clone();
736        let reset_grid = self.grid.clone();
737        div()
738            .id("live-grid-column-chooser")
739            .test_support()
740            .role(Role::Dialog)
741            .aria_label(SharedString::from(
742                t!("live_grid.chooser.label").into_owned(),
743            ))
744            .flex()
745            .flex_col()
746            .w(CHOOSER_WIDTH)
747            .popover_style(cx)
748            .text_sm()
749            .overflow_hidden()
750            .on_action(cx.listener(|panel, _: &Cancel, _, cx| panel.dismiss(cx)))
751            .on_action(cx.listener(|panel, _: &Escape, _, cx| panel.dismiss(cx)))
752            .when(searchable, |this| {
753                this.child(
754                    div().p_2().border_b_1().border_color(theme.border).child(
755                        Input::new(&self.search)
756                            .small()
757                            .prefix(Icon::new(IconName::Search).text_color(theme.muted_foreground))
758                            .cleanable(true),
759                    ),
760                )
761            })
762            .child(
763                div()
764                    .key_context(COLUMN_CHOOSER_KEY_CONTEXT)
765                    .on_action(cx.listener(|panel, _: &MoveColumnLeft, window, cx| {
766                        panel.step(-1, window, cx)
767                    }))
768                    .on_action(cx.listener(|panel, _: &MoveColumnRight, window, cx| {
769                        panel.step(1, window, cx)
770                    }))
771                    .py_1()
772                    .child(List::new(&self.list).small().max_h(LIST_MAX_HEIGHT)),
773            )
774            .child(
775                div()
776                    .flex()
777                    .justify_between()
778                    .p_1()
779                    .border_t_1()
780                    .border_color(theme.border)
781                    .child(
782                        Button::new("chooser-show-all")
783                            .ghost()
784                            .small()
785                            .label(SharedString::from(
786                                t!("live_grid.chooser.show_all").into_owned(),
787                            ))
788                            .disabled(self.all_shown)
789                            .on_click(move |_, window, cx| {
790                                if let Some(grid) = grid.upgrade() {
791                                    grid.chooser_command(ChooserCommand::ShowAll, window, cx);
792                                }
793                            }),
794                    )
795                    .child(
796                        Button::new("chooser-reset")
797                            .ghost()
798                            .small()
799                            .label(SharedString::from(
800                                t!("live_grid.menu.reset_columns").into_owned(),
801                            ))
802                            .on_click(move |_, window, cx| {
803                                if let Some(grid) = reset_grid.upgrade() {
804                                    grid.chooser_command(ChooserCommand::Reset, window, cx);
805                                }
806                            }),
807                    ),
808            )
809    }
810}
811
812/// Makes what the grid shows for "Choose columns…".
813pub trait ColumnChooserFactory: 'static {
814    /// Returns the view the grid shows in its popover at
815    /// `context.position()`, focused by the factory, or `None` after
816    /// showing a chooser elsewhere (a dialog with `window.open_dialog`).
817    /// Called once per opening, after the event that opened it, outside the
818    /// grid's update: the factory may read the grid through the handle.
819    fn open(
820        &self,
821        context: &ColumnChooserContext,
822        window: &mut Window,
823        cx: &mut App,
824    ) -> Option<AnyView>;
825}
826
827/// What a [`ColumnChooserFactory`] knows.
828pub struct ColumnChooserContext {
829    grid: LiveGridHandle,
830    position: Point<Pixels>,
831}
832
833impl ColumnChooserContext {
834    /// The grid.
835    pub fn grid(&self) -> &LiveGridHandle {
836        &self.grid
837    }
838
839    /// Where the popover's top left goes, in window coordinates.
840    pub fn position(&self) -> Point<Pixels> {
841        self.position
842    }
843}
844
845/// The default: [`ColumnChooserPanel`] in the grid's popover, focused on its
846/// search field or its list.
847#[derive(Clone, Copy, Debug)]
848pub struct PopoverColumnChooser {
849    search_threshold: usize,
850}
851
852impl PopoverColumnChooser {
853    /// The panel with its search field from 10 columns.
854    pub const fn new() -> Self {
855        Self {
856            search_threshold: DEFAULT_SEARCH_THRESHOLD,
857        }
858    }
859
860    /// Shows the panel's search field from this many columns (default 10).
861    pub const fn with_search_threshold(mut self, columns: usize) -> Self {
862        self.search_threshold = columns;
863        self
864    }
865
866    /// The columns from which the search field shows.
867    pub const fn search_threshold(&self) -> usize {
868        self.search_threshold
869    }
870}
871
872impl Default for PopoverColumnChooser {
873    fn default() -> Self {
874        Self::new()
875    }
876}
877
878impl ColumnChooserFactory for PopoverColumnChooser {
879    fn open(
880        &self,
881        context: &ColumnChooserContext,
882        window: &mut Window,
883        cx: &mut App,
884    ) -> Option<AnyView> {
885        let threshold = self.search_threshold;
886        let panel = cx.new(|cx| {
887            ColumnChooserPanel::new(context.grid(), window, cx).with_search_threshold(threshold)
888        });
889        let focus = panel.focus_handle(cx);
890        window.focus(&focus, cx);
891        Some(panel.into())
892    }
893}
894
895/// How a grid shows its column chooser, and the one open.
896pub(super) struct ChooserSlot {
897    factory: Rc<dyn ColumnChooserFactory>,
898    open: Option<OpenChooser>,
899}
900
901impl Default for ChooserSlot {
902    fn default() -> Self {
903        Self {
904            factory: Rc::new(PopoverColumnChooser::new()),
905            open: None,
906        }
907    }
908}
909
910/// The chooser in the grid's layer.
911struct OpenChooser {
912    view: AnyView,
913    position: Point<Pixels>,
914}
915
916impl<D: LiveGridDelegate> LiveGridState<D> {
917    /// Sets what "Choose columns…" shows (default [`PopoverColumnChooser`]).
918    pub fn with_column_chooser(mut self, factory: impl ColumnChooserFactory) -> Self {
919        self.column_chooser.factory = Rc::new(factory);
920        self
921    }
922
923    /// Opens the column chooser below the right end of the header, its
924    /// right edge at the header's (`ShowColumnChooser`). The view is built
925    /// after the current event. An open context menu closes.
926    pub fn show_column_chooser(&mut self, window: &mut Window, cx: &mut Context<Self>) {
927        let body = self.body_bounds;
928        let position = point(
929            (body.origin.x + body.size.width - CHOOSER_WIDTH).max(body.origin.x),
930            body.origin.y,
931        );
932        self.show_column_chooser_at(position, window, cx);
933    }
934
935    /// Opens the column chooser with its top left at `position` (window
936    /// coordinates), after the current event.
937    pub fn show_column_chooser_at(
938        &mut self,
939        position: Point<Pixels>,
940        window: &mut Window,
941        cx: &mut Context<Self>,
942    ) {
943        // One overlay at a time (Design Guides, "Avoid stacking overlays").
944        if self.menu.take().is_some() {
945            self.reconsider_held(cx);
946        }
947        let factory = self.column_chooser.factory.clone();
948        let grid = self.handle(cx);
949        let this = cx.weak_entity();
950        window.defer(cx, move |window, cx| {
951            let context = ColumnChooserContext { grid, position };
952            let view = factory.open(&context, window, cx);
953            let _ = this.update(cx, |state, cx| {
954                state.column_chooser.open = view.map(|view| OpenChooser { view, position });
955                cx.notify();
956            });
957        });
958    }
959
960    /// Closes the column chooser the grid shows, and gives the grid the
961    /// focus. Does nothing if none is open.
962    pub fn close_column_chooser(&mut self, window: &mut Window, cx: &mut Context<Self>) {
963        if self.column_chooser.open.take().is_some() {
964            window.focus(&self.focus_handle, cx);
965            cx.notify();
966        }
967    }
968
969    /// Returns true while the grid shows its column chooser.
970    pub fn is_column_chooser_open(&self) -> bool {
971        self.column_chooser.open.is_some()
972    }
973
974    /// Closes the chooser without moving the focus: a menu opens over it.
975    pub(super) fn drop_column_chooser(&mut self) {
976        self.column_chooser.open = None;
977    }
978
979    /// `ShowColumnChooser`: where the context menu was, if one is open (its
980    /// "Choose columns…" item), else below the right end of the header.
981    pub(super) fn on_show_column_chooser(
982        &mut self,
983        _: &crate::actions::ShowColumnChooser,
984        window: &mut Window,
985        cx: &mut Context<Self>,
986    ) {
987        match self.menu_position() {
988            Some(position) => self.show_column_chooser_at(position, window, cx),
989            None => self.show_column_chooser(window, cx),
990        }
991    }
992
993    /// The chooser's layer: over the whole window, a press outside the
994    /// panel closes it and goes no further, the wheel stops there, and
995    /// Escape closes it from anywhere inside.
996    pub(super) fn render_column_chooser(
997        &self,
998        window: &Window,
999        cx: &mut Context<Self>,
1000    ) -> Option<impl IntoElement + use<D>> {
1001        let open = self.column_chooser.open.as_ref()?;
1002        let viewport = window.viewport_size();
1003        Some(
1004            deferred(
1005                anchored().position(point(px(0.), px(0.))).child(
1006                    div()
1007                        .id("live-grid-chooser-layer")
1008                        .w(viewport.width)
1009                        .h(viewport.height)
1010                        .occlude()
1011                        .on_scroll_wheel(|_, _, cx| cx.stop_propagation())
1012                        .on_any_mouse_down(cx.listener(|state, _, window, cx| {
1013                            cx.stop_propagation();
1014                            state.close_column_chooser(window, cx);
1015                        }))
1016                        .capture_action(cx.listener(|state, _: &Cancel, window, cx| {
1017                            state.close_column_chooser(window, cx);
1018                        }))
1019                        .capture_action(cx.listener(|state, _: &Escape, window, cx| {
1020                            state.close_column_chooser(window, cx);
1021                        }))
1022                        .child(
1023                            anchored()
1024                                .position(open.position)
1025                                .snap_to_window_with_margin(px(8.))
1026                                .child(
1027                                    div()
1028                                        // A press in the panel stays there.
1029                                        .on_any_mouse_down(|_, _, cx| cx.stop_propagation())
1030                                        .child(open.view.clone()),
1031                                ),
1032                        ),
1033                ),
1034            )
1035            .with_priority(POPUP_PRIORITY),
1036        )
1037    }
1038
1039    /// Runs a command of the column chooser with the `User` origin. Returns
1040    /// whether it changed (or, for a dry run, would change) the layout.
1041    pub(super) fn run_chooser_command(
1042        &mut self,
1043        command: ChooserCommand<'_>,
1044        window: Option<&mut Window>,
1045        cx: &mut Context<Self>,
1046    ) -> bool {
1047        match command {
1048            ChooserCommand::Toggle(item) => self.chooser_toggle(item, cx),
1049            ChooserCommand::ShowAll => {
1050                let all_shown = self.columns.visible_set().len() == self.columns.order().len();
1051                self.show_all_columns_from(LayoutOrigin::User, cx);
1052                !all_shown
1053            }
1054            ChooserCommand::Reset => match window {
1055                Some(window) => {
1056                    self.on_reset_layout(&ResetLayout, window, cx);
1057                    true
1058                }
1059                None => false,
1060            },
1061            ChooserCommand::Move {
1062                item,
1063                zone,
1064                before,
1065                dry_run,
1066            } => self.chooser_move(item, zone, before, dry_run, cx),
1067            ChooserCommand::Step { item, step } => self.chooser_step(item, step, cx),
1068        }
1069    }
1070
1071    /// The columns of `item` in its zone, in zone order.
1072    fn chooser_members(&self, item: &ChooserItem) -> Option<(ColumnZone, Vec<ColumnIx>)> {
1073        match item {
1074            ChooserItem::Column(id) => {
1075                let ix = self.column_by_id(id.as_ref())?.ix();
1076                Some((self.columns.zone_of(ix), vec![ix]))
1077            }
1078            ChooserItem::Band(band) => {
1079                let first = self
1080                    .columns
1081                    .order()
1082                    .iter()
1083                    .copied()
1084                    .find(|ix| self.columns.bands_of(*ix).contains(band))?;
1085                let zone = self.columns.zone_of(first);
1086                Some((zone, self.columns.band_members(zone, band)))
1087            }
1088        }
1089    }
1090
1091    fn chooser_toggle(&mut self, item: &ChooserItem, cx: &mut Context<Self>) -> bool {
1092        let Some((_, members)) = self.chooser_members(item) else {
1093            return false;
1094        };
1095        // A band not fully shown is shown; a shown one hides the members
1096        // the user may hide, the last visible column of the grid excepted.
1097        let show = !members.iter().all(|ix| self.columns.is_visible(*ix));
1098        let hideable: Vec<bool> = members
1099            .iter()
1100            .map(|ix| self.columns.column(*ix).hideable)
1101            .collect();
1102        self.change_layout(LayoutOrigin::User, cx, |layout, _| {
1103            let mut changed = ColumnSet::EMPTY;
1104            for (ix, hideable) in members.iter().zip(&hideable) {
1105                if !show && !hideable {
1106                    continue;
1107                }
1108                if layout.set_visible(*ix, show) == Some(true) {
1109                    changed.insert(*ix);
1110                }
1111            }
1112            if changed.is_empty() {
1113                LayoutDelta::default()
1114            } else {
1115                LayoutDelta::new(LayoutPart::Visibility, changed.iter())
1116            }
1117        })
1118    }
1119
1120    fn chooser_move(
1121        &mut self,
1122        item: &ChooserItem,
1123        zone: ColumnZone,
1124        before: Option<&ColumnId>,
1125        dry_run: bool,
1126        cx: &mut Context<Self>,
1127    ) -> bool {
1128        let Some((from, members)) = self.chooser_members(item) else {
1129            return false;
1130        };
1131        let before = match before {
1132            Some(id) => match self.column_by_id(id.as_ref()) {
1133                Some(column) => Some(column.ix()),
1134                None => return false,
1135            },
1136            None => None,
1137        };
1138        if members.iter().any(|ix| !self.columns.column(*ix).movable)
1139            || before.is_some_and(|ix| members.contains(&ix))
1140        {
1141            return false;
1142        }
1143        if dry_run {
1144            let mut layout = self.columns.clone();
1145            return chooser_move_in(&mut layout, &*self.column_move, from, members, zone, before)
1146                .is_some();
1147        }
1148        self.change_layout(LayoutOrigin::User, cx, |layout, policy| {
1149            chooser_move_in(layout, policy, from, members, zone, before).unwrap_or_default()
1150        })
1151    }
1152
1153    fn chooser_step(&mut self, item: &ChooserItem, step: isize, cx: &mut Context<Self>) -> bool {
1154        let Some((zone, members)) = self.chooser_members(item) else {
1155            return false;
1156        };
1157        if members.iter().any(|ix| !self.columns.column(*ix).movable) {
1158            return false;
1159        }
1160        let positions: Vec<usize> = members
1161            .iter()
1162            .filter_map(|ix| self.columns.zone_position(*ix).map(|(_, i)| i))
1163            .collect();
1164        let (Some(&first), Some(&last)) = (positions.iter().min(), positions.iter().max()) else {
1165            return false;
1166        };
1167        let boundary = if step < 0 {
1168            match first.checked_sub(1) {
1169                Some(k) => k,
1170                None => return false,
1171            }
1172        } else {
1173            last + 2
1174        };
1175        let super::columns::Boundary::Gap(gap) = self
1176            .columns
1177            .zone_move(zone, members.clone())
1178            .resolve(boundary, &*self.column_move)
1179        else {
1180            return false;
1181        };
1182        self.change_layout(LayoutOrigin::User, cx, |layout, _| {
1183            if layout.move_members(zone, &members, gap) {
1184                LayoutDelta::new(LayoutPart::Order, members.iter().copied()).revealing()
1185            } else {
1186                LayoutDelta::default()
1187            }
1188        })
1189    }
1190}
1191
1192/// Puts `members` (of zone `from`) in `zone` before `before`, in one change:
1193/// to the zone first if it differs (the policy may take the rest of a band
1194/// along), then to its place. `None` if the policy refuses or nothing
1195/// changes.
1196fn chooser_move_in<C: crate::model::GridColumns>(
1197    layout: &mut super::columns::ColumnLayout<C>,
1198    policy: &dyn crate::policy::ColumnMovePolicy,
1199    from: ColumnZone,
1200    members: Vec<ColumnIx>,
1201    zone: ColumnZone,
1202    before: Option<ColumnIx>,
1203) -> Option<LayoutDelta> {
1204    if before.is_some_and(|ix| layout.zone_of(ix) != zone) {
1205        return None;
1206    }
1207    let mut delta = LayoutDelta::default();
1208    let mut members = members;
1209    if from != zone {
1210        let mut moved = Vec::new();
1211        for ix in &members {
1212            if layout.zone_of(*ix) == from {
1213                moved.extend(layout.set_zone(*ix, zone, policy));
1214            }
1215        }
1216        if moved.is_empty() {
1217            return None;
1218        }
1219        delta = LayoutDelta::new(LayoutPart::Zone, moved.iter().copied());
1220        delta.parts = delta.parts.with(LayoutPart::Order);
1221        members = layout
1222            .zone_order(zone)
1223            .iter()
1224            .copied()
1225            .filter(|ix| moved.contains(ix))
1226            .collect();
1227    }
1228    match layout.move_before(zone, members.clone(), before, policy) {
1229        Some(true) => {
1230            delta.union_with(LayoutDelta::new(LayoutPart::Order, members.iter().copied()));
1231        }
1232        Some(false) => {}
1233        None if from == zone => return None,
1234        // In the new zone at its edge: the place is refused, the zone not.
1235        None => {}
1236    }
1237    (!delta.is_empty()).then(|| delta.revealing())
1238}
1239
1240impl LiveGridHandle {
1241    /// Runs a command of the column chooser on the grid (`User` origin).
1242    pub(crate) fn chooser_command(
1243        &self,
1244        command: ChooserCommand<'_>,
1245        window: &mut Window,
1246        cx: &mut App,
1247    ) -> bool {
1248        self.ops().chooser(command, Some(window), cx)
1249    }
1250
1251    /// [`Self::chooser_command`] without a window: a dry run, which needs
1252    /// none.
1253    pub(crate) fn chooser_command_in_app(&self, command: ChooserCommand<'_>, cx: &mut App) -> bool {
1254        self.ops().chooser(command, None, cx)
1255    }
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260    use super::*;
1261
1262    #[test]
1263    fn test_default_factory() {
1264        let factory = PopoverColumnChooser::default();
1265        assert_eq!(factory.search_threshold(), 10);
1266        assert_eq!(
1267            PopoverColumnChooser::new()
1268                .with_search_threshold(3)
1269                .search_threshold(),
1270            3
1271        );
1272        assert_eq!(ChooserItem::Band("prices"), ChooserItem::Band("prices"));
1273    }
1274}