Skip to main content

sqlly_datatable/pivot/
sidebar.rs

1//! The pivot configuration sidebar: a field list plus the Rows / Columns /
2//! Values / Filters drop zones, driven by GPUI's native drag-and-drop
3//! (`on_drag` / `drag_over` / `on_drop`).
4//!
5//! The sidebar and the pivot grid share one [`Entity<PivotState>`]: every
6//! drop mutates [`crate::pivot::PivotConfig`] through the same
7//! `move_field` API available to programmatic callers, then triggers
8//! `recompute()`.
9
10use crate::grid::theme::GridTheme;
11use crate::pivot::aggregation::AggregationFn;
12use crate::pivot::config::PivotZone;
13use crate::pivot::state::PivotState;
14
15use gpui::{
16    anchored, deferred, div, point, px, App, AppContext as _, Context, Corner, Entity, FontWeight,
17    InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement,
18    Render, SharedString, StatefulInteractiveElement, Styled, Window,
19};
20
21/// Draw priority for the filter popover overlay (matches the grid's menus).
22const POPOVER_PRIORITY: usize = 1_000_000;
23/// Max checklist rows rendered in the filter popover.
24const FILTER_POPOVER_MAX_ROWS: usize = 200;
25
26/// Horizontal chrome between the sidebar edge and accordion content: sidebar
27/// padding (8×2), accordion border (1×2), accordion content padding (16×2).
28const SIDEBAR_CONTENT_CHROME: f32 = 50.0;
29/// Horizontal chrome of a drop-zone box: padding (6×2) + border (1×2).
30const ZONE_CHROME: f32 = 14.0;
31/// Horizontal chrome of a chip: padding (8×2) + border (1×2).
32const CHIP_CHROME: f32 = 18.0;
33/// Flex gap between a chip's label and its trailing glyphs (marker, picker
34/// arrow, remove button).
35const CHIP_GAP: f32 = 6.0;
36/// Chip label font size.
37const CHIP_FONT_SIZE: f32 = 12.0;
38/// Zone-marker / badge font size.
39const MARKER_FONT_SIZE: f32 = 11.0;
40
41/// Measured pixel width of `text` at `size` in the window's default UI font.
42fn measure_text(window: &Window, text: &str, size: f32) -> f32 {
43    if text.is_empty() {
44        return 0.0;
45    }
46    let run = gpui::TextRun {
47        len: text.len(),
48        color: gpui::Hsla::default(),
49        font: window.text_style().font(),
50        background_color: None,
51        underline: None,
52        strikethrough: None,
53    };
54    let line = window.text_system().shape_line(
55        SharedString::from(text.to_owned()),
56        px(size),
57        &[run],
58        None,
59    );
60    f32::from(line.width)
61}
62
63/// Hover tooltip showing a chip's full label when its text is chopped.
64struct ChipTooltip {
65    label: SharedString,
66    bg: gpui::Hsla,
67    fg: gpui::Hsla,
68    border: gpui::Hsla,
69}
70
71impl Render for ChipTooltip {
72    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
73        div()
74            .px(px(8.0))
75            .py(px(4.0))
76            .rounded(px(4.0))
77            .bg(self.bg)
78            .border_1()
79            .border_color(self.border)
80            .text_color(self.fg)
81            .text_size(px(CHIP_FONT_SIZE))
82            .child(self.label.clone())
83    }
84}
85
86/// A little floppy-disk glyph drawn with divs (no icon font dependency).
87fn disk_icon(color: gpui::Hsla) -> gpui::Div {
88    div()
89        .w(px(13.0))
90        .h(px(13.0))
91        .rounded(px(2.0))
92        .border_1()
93        .border_color(color)
94        .relative()
95        .child(
96            div()
97                .absolute()
98                .top(px(0.0))
99                .left(px(3.0))
100                .w(px(5.0))
101                .h(px(4.0))
102                .border_1()
103                .border_color(color),
104        )
105        .child(
106            div()
107                .absolute()
108                .bottom(px(1.0))
109                .left(px(2.0))
110                .w(px(7.0))
111                .h(px(4.0))
112                .bg(color),
113        )
114}
115
116/// Payload carried by a field-chip drag.
117struct DraggedField {
118    field: usize,
119}
120
121/// The ghost chip rendered under the cursor during a drag.
122struct DragGhost {
123    label: String,
124    bg: gpui::Hsla,
125    fg: gpui::Hsla,
126    border: gpui::Hsla,
127}
128
129impl Render for DragGhost {
130    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
131        div()
132            .px(px(8.0))
133            .py(px(3.0))
134            .rounded(px(4.0))
135            .bg(self.bg)
136            .border_1()
137            .border_color(self.border)
138            .text_color(self.fg)
139            .text_size(px(12.0))
140            .child(self.label.clone())
141    }
142}
143
144/// Drag-and-drop pivot configuration sidebar. Owns nothing but a handle to
145/// the shared [`PivotState`].
146pub struct PivotSidebar {
147    /// The shared pivot state.
148    pub state: Entity<PivotState>,
149    expanded_sections: Vec<SharedString>,
150}
151
152impl PivotSidebar {
153    /// Wrap an existing pivot state entity.
154    #[must_use]
155    pub fn new(state: Entity<PivotState>) -> Self {
156        Self {
157            state,
158            expanded_sections: vec!["fields".into(), "layout".into(), "display".into()],
159        }
160    }
161
162    fn set_section_expanded(&mut self, id: &SharedString, expanded: bool) {
163        if expanded {
164            if !self.expanded_sections.contains(id) {
165                self.expanded_sections.push(id.clone());
166            }
167        } else {
168            self.expanded_sections.retain(|section| section != id);
169        }
170    }
171
172    /// One collapsible sidebar section: a clickable header row (title, an
173    /// optional extra control next to the title, expand indicator) over an
174    /// optional content body. Hand-rolled — instead of the `gpui-ui-kit`
175    /// accordion, whose header only accepts a title string — so the Layout
176    /// header can host the save-configuration button.
177    #[allow(clippy::too_many_arguments)]
178    fn section(
179        sidebar: &Entity<PivotSidebar>,
180        theme: &GridTheme,
181        id: &'static str,
182        title: &'static str,
183        header_extra: Option<gpui::AnyElement>,
184        expanded: bool,
185        first: bool,
186        content: gpui::AnyElement,
187    ) -> gpui::AnyElement {
188        let sidebar = sidebar.clone();
189        let section_id: SharedString = id.into();
190        let hover_bg = theme.menu_hover_bg;
191
192        let mut header = div()
193            .id(SharedString::from(format!("pivot-section-{id}")))
194            .flex()
195            .items_center()
196            .justify_between()
197            .px(px(16.0))
198            .py(px(12.0))
199            .bg(theme.header_bg)
200            .cursor_pointer()
201            .hover(move |style| style.bg(hover_bg))
202            .on_mouse_up(MouseButton::Left, move |_e: &MouseUpEvent, _w, cx| {
203                sidebar.update(cx, |this, cx| {
204                    this.set_section_expanded(&section_id, !expanded);
205                    cx.notify();
206                });
207            })
208            .child(
209                div()
210                    .flex()
211                    .items_center()
212                    .gap(px(8.0))
213                    .child(
214                        div()
215                            .text_size(px(14.0))
216                            .font_weight(FontWeight::MEDIUM)
217                            .text_color(theme.header_fg)
218                            .child(title),
219                    )
220                    .children(header_extra),
221            )
222            .child(
223                div()
224                    .text_size(px(12.0))
225                    .text_color(theme.muted_text)
226                    .child(if expanded { "▼" } else { "▶" }),
227            );
228        if !first {
229            header = header.border_t_1().border_color(theme.grid_line);
230        }
231
232        let mut wrapper = div().flex().flex_col().child(header);
233        if expanded {
234            wrapper = wrapper.child(
235                div()
236                    .px(px(16.0))
237                    .py(px(12.0))
238                    .bg(theme.menu_bg)
239                    .border_t_1()
240                    .border_color(theme.grid_line)
241                    .child(content),
242            );
243        }
244        wrapper.into_any_element()
245    }
246
247    /// A draggable field chip. `zone` is `None` for the source field list.
248    /// `label_budget` is the chip's inner content width; when the label (plus
249    /// its trailing glyphs) can't fit, the text is ellipsized and a hover
250    /// tooltip shows the full name.
251    #[allow(clippy::too_many_arguments)]
252    fn chip(
253        state: &Entity<PivotState>,
254        id: (&'static str, usize),
255        field: usize,
256        label: String,
257        zone: Option<PivotZone>,
258        removable: bool,
259        marker: Option<&'static str>,
260        label_budget: f32,
261        window: &Window,
262        cx: &App,
263    ) -> gpui::AnyElement {
264        let s = state.read(cx);
265        let theme = s.theme.clone();
266        let ghost_label = label.clone();
267        let ghost_bg = theme.pivot_chip_bg;
268        let ghost_fg = theme.pivot_chip_fg;
269        let ghost_border = theme.grid_line;
270
271        let state_drop = state.clone();
272        let state_remove = state.clone();
273
274        let mut available = label_budget;
275        if let Some(marker) = marker {
276            available -= CHIP_GAP + measure_text(window, marker, MARKER_FONT_SIZE);
277        }
278        if removable {
279            available -= CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE);
280        }
281        let chopped = measure_text(window, &label, CHIP_FONT_SIZE) > available + 0.5;
282
283        let mut chip = div()
284            .id(id)
285            .px(px(8.0))
286            .py(px(3.0))
287            .rounded(px(4.0))
288            .bg(theme.pivot_chip_bg)
289            .border_1()
290            .border_color(theme.grid_line)
291            .text_color(theme.pivot_chip_fg)
292            .text_size(px(CHIP_FONT_SIZE))
293            .flex()
294            .items_center()
295            .gap(px(CHIP_GAP))
296            .cursor_pointer()
297            .on_drag(
298                DraggedField { field },
299                move |_drag, _offset, _window, cx| {
300                    cx.new(|_| DragGhost {
301                        label: ghost_label.clone(),
302                        bg: ghost_bg,
303                        fg: ghost_fg,
304                        border: ghost_border,
305                    })
306                },
307            )
308            .child(
309                div()
310                    .flex_1()
311                    .min_w(px(0.0))
312                    .truncate()
313                    .child(label.clone()),
314            );
315
316        if chopped {
317            let tip_label: SharedString = label.into();
318            let tip_bg = theme.menu_bg;
319            let tip_fg = theme.menu_fg;
320            let tip_border = theme.grid_line;
321            chip = chip.tooltip(move |_window, cx| {
322                cx.new(|_| ChipTooltip {
323                    label: tip_label.clone(),
324                    bg: tip_bg,
325                    fg: tip_fg,
326                    border: tip_border,
327                })
328                .into()
329            });
330        }
331
332        if let Some(marker) = marker {
333            chip = chip.child(
334                div()
335                    .flex_none()
336                    .text_color(theme.sort_indicator)
337                    .text_size(px(MARKER_FONT_SIZE))
338                    .child(marker),
339            );
340        }
341
342        if removable {
343            chip = chip.child(
344                div()
345                    .flex_none()
346                    .text_color(theme.muted_text)
347                    .cursor_pointer()
348                    .child("✕")
349                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
350                        cx.stop_propagation();
351                        state_remove.update(cx, |s, cx| {
352                            s.config.remove_field(field);
353                            s.recompute();
354                            cx.notify();
355                        });
356                    }),
357            );
358        }
359
360        // Dropping onto a chip inside a zone inserts the dragged field at
361        // this chip's position (reorder support).
362        if let Some(zone) = zone {
363            chip = chip.on_drop::<DraggedField>(move |dragged, _window, cx| {
364                cx.stop_propagation();
365                let dragged_field = dragged.field;
366                state_drop.update(cx, |s, cx| {
367                    let index = s.config.fields_in(zone).iter().position(|&f| f == field);
368                    s.config.move_field(dragged_field, zone, index);
369                    s.recompute();
370                    cx.notify();
371                });
372            });
373        }
374
375        chip.into_any_element()
376    }
377
378    /// One drop zone with its label, hint, and assigned chips. `content_w` is
379    /// the accordion content width available to the zone box.
380    fn zone(
381        state: &Entity<PivotState>,
382        zone: PivotZone,
383        title: &'static str,
384        content_w: f32,
385        window: &Window,
386        cx: &App,
387    ) -> gpui::AnyElement {
388        let s = state.read(cx);
389        let theme = s.theme.clone();
390        let fields = s.config.fields_in(zone);
391        let columns = s.source_columns().to_vec();
392        let agg = s.config.aggregation;
393        let agg_menu_open = s.agg_menu_open;
394
395        let state_drop = state.clone();
396        let active_bg = theme.pivot_drop_zone_active_bg;
397        let label_budget = content_w - ZONE_CHROME - CHIP_CHROME;
398
399        let mut chips: Vec<gpui::AnyElement> = Vec::new();
400        for (i, &field) in fields.iter().enumerate() {
401            let name = columns
402                .get(field)
403                .map(|c| c.name.clone())
404                .unwrap_or_else(|| format!("column {field}"));
405            match zone {
406                PivotZone::Values => {
407                    chips.push(Self::values_chip(
408                        state,
409                        field,
410                        agg,
411                        agg_menu_open,
412                        name,
413                        label_budget,
414                        window,
415                        cx,
416                    ));
417                }
418                PivotZone::Filters => {
419                    let filter_on = s.filter_active(field);
420                    let state_open = state.clone();
421                    let chip = div()
422                        .id(("pivot-filter-chip", i))
423                        .on_mouse_down(MouseButton::Left, {
424                            let state_open = state_open.clone();
425                            move |e: &MouseDownEvent, _w, cx| {
426                                state_open.update(cx, |s, cx| {
427                                    s.open_filter_popover(field, e.position);
428                                    cx.notify();
429                                });
430                            }
431                        })
432                        .child(Self::chip(
433                            state,
434                            ("pivot-filter-chip-inner", i),
435                            field,
436                            name,
437                            Some(zone),
438                            true,
439                            filter_on.then_some("●"),
440                            label_budget,
441                            window,
442                            cx,
443                        ));
444                    chips.push(chip.into_any_element());
445                }
446                PivotZone::Rows | PivotZone::Columns => {
447                    let id = match zone {
448                        PivotZone::Rows => ("pivot-row-chip", i),
449                        _ => ("pivot-col-chip", i),
450                    };
451                    chips.push(Self::chip(
452                        state,
453                        id,
454                        field,
455                        name,
456                        Some(zone),
457                        true,
458                        None,
459                        label_budget,
460                        window,
461                        cx,
462                    ));
463                }
464            }
465        }
466
467        let hint = if chips.is_empty() {
468            Some(
469                div()
470                    .text_color(theme.muted_text)
471                    .text_size(px(11.0))
472                    .child("Drag fields here"),
473            )
474        } else {
475            None
476        };
477
478        div()
479            .flex()
480            .flex_col()
481            .gap(px(4.0))
482            .child(
483                div()
484                    .text_color(theme.muted_text)
485                    .text_size(px(11.0))
486                    .child(title),
487            )
488            .child(
489                div()
490                    .min_h(px(40.0))
491                    .p(px(6.0))
492                    .rounded(px(4.0))
493                    .bg(theme.pivot_drop_zone_bg)
494                    .border_1()
495                    .border_color(theme.grid_line)
496                    .flex()
497                    .flex_col()
498                    .gap(px(4.0))
499                    .drag_over::<DraggedField>(move |style, _, _, _| style.bg(active_bg))
500                    .on_drop::<DraggedField>(move |dragged, _window, cx| {
501                        let dragged_field = dragged.field;
502                        state_drop.update(cx, |s, cx| {
503                            s.config.move_field(dragged_field, zone, None);
504                            s.recompute();
505                            cx.notify();
506                        });
507                    })
508                    .children(chips)
509                    .children(hint),
510            )
511            .into_any_element()
512    }
513
514    /// The Values-zone chip: caption plus the aggregation picker. The caption
515    /// is ellipsized so both the picker arrow and the remove button always
516    /// fit; a hover tooltip shows the full caption when chopped.
517    #[allow(clippy::too_many_arguments)]
518    fn values_chip(
519        state: &Entity<PivotState>,
520        field: usize,
521        agg: AggregationFn,
522        menu_open: bool,
523        name: String,
524        label_budget: f32,
525        window: &Window,
526        cx: &App,
527    ) -> gpui::AnyElement {
528        let s = state.read(cx);
529        let theme = s.theme.clone();
530        let state_toggle = state.clone();
531        let state_remove = state.clone();
532
533        let mut rows: Vec<gpui::AnyElement> = Vec::new();
534        let caption = agg.caption(&name);
535        let ghost_label = caption.clone();
536        let ghost_bg = theme.pivot_chip_bg;
537        let ghost_fg = theme.pivot_chip_fg;
538        let ghost_border = theme.grid_line;
539
540        let arrow = if menu_open { "▲" } else { "▼" };
541        let available = label_budget
542            - (CHIP_GAP + measure_text(window, arrow, CHIP_FONT_SIZE))
543            - (CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE));
544        let chopped = measure_text(window, &caption, CHIP_FONT_SIZE) > available + 0.5;
545
546        let mut chip = div()
547            .id("pivot-values-chip")
548            .px(px(8.0))
549            .py(px(3.0))
550            .rounded(px(4.0))
551            .bg(theme.pivot_chip_bg)
552            .border_1()
553            .border_color(theme.grid_line)
554            .text_color(theme.pivot_chip_fg)
555            .text_size(px(CHIP_FONT_SIZE))
556            .flex()
557            .items_center()
558            .gap(px(CHIP_GAP))
559            .cursor_pointer()
560            .on_drag(
561                DraggedField { field },
562                move |_drag, _offset, _window, cx| {
563                    cx.new(|_| DragGhost {
564                        label: ghost_label.clone(),
565                        bg: ghost_bg,
566                        fg: ghost_fg,
567                        border: ghost_border,
568                    })
569                },
570            )
571            .child(
572                div()
573                    .flex_1()
574                    .min_w(px(0.0))
575                    .truncate()
576                    .child(caption.clone()),
577            )
578            .child(
579                div()
580                    .flex_none()
581                    .cursor_pointer()
582                    .text_color(theme.sort_indicator)
583                    .child(arrow)
584                    .on_mouse_down(MouseButton::Left, {
585                        let state_toggle = state_toggle.clone();
586                        move |_e: &MouseDownEvent, _w, cx| {
587                            cx.stop_propagation();
588                            state_toggle.update(cx, |s, cx| {
589                                s.agg_menu_open = !s.agg_menu_open;
590                                cx.notify();
591                            });
592                        }
593                    }),
594            )
595            .child(
596                div()
597                    .flex_none()
598                    .text_color(theme.muted_text)
599                    .cursor_pointer()
600                    .child("✕")
601                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
602                        cx.stop_propagation();
603                        state_remove.update(cx, |s, cx| {
604                            s.config.remove_field(field);
605                            s.recompute();
606                            cx.notify();
607                        });
608                    }),
609            );
610
611        if chopped {
612            let tip_label: SharedString = caption.into();
613            let tip_bg = theme.menu_bg;
614            let tip_fg = theme.menu_fg;
615            let tip_border = theme.grid_line;
616            chip = chip.tooltip(move |_window, cx| {
617                cx.new(|_| ChipTooltip {
618                    label: tip_label.clone(),
619                    bg: tip_bg,
620                    fg: tip_fg,
621                    border: tip_border,
622                })
623                .into()
624            });
625        }
626
627        rows.push(chip.into_any_element());
628
629        if menu_open {
630            for func in AggregationFn::all() {
631                let selected = func == agg;
632                let state_pick = state.clone();
633                rows.push(
634                    div()
635                        .px(px(8.0))
636                        .py(px(2.0))
637                        .rounded(px(3.0))
638                        .bg(if selected {
639                            theme.menu_hover_bg
640                        } else {
641                            theme.menu_bg
642                        })
643                        .text_color(theme.menu_fg)
644                        .text_size(px(12.0))
645                        .cursor_pointer()
646                        .child(func.label())
647                        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
648                            state_pick.update(cx, |s, cx| {
649                                s.config.aggregation = func;
650                                s.agg_menu_open = false;
651                                s.recompute();
652                                cx.notify();
653                            });
654                        })
655                        .into_any_element(),
656                );
657            }
658        }
659
660        div()
661            .flex()
662            .flex_col()
663            .gap(px(2.0))
664            .children(rows)
665            .into_any_element()
666    }
667
668    /// A labelled checkbox row bound to one totals option.
669    fn option_row(
670        state: &Entity<PivotState>,
671        label: &'static str,
672        checked: bool,
673        apply: impl Fn(&mut PivotState) + 'static,
674        cx: &App,
675    ) -> gpui::AnyElement {
676        let theme = state.read(cx).theme.clone();
677        let state_toggle = state.clone();
678        let boxed = div()
679            .w(px(12.0))
680            .h(px(12.0))
681            .border_1()
682            .border_color(theme.grid_line)
683            .bg(theme.menu_bg)
684            .flex()
685            .items_center()
686            .justify_center()
687            .children(checked.then(|| div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator)));
688        div()
689            .flex()
690            .items_center()
691            .gap(px(6.0))
692            .cursor_pointer()
693            .text_size(px(12.0))
694            .text_color(theme.menu_fg)
695            .child(boxed)
696            .child(label)
697            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
698                state_toggle.update(cx, |s, cx| {
699                    apply(s);
700                    s.recompute();
701                    cx.notify();
702                });
703            })
704            .into_any_element()
705    }
706
707    /// Small inline text button.
708    fn text_button(
709        state: &Entity<PivotState>,
710        label: &'static str,
711        apply: impl Fn(&mut PivotState, &mut App) + 'static,
712        cx: &App,
713    ) -> gpui::AnyElement {
714        let theme = state.read(cx).theme.clone();
715        let state_btn = state.clone();
716        div()
717            .px(px(6.0))
718            .py(px(2.0))
719            .rounded(px(3.0))
720            .border_1()
721            .border_color(theme.grid_line)
722            .bg(theme.pivot_drop_zone_bg)
723            .text_color(theme.menu_fg)
724            .text_size(px(11.0))
725            .cursor_pointer()
726            .child(label)
727            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
728                state_btn.update(cx, |s, cx| {
729                    apply(s, cx);
730                    cx.notify();
731                });
732            })
733            .into_any_element()
734    }
735
736    /// The Filters-zone checklist popover overlay, if open.
737    fn render_filter_popover(
738        state: &Entity<PivotState>,
739        cx: &App,
740    ) -> Option<impl IntoElement + use<>> {
741        let s = state.read(cx);
742        let popover = s.filter_popover()?.clone();
743        let theme = s.theme.clone();
744        let field_name = s
745            .source_columns()
746            .get(popover.field)
747            .map(|c| c.name.clone())
748            .unwrap_or_default();
749
750        let checkbox = |checked: bool| {
751            let mut b = div()
752                .w(px(12.0))
753                .h(px(12.0))
754                .border_1()
755                .border_color(theme.grid_line)
756                .bg(theme.menu_bg)
757                .flex()
758                .items_center()
759                .justify_center();
760            if checked {
761                b = b.child(div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator));
762            }
763            b
764        };
765
766        let state_all = state.clone();
767        let select_all = div()
768            .h(px(22.0))
769            .flex()
770            .items_center()
771            .gap(px(6.0))
772            .cursor_pointer()
773            .child(checkbox(popover.all_checked()))
774            .child("(Select All)")
775            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
776                state_all.update(cx, |s, cx| {
777                    s.toggle_filter_popover_select_all();
778                    cx.notify();
779                });
780            });
781
782        let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
783        for (i, row) in popover
784            .rows
785            .iter()
786            .enumerate()
787            .take(FILTER_POPOVER_MAX_ROWS)
788        {
789            let state_val = state.clone();
790            value_rows.push(
791                div()
792                    .h(px(20.0))
793                    .flex()
794                    .items_center()
795                    .gap(px(6.0))
796                    .cursor_pointer()
797                    .child(checkbox(row.checked))
798                    .child(div().text_color(theme.menu_fg).child(row.label.clone()))
799                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
800                        state_val.update(cx, |s, cx| {
801                            s.toggle_filter_popover_value(i);
802                            cx.notify();
803                        });
804                    })
805                    .into_any_element(),
806            );
807        }
808        let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
809
810        let state_clear = state.clone();
811        let state_close = state.clone();
812        let clear_field = popover.field;
813        let buttons = div()
814            .flex()
815            .gap(px(6.0))
816            .child(
817                div()
818                    .flex_1()
819                    .h(px(24.0))
820                    .flex()
821                    .items_center()
822                    .justify_center()
823                    .border_1()
824                    .border_color(theme.grid_line)
825                    .bg(theme.menu_hover_bg)
826                    .cursor_pointer()
827                    .child("Clear")
828                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
829                        state_clear.update(cx, |s, cx| {
830                            s.clear_filter(clear_field);
831                            cx.notify();
832                        });
833                    }),
834            )
835            .child(
836                div()
837                    .flex_1()
838                    .h(px(24.0))
839                    .flex()
840                    .items_center()
841                    .justify_center()
842                    .border_1()
843                    .border_color(theme.grid_line)
844                    .bg(theme.menu_hover_bg)
845                    .cursor_pointer()
846                    .child("Close")
847                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
848                        state_close.update(cx, |s, cx| {
849                            s.close_filter_popover();
850                            cx.notify();
851                        });
852                    }),
853            );
854
855        let body = div()
856            .flex()
857            .flex_col()
858            .w(px(240.0))
859            .p(px(8.0))
860            .gap(px(6.0))
861            .bg(theme.menu_bg)
862            .border_1()
863            .border_color(theme.grid_line)
864            .text_color(theme.menu_fg)
865            .text_size(px(12.0))
866            .child(
867                div()
868                    .text_color(theme.muted_text)
869                    .text_size(px(11.0))
870                    .child(format!("Filter: {field_name}")),
871            )
872            .child(select_all)
873            .child(
874                div()
875                    .id("pivot-filter-values")
876                    .flex()
877                    .flex_col()
878                    .max_h(px(200.0))
879                    .overflow_y_scroll()
880                    .children(value_rows)
881                    .children(truncated.then(|| {
882                        div()
883                            .text_color(theme.muted_text)
884                            .text_size(px(11.0))
885                            .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
886                    })),
887            )
888            .child(buttons);
889
890        let state_backdrop = state.clone();
891        let overlay = deferred(
892            anchored()
893                .anchor(Corner::TopLeft)
894                .position(point(popover.anchor.x, popover.anchor.y))
895                .child(div().occlude().child(body).on_mouse_down_out(
896                    move |_e: &MouseDownEvent, _window, cx| {
897                        state_backdrop.update(cx, |s, cx| {
898                            if s.filter_popover().is_some() {
899                                s.close_filter_popover();
900                                cx.notify();
901                            }
902                        });
903                    },
904                )),
905        )
906        .with_priority(POPOVER_PRIORITY);
907        Some(overlay)
908    }
909}
910
911impl Render for PivotSidebar {
912    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
913        let (theme, columns, config, sidebar_width, save_handler) = {
914            let s = self.state.read(cx);
915            (
916                s.theme.clone(),
917                s.source_columns().to_vec(),
918                s.config.clone(),
919                s.sidebar_width,
920                s.save_config_handler.clone(),
921            )
922        };
923        let content_w = sidebar_width - SIDEBAR_CONTENT_CHROME;
924
925        // Source field list with zone badges.
926        let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
927        for (i, col) in columns.iter().enumerate() {
928            let badge = match config.zone_of(i) {
929                Some(PivotZone::Rows) => Some("R"),
930                Some(PivotZone::Columns) => Some("C"),
931                Some(PivotZone::Values) => Some("V"),
932                Some(PivotZone::Filters) => Some("F"),
933                None => None,
934            };
935            field_chips.push(Self::chip(
936                &self.state,
937                ("pivot-source-field", i),
938                i,
939                col.name.clone(),
940                None,
941                false,
942                badge,
943                content_w - CHIP_CHROME,
944                window,
945                cx,
946            ));
947        }
948
949        let options = div()
950            .flex()
951            .flex_col()
952            .gap(px(4.0))
953            .child(
954                div()
955                    .text_color(theme.muted_text)
956                    .text_size(px(11.0))
957                    .child("Totals"),
958            )
959            .child(Self::option_row(
960                &self.state,
961                "Row subtotals",
962                config.show_row_subtotals,
963                |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
964                cx,
965            ))
966            .child(Self::option_row(
967                &self.state,
968                "Column subtotal columns",
969                config.show_column_subtotals,
970                |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
971                cx,
972            ))
973            .child(Self::option_row(
974                &self.state,
975                "Grand total row",
976                config.show_row_grand_total,
977                |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
978                cx,
979            ))
980            .child(Self::option_row(
981                &self.state,
982                "Grand total column",
983                config.show_column_grand_total,
984                |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
985                cx,
986            ));
987
988        let tools = div()
989            .flex()
990            .flex_col()
991            .gap(px(4.0))
992            .child(
993                div()
994                    .text_color(theme.muted_text)
995                    .text_size(px(11.0))
996                    .child("Groups"),
997            )
998            .child(
999                div()
1000                    .flex()
1001                    .gap(px(4.0))
1002                    .child(Self::text_button(
1003                        &self.state,
1004                        "Expand rows",
1005                        |s, _| s.expand_all_rows(),
1006                        cx,
1007                    ))
1008                    .child(Self::text_button(
1009                        &self.state,
1010                        "Collapse rows",
1011                        |s, _| s.collapse_all_rows(),
1012                        cx,
1013                    )),
1014            )
1015            .child(
1016                div()
1017                    .flex()
1018                    .gap(px(4.0))
1019                    .child(Self::text_button(
1020                        &self.state,
1021                        "Expand cols",
1022                        |s, _| s.expand_all_cols(),
1023                        cx,
1024                    ))
1025                    .child(Self::text_button(
1026                        &self.state,
1027                        "Collapse cols",
1028                        |s, _| s.collapse_all_cols(),
1029                        cx,
1030                    )),
1031            )
1032            .child(
1033                div()
1034                    .text_color(theme.muted_text)
1035                    .text_size(px(11.0))
1036                    .child("Export"),
1037            )
1038            .child(Self::text_button(
1039                &self.state,
1040                "Copy pivot as CSV",
1041                |s, cx| {
1042                    let csv = s.to_csv();
1043                    if !csv.is_empty() {
1044                        cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
1045                    }
1046                },
1047                cx,
1048            ));
1049
1050        let fields = div()
1051            .flex()
1052            .flex_col()
1053            .gap(px(3.0))
1054            .child(
1055                div()
1056                    .text_color(theme.muted_text)
1057                    .text_size(px(11.0))
1058                    .child("Drag a field into a layout zone"),
1059            )
1060            .child(
1061                div()
1062                    .id("pivot-field-list")
1063                    .flex()
1064                    .flex_col()
1065                    .gap(px(3.0))
1066                    .max_h(px(220.0))
1067                    .overflow_y_scroll()
1068                    .children(field_chips),
1069            );
1070
1071        // Save-configuration button, rendered in the Layout section header
1072        // next to its title. Only present while a host wired up the action
1073        // (via `PivotState::on_save_config` or the widget builder).
1074        let save_button = save_handler.map(|handler| {
1075            let state_save = self.state.clone();
1076            let icon_color = theme.muted_text;
1077            let hover_bg = theme.pivot_drop_zone_active_bg;
1078            let tip_bg = theme.menu_bg;
1079            let tip_fg = theme.menu_fg;
1080            let tip_border = theme.grid_line;
1081            div()
1082                .id("pivot-save-config")
1083                .p(px(2.0))
1084                .rounded(px(3.0))
1085                .cursor_pointer()
1086                .hover(move |style| style.bg(hover_bg))
1087                .child(disk_icon(icon_color))
1088                .tooltip(move |_window, cx| {
1089                    cx.new(|_| ChipTooltip {
1090                        label: "Save configuration".into(),
1091                        bg: tip_bg,
1092                        fg: tip_fg,
1093                        border: tip_border,
1094                    })
1095                    .into()
1096                })
1097                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1098                    cx.stop_propagation();
1099                    let config = state_save.read(cx).config.clone();
1100                    handler(&config, cx);
1101                })
1102                // The section header toggles on mouse-up; swallow it so a
1103                // click on the save button doesn't also collapse the section.
1104                .on_mouse_up(MouseButton::Left, |_e: &MouseUpEvent, _w, cx| {
1105                    cx.stop_propagation();
1106                })
1107                .into_any_element()
1108        });
1109
1110        let layout = div()
1111            .flex()
1112            .flex_col()
1113            .gap(px(6.0))
1114            .child(Self::zone(
1115                &self.state,
1116                PivotZone::Filters,
1117                "Filters",
1118                content_w,
1119                window,
1120                cx,
1121            ))
1122            .child(Self::zone(
1123                &self.state,
1124                PivotZone::Columns,
1125                "Columns",
1126                content_w,
1127                window,
1128                cx,
1129            ))
1130            .child(Self::zone(
1131                &self.state,
1132                PivotZone::Rows,
1133                "Rows",
1134                content_w,
1135                window,
1136                cx,
1137            ))
1138            .child(Self::zone(
1139                &self.state,
1140                PivotZone::Values,
1141                "Values",
1142                content_w,
1143                window,
1144                cx,
1145            ));
1146
1147        let display = div()
1148            .flex()
1149            .flex_col()
1150            .gap(px(10.0))
1151            .child(options)
1152            .child(tools);
1153
1154        let sidebar = cx.entity().clone();
1155        let is_expanded = |id: &str| self.expanded_sections.iter().any(|section| section == id);
1156        let sections = div()
1157            .flex()
1158            .flex_col()
1159            .border_1()
1160            .border_color(theme.grid_line)
1161            .rounded_lg()
1162            .child(Self::section(
1163                &sidebar,
1164                &theme,
1165                "fields",
1166                "Fields",
1167                None,
1168                is_expanded("fields"),
1169                true,
1170                fields.into_any_element(),
1171            ))
1172            .child(Self::section(
1173                &sidebar,
1174                &theme,
1175                "layout",
1176                "Layout",
1177                save_button,
1178                is_expanded("layout"),
1179                false,
1180                layout.into_any_element(),
1181            ))
1182            .child(Self::section(
1183                &sidebar,
1184                &theme,
1185                "display",
1186                "Display and export",
1187                None,
1188                is_expanded("display"),
1189                false,
1190                display.into_any_element(),
1191            ));
1192
1193        div()
1194            .id("pivot-sidebar")
1195            .h_full()
1196            .flex()
1197            .flex_col()
1198            .gap(px(8.0))
1199            .p(px(8.0))
1200            .bg(theme.menu_bg)
1201            .text_color(theme.menu_fg)
1202            .text_size(px(12.0))
1203            .overflow_y_scroll()
1204            .child(
1205                div()
1206                    .text_color(theme.header_fg)
1207                    .text_size(px(13.0))
1208                    .child("Pivot Controls"),
1209            )
1210            .child(sections)
1211            .children(Self::render_filter_popover(&self.state, cx))
1212    }
1213}