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::pivot::aggregation::AggregationFn;
11use crate::pivot::config::PivotZone;
12use crate::pivot::state::PivotState;
13
14use gpui::{
15    anchored, deferred, div, point, px, App, AppContext as _, Context, Corner, Entity,
16    InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Render,
17    SharedString, StatefulInteractiveElement, Styled, Window,
18};
19use gpui_ui_kit::accordion::{Accordion, AccordionItem, AccordionMode, AccordionTheme};
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    /// A draggable field chip. `zone` is `None` for the source field list.
173    /// `label_budget` is the chip's inner content width; when the label (plus
174    /// its trailing glyphs) can't fit, the text is ellipsized and a hover
175    /// tooltip shows the full name.
176    #[allow(clippy::too_many_arguments)]
177    fn chip(
178        state: &Entity<PivotState>,
179        id: (&'static str, usize),
180        field: usize,
181        label: String,
182        zone: Option<PivotZone>,
183        removable: bool,
184        marker: Option<&'static str>,
185        label_budget: f32,
186        window: &Window,
187        cx: &App,
188    ) -> gpui::AnyElement {
189        let s = state.read(cx);
190        let theme = s.theme.clone();
191        let ghost_label = label.clone();
192        let ghost_bg = theme.pivot_chip_bg;
193        let ghost_fg = theme.pivot_chip_fg;
194        let ghost_border = theme.grid_line;
195
196        let state_drop = state.clone();
197        let state_remove = state.clone();
198
199        let mut available = label_budget;
200        if let Some(marker) = marker {
201            available -= CHIP_GAP + measure_text(window, marker, MARKER_FONT_SIZE);
202        }
203        if removable {
204            available -= CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE);
205        }
206        let chopped = measure_text(window, &label, CHIP_FONT_SIZE) > available + 0.5;
207
208        let mut chip = div()
209            .id(id)
210            .px(px(8.0))
211            .py(px(3.0))
212            .rounded(px(4.0))
213            .bg(theme.pivot_chip_bg)
214            .border_1()
215            .border_color(theme.grid_line)
216            .text_color(theme.pivot_chip_fg)
217            .text_size(px(CHIP_FONT_SIZE))
218            .flex()
219            .items_center()
220            .gap(px(CHIP_GAP))
221            .cursor_pointer()
222            .on_drag(
223                DraggedField { field },
224                move |_drag, _offset, _window, cx| {
225                    cx.new(|_| DragGhost {
226                        label: ghost_label.clone(),
227                        bg: ghost_bg,
228                        fg: ghost_fg,
229                        border: ghost_border,
230                    })
231                },
232            )
233            .child(
234                div()
235                    .flex_1()
236                    .min_w(px(0.0))
237                    .truncate()
238                    .child(label.clone()),
239            );
240
241        if chopped {
242            let tip_label: SharedString = label.into();
243            let tip_bg = theme.menu_bg;
244            let tip_fg = theme.menu_fg;
245            let tip_border = theme.grid_line;
246            chip = chip.tooltip(move |_window, cx| {
247                cx.new(|_| ChipTooltip {
248                    label: tip_label.clone(),
249                    bg: tip_bg,
250                    fg: tip_fg,
251                    border: tip_border,
252                })
253                .into()
254            });
255        }
256
257        if let Some(marker) = marker {
258            chip = chip.child(
259                div()
260                    .flex_none()
261                    .text_color(theme.sort_indicator)
262                    .text_size(px(MARKER_FONT_SIZE))
263                    .child(marker),
264            );
265        }
266
267        if removable {
268            chip = chip.child(
269                div()
270                    .flex_none()
271                    .text_color(theme.muted_text)
272                    .cursor_pointer()
273                    .child("✕")
274                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
275                        cx.stop_propagation();
276                        state_remove.update(cx, |s, cx| {
277                            s.config.remove_field(field);
278                            s.recompute();
279                            cx.notify();
280                        });
281                    }),
282            );
283        }
284
285        // Dropping onto a chip inside a zone inserts the dragged field at
286        // this chip's position (reorder support).
287        if let Some(zone) = zone {
288            chip = chip.on_drop::<DraggedField>(move |dragged, _window, cx| {
289                cx.stop_propagation();
290                let dragged_field = dragged.field;
291                state_drop.update(cx, |s, cx| {
292                    let index = s.config.fields_in(zone).iter().position(|&f| f == field);
293                    s.config.move_field(dragged_field, zone, index);
294                    s.recompute();
295                    cx.notify();
296                });
297            });
298        }
299
300        chip.into_any_element()
301    }
302
303    /// One drop zone with its label, hint, and assigned chips. `content_w` is
304    /// the accordion content width available to the zone box.
305    fn zone(
306        state: &Entity<PivotState>,
307        zone: PivotZone,
308        title: &'static str,
309        content_w: f32,
310        window: &Window,
311        cx: &App,
312    ) -> gpui::AnyElement {
313        let s = state.read(cx);
314        let theme = s.theme.clone();
315        let fields = s.config.fields_in(zone);
316        let columns = s.source_columns().to_vec();
317        let agg = s.config.aggregation;
318        let agg_menu_open = s.agg_menu_open;
319
320        let state_drop = state.clone();
321        let active_bg = theme.pivot_drop_zone_active_bg;
322        let label_budget = content_w - ZONE_CHROME - CHIP_CHROME;
323
324        let mut chips: Vec<gpui::AnyElement> = Vec::new();
325        for (i, &field) in fields.iter().enumerate() {
326            let name = columns
327                .get(field)
328                .map(|c| c.name.clone())
329                .unwrap_or_else(|| format!("column {field}"));
330            match zone {
331                PivotZone::Values => {
332                    chips.push(Self::values_chip(
333                        state,
334                        field,
335                        agg,
336                        agg_menu_open,
337                        name,
338                        label_budget,
339                        window,
340                        cx,
341                    ));
342                }
343                PivotZone::Filters => {
344                    let filter_on = s.filter_active(field);
345                    let state_open = state.clone();
346                    let chip = div()
347                        .id(("pivot-filter-chip", i))
348                        .on_mouse_down(MouseButton::Left, {
349                            let state_open = state_open.clone();
350                            move |e: &MouseDownEvent, _w, cx| {
351                                state_open.update(cx, |s, cx| {
352                                    s.open_filter_popover(field, e.position);
353                                    cx.notify();
354                                });
355                            }
356                        })
357                        .child(Self::chip(
358                            state,
359                            ("pivot-filter-chip-inner", i),
360                            field,
361                            name,
362                            Some(zone),
363                            true,
364                            filter_on.then_some("●"),
365                            label_budget,
366                            window,
367                            cx,
368                        ));
369                    chips.push(chip.into_any_element());
370                }
371                PivotZone::Rows | PivotZone::Columns => {
372                    let id = match zone {
373                        PivotZone::Rows => ("pivot-row-chip", i),
374                        _ => ("pivot-col-chip", i),
375                    };
376                    chips.push(Self::chip(
377                        state,
378                        id,
379                        field,
380                        name,
381                        Some(zone),
382                        true,
383                        None,
384                        label_budget,
385                        window,
386                        cx,
387                    ));
388                }
389            }
390        }
391
392        let hint = if chips.is_empty() {
393            Some(
394                div()
395                    .text_color(theme.muted_text)
396                    .text_size(px(11.0))
397                    .child("Drag fields here"),
398            )
399        } else {
400            None
401        };
402
403        div()
404            .flex()
405            .flex_col()
406            .gap(px(4.0))
407            .child(
408                div()
409                    .text_color(theme.muted_text)
410                    .text_size(px(11.0))
411                    .child(title),
412            )
413            .child(
414                div()
415                    .min_h(px(40.0))
416                    .p(px(6.0))
417                    .rounded(px(4.0))
418                    .bg(theme.pivot_drop_zone_bg)
419                    .border_1()
420                    .border_color(theme.grid_line)
421                    .flex()
422                    .flex_col()
423                    .gap(px(4.0))
424                    .drag_over::<DraggedField>(move |style, _, _, _| style.bg(active_bg))
425                    .on_drop::<DraggedField>(move |dragged, _window, cx| {
426                        let dragged_field = dragged.field;
427                        state_drop.update(cx, |s, cx| {
428                            s.config.move_field(dragged_field, zone, None);
429                            s.recompute();
430                            cx.notify();
431                        });
432                    })
433                    .children(chips)
434                    .children(hint),
435            )
436            .into_any_element()
437    }
438
439    /// The Values-zone chip: caption plus the aggregation picker. The caption
440    /// is ellipsized so both the picker arrow and the remove button always
441    /// fit; a hover tooltip shows the full caption when chopped.
442    #[allow(clippy::too_many_arguments)]
443    fn values_chip(
444        state: &Entity<PivotState>,
445        field: usize,
446        agg: AggregationFn,
447        menu_open: bool,
448        name: String,
449        label_budget: f32,
450        window: &Window,
451        cx: &App,
452    ) -> gpui::AnyElement {
453        let s = state.read(cx);
454        let theme = s.theme.clone();
455        let state_toggle = state.clone();
456        let state_remove = state.clone();
457
458        let mut rows: Vec<gpui::AnyElement> = Vec::new();
459        let caption = agg.caption(&name);
460        let ghost_label = caption.clone();
461        let ghost_bg = theme.pivot_chip_bg;
462        let ghost_fg = theme.pivot_chip_fg;
463        let ghost_border = theme.grid_line;
464
465        let arrow = if menu_open { "▲" } else { "▼" };
466        let available = label_budget
467            - (CHIP_GAP + measure_text(window, arrow, CHIP_FONT_SIZE))
468            - (CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE));
469        let chopped = measure_text(window, &caption, CHIP_FONT_SIZE) > available + 0.5;
470
471        let mut chip = div()
472            .id("pivot-values-chip")
473            .px(px(8.0))
474            .py(px(3.0))
475            .rounded(px(4.0))
476            .bg(theme.pivot_chip_bg)
477            .border_1()
478            .border_color(theme.grid_line)
479            .text_color(theme.pivot_chip_fg)
480            .text_size(px(CHIP_FONT_SIZE))
481            .flex()
482            .items_center()
483            .gap(px(CHIP_GAP))
484            .cursor_pointer()
485            .on_drag(
486                DraggedField { field },
487                move |_drag, _offset, _window, cx| {
488                    cx.new(|_| DragGhost {
489                        label: ghost_label.clone(),
490                        bg: ghost_bg,
491                        fg: ghost_fg,
492                        border: ghost_border,
493                    })
494                },
495            )
496            .child(
497                div()
498                    .flex_1()
499                    .min_w(px(0.0))
500                    .truncate()
501                    .child(caption.clone()),
502            )
503            .child(
504                div()
505                    .flex_none()
506                    .cursor_pointer()
507                    .text_color(theme.sort_indicator)
508                    .child(arrow)
509                    .on_mouse_down(MouseButton::Left, {
510                        let state_toggle = state_toggle.clone();
511                        move |_e: &MouseDownEvent, _w, cx| {
512                            cx.stop_propagation();
513                            state_toggle.update(cx, |s, cx| {
514                                s.agg_menu_open = !s.agg_menu_open;
515                                cx.notify();
516                            });
517                        }
518                    }),
519            )
520            .child(
521                div()
522                    .flex_none()
523                    .text_color(theme.muted_text)
524                    .cursor_pointer()
525                    .child("✕")
526                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
527                        cx.stop_propagation();
528                        state_remove.update(cx, |s, cx| {
529                            s.config.remove_field(field);
530                            s.recompute();
531                            cx.notify();
532                        });
533                    }),
534            );
535
536        if chopped {
537            let tip_label: SharedString = caption.into();
538            let tip_bg = theme.menu_bg;
539            let tip_fg = theme.menu_fg;
540            let tip_border = theme.grid_line;
541            chip = chip.tooltip(move |_window, cx| {
542                cx.new(|_| ChipTooltip {
543                    label: tip_label.clone(),
544                    bg: tip_bg,
545                    fg: tip_fg,
546                    border: tip_border,
547                })
548                .into()
549            });
550        }
551
552        rows.push(chip.into_any_element());
553
554        if menu_open {
555            for func in AggregationFn::all() {
556                let selected = func == agg;
557                let state_pick = state.clone();
558                rows.push(
559                    div()
560                        .px(px(8.0))
561                        .py(px(2.0))
562                        .rounded(px(3.0))
563                        .bg(if selected {
564                            theme.menu_hover_bg
565                        } else {
566                            theme.menu_bg
567                        })
568                        .text_color(theme.menu_fg)
569                        .text_size(px(12.0))
570                        .cursor_pointer()
571                        .child(func.label())
572                        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
573                            state_pick.update(cx, |s, cx| {
574                                s.config.aggregation = func;
575                                s.agg_menu_open = false;
576                                s.recompute();
577                                cx.notify();
578                            });
579                        })
580                        .into_any_element(),
581                );
582            }
583        }
584
585        div()
586            .flex()
587            .flex_col()
588            .gap(px(2.0))
589            .children(rows)
590            .into_any_element()
591    }
592
593    /// A labelled checkbox row bound to one totals option.
594    fn option_row(
595        state: &Entity<PivotState>,
596        label: &'static str,
597        checked: bool,
598        apply: impl Fn(&mut PivotState) + 'static,
599        cx: &App,
600    ) -> gpui::AnyElement {
601        let theme = state.read(cx).theme.clone();
602        let state_toggle = state.clone();
603        let boxed = div()
604            .w(px(12.0))
605            .h(px(12.0))
606            .border_1()
607            .border_color(theme.grid_line)
608            .bg(theme.menu_bg)
609            .flex()
610            .items_center()
611            .justify_center()
612            .children(checked.then(|| div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator)));
613        div()
614            .flex()
615            .items_center()
616            .gap(px(6.0))
617            .cursor_pointer()
618            .text_size(px(12.0))
619            .text_color(theme.menu_fg)
620            .child(boxed)
621            .child(label)
622            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
623                state_toggle.update(cx, |s, cx| {
624                    apply(s);
625                    s.recompute();
626                    cx.notify();
627                });
628            })
629            .into_any_element()
630    }
631
632    /// Small inline text button.
633    fn text_button(
634        state: &Entity<PivotState>,
635        label: &'static str,
636        apply: impl Fn(&mut PivotState, &mut App) + 'static,
637        cx: &App,
638    ) -> gpui::AnyElement {
639        let theme = state.read(cx).theme.clone();
640        let state_btn = state.clone();
641        div()
642            .px(px(6.0))
643            .py(px(2.0))
644            .rounded(px(3.0))
645            .border_1()
646            .border_color(theme.grid_line)
647            .bg(theme.pivot_drop_zone_bg)
648            .text_color(theme.menu_fg)
649            .text_size(px(11.0))
650            .cursor_pointer()
651            .child(label)
652            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
653                state_btn.update(cx, |s, cx| {
654                    apply(s, cx);
655                    cx.notify();
656                });
657            })
658            .into_any_element()
659    }
660
661    /// The Filters-zone checklist popover overlay, if open.
662    fn render_filter_popover(
663        state: &Entity<PivotState>,
664        cx: &App,
665    ) -> Option<impl IntoElement + use<>> {
666        let s = state.read(cx);
667        let popover = s.filter_popover()?.clone();
668        let theme = s.theme.clone();
669        let field_name = s
670            .source_columns()
671            .get(popover.field)
672            .map(|c| c.name.clone())
673            .unwrap_or_default();
674
675        let checkbox = |checked: bool| {
676            let mut b = div()
677                .w(px(12.0))
678                .h(px(12.0))
679                .border_1()
680                .border_color(theme.grid_line)
681                .bg(theme.menu_bg)
682                .flex()
683                .items_center()
684                .justify_center();
685            if checked {
686                b = b.child(div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator));
687            }
688            b
689        };
690
691        let state_all = state.clone();
692        let select_all = div()
693            .h(px(22.0))
694            .flex()
695            .items_center()
696            .gap(px(6.0))
697            .cursor_pointer()
698            .child(checkbox(popover.all_checked()))
699            .child("(Select All)")
700            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
701                state_all.update(cx, |s, cx| {
702                    s.toggle_filter_popover_select_all();
703                    cx.notify();
704                });
705            });
706
707        let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
708        for (i, row) in popover
709            .rows
710            .iter()
711            .enumerate()
712            .take(FILTER_POPOVER_MAX_ROWS)
713        {
714            let state_val = state.clone();
715            value_rows.push(
716                div()
717                    .h(px(20.0))
718                    .flex()
719                    .items_center()
720                    .gap(px(6.0))
721                    .cursor_pointer()
722                    .child(checkbox(row.checked))
723                    .child(div().text_color(theme.menu_fg).child(row.label.clone()))
724                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
725                        state_val.update(cx, |s, cx| {
726                            s.toggle_filter_popover_value(i);
727                            cx.notify();
728                        });
729                    })
730                    .into_any_element(),
731            );
732        }
733        let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
734
735        let state_clear = state.clone();
736        let state_close = state.clone();
737        let clear_field = popover.field;
738        let buttons = div()
739            .flex()
740            .gap(px(6.0))
741            .child(
742                div()
743                    .flex_1()
744                    .h(px(24.0))
745                    .flex()
746                    .items_center()
747                    .justify_center()
748                    .border_1()
749                    .border_color(theme.grid_line)
750                    .bg(theme.menu_hover_bg)
751                    .cursor_pointer()
752                    .child("Clear")
753                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
754                        state_clear.update(cx, |s, cx| {
755                            s.clear_filter(clear_field);
756                            cx.notify();
757                        });
758                    }),
759            )
760            .child(
761                div()
762                    .flex_1()
763                    .h(px(24.0))
764                    .flex()
765                    .items_center()
766                    .justify_center()
767                    .border_1()
768                    .border_color(theme.grid_line)
769                    .bg(theme.menu_hover_bg)
770                    .cursor_pointer()
771                    .child("Close")
772                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
773                        state_close.update(cx, |s, cx| {
774                            s.close_filter_popover();
775                            cx.notify();
776                        });
777                    }),
778            );
779
780        let body = div()
781            .flex()
782            .flex_col()
783            .w(px(240.0))
784            .p(px(8.0))
785            .gap(px(6.0))
786            .bg(theme.menu_bg)
787            .border_1()
788            .border_color(theme.grid_line)
789            .text_color(theme.menu_fg)
790            .text_size(px(12.0))
791            .child(
792                div()
793                    .text_color(theme.muted_text)
794                    .text_size(px(11.0))
795                    .child(format!("Filter: {field_name}")),
796            )
797            .child(select_all)
798            .child(
799                div()
800                    .id("pivot-filter-values")
801                    .flex()
802                    .flex_col()
803                    .max_h(px(200.0))
804                    .overflow_y_scroll()
805                    .children(value_rows)
806                    .children(truncated.then(|| {
807                        div()
808                            .text_color(theme.muted_text)
809                            .text_size(px(11.0))
810                            .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
811                    })),
812            )
813            .child(buttons);
814
815        let state_backdrop = state.clone();
816        let overlay = deferred(
817            anchored()
818                .anchor(Corner::TopLeft)
819                .position(point(popover.anchor.x, popover.anchor.y))
820                .child(div().occlude().child(body).on_mouse_down_out(
821                    move |_e: &MouseDownEvent, _window, cx| {
822                        state_backdrop.update(cx, |s, cx| {
823                            if s.filter_popover().is_some() {
824                                s.close_filter_popover();
825                                cx.notify();
826                            }
827                        });
828                    },
829                )),
830        )
831        .with_priority(POPOVER_PRIORITY);
832        Some(overlay)
833    }
834}
835
836impl Render for PivotSidebar {
837    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
838        let (theme, columns, config, sidebar_width, save_handler) = {
839            let s = self.state.read(cx);
840            (
841                s.theme.clone(),
842                s.source_columns().to_vec(),
843                s.config.clone(),
844                s.sidebar_width,
845                s.save_config_handler.clone(),
846            )
847        };
848        let content_w = sidebar_width - SIDEBAR_CONTENT_CHROME;
849
850        // Source field list with zone badges.
851        let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
852        for (i, col) in columns.iter().enumerate() {
853            let badge = match config.zone_of(i) {
854                Some(PivotZone::Rows) => Some("R"),
855                Some(PivotZone::Columns) => Some("C"),
856                Some(PivotZone::Values) => Some("V"),
857                Some(PivotZone::Filters) => Some("F"),
858                None => None,
859            };
860            field_chips.push(Self::chip(
861                &self.state,
862                ("pivot-source-field", i),
863                i,
864                col.name.clone(),
865                None,
866                false,
867                badge,
868                content_w - CHIP_CHROME,
869                window,
870                cx,
871            ));
872        }
873
874        let options = div()
875            .flex()
876            .flex_col()
877            .gap(px(4.0))
878            .child(
879                div()
880                    .text_color(theme.muted_text)
881                    .text_size(px(11.0))
882                    .child("Totals"),
883            )
884            .child(Self::option_row(
885                &self.state,
886                "Row subtotals",
887                config.show_row_subtotals,
888                |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
889                cx,
890            ))
891            .child(Self::option_row(
892                &self.state,
893                "Column subtotal columns",
894                config.show_column_subtotals,
895                |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
896                cx,
897            ))
898            .child(Self::option_row(
899                &self.state,
900                "Grand total row",
901                config.show_row_grand_total,
902                |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
903                cx,
904            ))
905            .child(Self::option_row(
906                &self.state,
907                "Grand total column",
908                config.show_column_grand_total,
909                |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
910                cx,
911            ));
912
913        let tools = div()
914            .flex()
915            .flex_col()
916            .gap(px(4.0))
917            .child(
918                div()
919                    .text_color(theme.muted_text)
920                    .text_size(px(11.0))
921                    .child("Groups"),
922            )
923            .child(
924                div()
925                    .flex()
926                    .gap(px(4.0))
927                    .child(Self::text_button(
928                        &self.state,
929                        "Expand rows",
930                        |s, _| s.expand_all_rows(),
931                        cx,
932                    ))
933                    .child(Self::text_button(
934                        &self.state,
935                        "Collapse rows",
936                        |s, _| s.collapse_all_rows(),
937                        cx,
938                    )),
939            )
940            .child(
941                div()
942                    .flex()
943                    .gap(px(4.0))
944                    .child(Self::text_button(
945                        &self.state,
946                        "Expand cols",
947                        |s, _| s.expand_all_cols(),
948                        cx,
949                    ))
950                    .child(Self::text_button(
951                        &self.state,
952                        "Collapse cols",
953                        |s, _| s.collapse_all_cols(),
954                        cx,
955                    )),
956            )
957            .child(
958                div()
959                    .text_color(theme.muted_text)
960                    .text_size(px(11.0))
961                    .child("Export"),
962            )
963            .child(Self::text_button(
964                &self.state,
965                "Copy pivot as CSV",
966                |s, cx| {
967                    let csv = s.to_csv();
968                    if !csv.is_empty() {
969                        cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
970                    }
971                },
972                cx,
973            ));
974
975        let fields = div()
976            .flex()
977            .flex_col()
978            .gap(px(3.0))
979            .child(
980                div()
981                    .text_color(theme.muted_text)
982                    .text_size(px(11.0))
983                    .child("Drag a field into a layout zone"),
984            )
985            .child(
986                div()
987                    .id("pivot-field-list")
988                    .flex()
989                    .flex_col()
990                    .gap(px(3.0))
991                    .max_h(px(220.0))
992                    .overflow_y_scroll()
993                    .children(field_chips),
994            );
995
996        // Save-configuration button: only rendered while a host wired up the
997        // action (via `PivotState::on_save_config` or the widget builder).
998        let save_row = save_handler.map(|handler| {
999            let state_save = self.state.clone();
1000            let icon_color = theme.muted_text;
1001            let hover_bg = theme.menu_hover_bg;
1002            let tip_bg = theme.menu_bg;
1003            let tip_fg = theme.menu_fg;
1004            let tip_border = theme.grid_line;
1005            div().flex().justify_end().child(
1006                div()
1007                    .id("pivot-save-config")
1008                    .p(px(3.0))
1009                    .rounded(px(3.0))
1010                    .cursor_pointer()
1011                    .hover(move |style| style.bg(hover_bg))
1012                    .child(disk_icon(icon_color))
1013                    .tooltip(move |_window, cx| {
1014                        cx.new(|_| ChipTooltip {
1015                            label: "Save configuration".into(),
1016                            bg: tip_bg,
1017                            fg: tip_fg,
1018                            border: tip_border,
1019                        })
1020                        .into()
1021                    })
1022                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1023                        cx.stop_propagation();
1024                        let config = state_save.read(cx).config.clone();
1025                        handler(&config, cx);
1026                    }),
1027            )
1028        });
1029
1030        let layout = div()
1031            .flex()
1032            .flex_col()
1033            .gap(px(6.0))
1034            .children(save_row)
1035            .child(Self::zone(
1036                &self.state,
1037                PivotZone::Filters,
1038                "Filters",
1039                content_w,
1040                window,
1041                cx,
1042            ))
1043            .child(Self::zone(
1044                &self.state,
1045                PivotZone::Columns,
1046                "Columns",
1047                content_w,
1048                window,
1049                cx,
1050            ))
1051            .child(Self::zone(
1052                &self.state,
1053                PivotZone::Rows,
1054                "Rows",
1055                content_w,
1056                window,
1057                cx,
1058            ))
1059            .child(Self::zone(
1060                &self.state,
1061                PivotZone::Values,
1062                "Values",
1063                content_w,
1064                window,
1065                cx,
1066            ));
1067
1068        let display = div()
1069            .flex()
1070            .flex_col()
1071            .gap(px(10.0))
1072            .child(options)
1073            .child(tools);
1074
1075        let accordion_theme = AccordionTheme {
1076            header_bg: theme.header_bg.into(),
1077            header_hover_bg: theme.menu_hover_bg.into(),
1078            content_bg: theme.menu_bg.into(),
1079            border: theme.grid_line.into(),
1080            title_color: theme.header_fg.into(),
1081            indicator_color: theme.muted_text.into(),
1082        };
1083        let sidebar = cx.entity().clone();
1084        let accordion = Accordion::new()
1085            .mode(AccordionMode::Multiple)
1086            .expanded(self.expanded_sections.clone())
1087            .theme(accordion_theme)
1088            .item(AccordionItem::new("fields", "Fields").content(fields))
1089            .item(AccordionItem::new("layout", "Layout").content(layout))
1090            .item(AccordionItem::new("display", "Display and export").content(display))
1091            .on_change(move |id, expanded, _window, cx| {
1092                sidebar.update(cx, |this, cx| {
1093                    this.set_section_expanded(id, expanded);
1094                    cx.notify();
1095                });
1096            });
1097
1098        div()
1099            .id("pivot-sidebar")
1100            .h_full()
1101            .flex()
1102            .flex_col()
1103            .gap(px(8.0))
1104            .p(px(8.0))
1105            .bg(theme.menu_bg)
1106            .text_color(theme.menu_fg)
1107            .text_size(px(12.0))
1108            .overflow_y_scroll()
1109            .child(
1110                div()
1111                    .text_color(theme.header_fg)
1112                    .text_size(px(13.0))
1113                    .child("Pivot Controls"),
1114            )
1115            .child(accordion)
1116            .children(Self::render_filter_popover(&self.state, cx))
1117    }
1118}