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). Double-clicking a zone chip
362        // opens the per-field format dialog.
363        if let Some(zone) = zone {
364            let state_dialog = state.clone();
365            chip = chip
366                .on_drop::<DraggedField>(move |dragged, _window, cx| {
367                    cx.stop_propagation();
368                    let dragged_field = dragged.field;
369                    state_drop.update(cx, |s, cx| {
370                        let index = s.config.fields_in(zone).iter().position(|&f| f == field);
371                        s.config.move_field(dragged_field, zone, index);
372                        s.recompute();
373                        cx.notify();
374                    });
375                })
376                .on_mouse_down(MouseButton::Left, move |e: &MouseDownEvent, _w, cx| {
377                    if e.click_count == 2 {
378                        cx.stop_propagation();
379                        state_dialog.update(cx, |s, cx| {
380                            s.close_filter_popover();
381                            s.open_format_dialog(field, zone, e.position);
382                            cx.notify();
383                        });
384                    }
385                });
386        }
387
388        chip.into_any_element()
389    }
390
391    /// One drop zone with its label, hint, and assigned chips. `content_w` is
392    /// the accordion content width available to the zone box.
393    fn zone(
394        state: &Entity<PivotState>,
395        zone: PivotZone,
396        title: &'static str,
397        content_w: f32,
398        window: &Window,
399        cx: &App,
400    ) -> gpui::AnyElement {
401        let s = state.read(cx);
402        let theme = s.theme.clone();
403        let fields = s.config.fields_in(zone);
404        let columns = s.source_columns().to_vec();
405        let agg = s.config.aggregation;
406        let agg_menu_open = s.agg_menu_open;
407
408        let state_drop = state.clone();
409        let active_bg = theme.pivot_drop_zone_active_bg;
410        let label_budget = content_w - ZONE_CHROME - CHIP_CHROME;
411
412        let mut chips: Vec<gpui::AnyElement> = Vec::new();
413        for (i, &field) in fields.iter().enumerate() {
414            let name = columns
415                .get(field)
416                .map(|c| c.name.clone())
417                .unwrap_or_else(|| format!("column {field}"));
418            match zone {
419                PivotZone::Values => {
420                    chips.push(Self::values_chip(
421                        state,
422                        field,
423                        agg,
424                        agg_menu_open,
425                        name,
426                        label_budget,
427                        window,
428                        cx,
429                    ));
430                }
431                PivotZone::Filters => {
432                    let filter_on = s.filter_active(field);
433                    let state_open = state.clone();
434                    let chip = div()
435                        .id(("pivot-filter-chip", i))
436                        .on_mouse_down(MouseButton::Left, {
437                            let state_open = state_open.clone();
438                            move |e: &MouseDownEvent, _w, cx| {
439                                // Double-clicks open the format dialog (see
440                                // the inner chip); only single clicks open
441                                // the checklist. The popover is anchored a
442                                // little below the cursor so the second
443                                // click of a double-click still reaches the
444                                // chip instead of the freshly opened popover.
445                                if e.click_count != 1 {
446                                    return;
447                                }
448                                let anchor = point(e.position.x, e.position.y + px(20.0));
449                                state_open.update(cx, |s, cx| {
450                                    s.open_filter_popover(field, anchor);
451                                    cx.notify();
452                                });
453                            }
454                        })
455                        .child(Self::chip(
456                            state,
457                            ("pivot-filter-chip-inner", i),
458                            field,
459                            name,
460                            Some(zone),
461                            true,
462                            filter_on.then_some("●"),
463                            label_budget,
464                            window,
465                            cx,
466                        ));
467                    chips.push(chip.into_any_element());
468                }
469                PivotZone::Rows | PivotZone::Columns => {
470                    let id = match zone {
471                        PivotZone::Rows => ("pivot-row-chip", i),
472                        _ => ("pivot-col-chip", i),
473                    };
474                    chips.push(Self::chip(
475                        state,
476                        id,
477                        field,
478                        name,
479                        Some(zone),
480                        true,
481                        None,
482                        label_budget,
483                        window,
484                        cx,
485                    ));
486                }
487            }
488        }
489
490        let hint = if chips.is_empty() {
491            Some(
492                div()
493                    .text_color(theme.muted_text)
494                    .text_size(px(11.0))
495                    .child("Drag fields here"),
496            )
497        } else {
498            None
499        };
500
501        div()
502            .flex()
503            .flex_col()
504            .gap(px(4.0))
505            .child(
506                div()
507                    .text_color(theme.muted_text)
508                    .text_size(px(11.0))
509                    .child(title),
510            )
511            .child(
512                div()
513                    .min_h(px(40.0))
514                    .p(px(6.0))
515                    .rounded(px(4.0))
516                    .bg(theme.pivot_drop_zone_bg)
517                    .border_1()
518                    .border_color(theme.grid_line)
519                    .flex()
520                    .flex_col()
521                    .gap(px(4.0))
522                    .drag_over::<DraggedField>(move |style, _, _, _| style.bg(active_bg))
523                    .on_drop::<DraggedField>(move |dragged, _window, cx| {
524                        let dragged_field = dragged.field;
525                        state_drop.update(cx, |s, cx| {
526                            s.config.move_field(dragged_field, zone, None);
527                            s.recompute();
528                            cx.notify();
529                        });
530                    })
531                    .children(chips)
532                    .children(hint),
533            )
534            .into_any_element()
535    }
536
537    /// The Values-zone chip: caption plus the aggregation picker. The caption
538    /// is ellipsized so both the picker arrow and the remove button always
539    /// fit; a hover tooltip shows the full caption when chopped.
540    #[allow(clippy::too_many_arguments)]
541    fn values_chip(
542        state: &Entity<PivotState>,
543        field: usize,
544        agg: AggregationFn,
545        menu_open: bool,
546        name: String,
547        label_budget: f32,
548        window: &Window,
549        cx: &App,
550    ) -> gpui::AnyElement {
551        let s = state.read(cx);
552        let theme = s.theme.clone();
553        let state_toggle = state.clone();
554        let state_remove = state.clone();
555
556        let mut rows: Vec<gpui::AnyElement> = Vec::new();
557        let caption = agg.caption(&name);
558        let ghost_label = caption.clone();
559        let ghost_bg = theme.pivot_chip_bg;
560        let ghost_fg = theme.pivot_chip_fg;
561        let ghost_border = theme.grid_line;
562
563        let arrow = if menu_open { "▲" } else { "▼" };
564        let available = label_budget
565            - (CHIP_GAP + measure_text(window, arrow, CHIP_FONT_SIZE))
566            - (CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE));
567        let chopped = measure_text(window, &caption, CHIP_FONT_SIZE) > available + 0.5;
568
569        let mut chip = div()
570            .id("pivot-values-chip")
571            .px(px(8.0))
572            .py(px(3.0))
573            .rounded(px(4.0))
574            .bg(theme.pivot_chip_bg)
575            .border_1()
576            .border_color(theme.grid_line)
577            .text_color(theme.pivot_chip_fg)
578            .text_size(px(CHIP_FONT_SIZE))
579            .flex()
580            .items_center()
581            .gap(px(CHIP_GAP))
582            .cursor_pointer()
583            .on_drag(
584                DraggedField { field },
585                move |_drag, _offset, _window, cx| {
586                    cx.new(|_| DragGhost {
587                        label: ghost_label.clone(),
588                        bg: ghost_bg,
589                        fg: ghost_fg,
590                        border: ghost_border,
591                    })
592                },
593            )
594            .child(
595                div()
596                    .flex_1()
597                    .min_w(px(0.0))
598                    .truncate()
599                    .child(caption.clone()),
600            )
601            .child(
602                div()
603                    .flex_none()
604                    .cursor_pointer()
605                    .text_color(theme.sort_indicator)
606                    .child(arrow)
607                    .on_mouse_down(MouseButton::Left, {
608                        let state_toggle = state_toggle.clone();
609                        move |_e: &MouseDownEvent, _w, cx| {
610                            cx.stop_propagation();
611                            state_toggle.update(cx, |s, cx| {
612                                s.agg_menu_open = !s.agg_menu_open;
613                                cx.notify();
614                            });
615                        }
616                    }),
617            )
618            .child(
619                div()
620                    .flex_none()
621                    .text_color(theme.muted_text)
622                    .cursor_pointer()
623                    .child("✕")
624                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
625                        cx.stop_propagation();
626                        state_remove.update(cx, |s, cx| {
627                            s.config.remove_field(field);
628                            s.recompute();
629                            cx.notify();
630                        });
631                    }),
632            );
633
634        if chopped {
635            let tip_label: SharedString = caption.into();
636            let tip_bg = theme.menu_bg;
637            let tip_fg = theme.menu_fg;
638            let tip_border = theme.grid_line;
639            chip = chip.tooltip(move |_window, cx| {
640                cx.new(|_| ChipTooltip {
641                    label: tip_label.clone(),
642                    bg: tip_bg,
643                    fg: tip_fg,
644                    border: tip_border,
645                })
646                .into()
647            });
648        }
649
650        let state_dialog = state.clone();
651        chip = chip.on_mouse_down(MouseButton::Left, move |e: &MouseDownEvent, _w, cx| {
652            if e.click_count == 2 {
653                cx.stop_propagation();
654                state_dialog.update(cx, |s, cx| {
655                    s.open_format_dialog(field, PivotZone::Values, e.position);
656                    cx.notify();
657                });
658            }
659        });
660
661        rows.push(chip.into_any_element());
662
663        if menu_open {
664            for func in AggregationFn::all() {
665                let selected = func == agg;
666                let state_pick = state.clone();
667                rows.push(
668                    div()
669                        .px(px(8.0))
670                        .py(px(2.0))
671                        .rounded(px(3.0))
672                        .bg(if selected {
673                            theme.menu_hover_bg
674                        } else {
675                            theme.menu_bg
676                        })
677                        .text_color(theme.menu_fg)
678                        .text_size(px(12.0))
679                        .cursor_pointer()
680                        .child(func.label())
681                        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
682                            state_pick.update(cx, |s, cx| {
683                                s.config.aggregation = func;
684                                s.agg_menu_open = false;
685                                s.recompute();
686                                cx.notify();
687                            });
688                        })
689                        .into_any_element(),
690                );
691            }
692        }
693
694        div()
695            .flex()
696            .flex_col()
697            .gap(px(2.0))
698            .children(rows)
699            .into_any_element()
700    }
701
702    /// A labelled checkbox row bound to one totals option.
703    fn option_row(
704        state: &Entity<PivotState>,
705        label: &'static str,
706        checked: bool,
707        apply: impl Fn(&mut PivotState) + 'static,
708        cx: &App,
709    ) -> gpui::AnyElement {
710        let theme = state.read(cx).theme.clone();
711        let state_toggle = state.clone();
712        let boxed = div()
713            .w(px(12.0))
714            .h(px(12.0))
715            .border_1()
716            .border_color(theme.grid_line)
717            .bg(theme.menu_bg)
718            .flex()
719            .items_center()
720            .justify_center()
721            .children(checked.then(|| div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator)));
722        div()
723            .flex()
724            .items_center()
725            .gap(px(6.0))
726            .cursor_pointer()
727            .text_size(px(12.0))
728            .text_color(theme.menu_fg)
729            .child(boxed)
730            .child(label)
731            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
732                state_toggle.update(cx, |s, cx| {
733                    apply(s);
734                    s.recompute();
735                    cx.notify();
736                });
737            })
738            .into_any_element()
739    }
740
741    /// Small inline text button.
742    fn text_button(
743        state: &Entity<PivotState>,
744        label: &'static str,
745        apply: impl Fn(&mut PivotState, &mut App) + 'static,
746        cx: &App,
747    ) -> gpui::AnyElement {
748        let theme = state.read(cx).theme.clone();
749        let state_btn = state.clone();
750        div()
751            .px(px(6.0))
752            .py(px(2.0))
753            .rounded(px(3.0))
754            .border_1()
755            .border_color(theme.grid_line)
756            .bg(theme.pivot_drop_zone_bg)
757            .text_color(theme.menu_fg)
758            .text_size(px(11.0))
759            .cursor_pointer()
760            .child(label)
761            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
762                state_btn.update(cx, |s, cx| {
763                    apply(s, cx);
764                    cx.notify();
765                });
766            })
767            .into_any_element()
768    }
769
770    /// The Filters-zone checklist popover overlay, if open.
771    fn render_filter_popover(
772        state: &Entity<PivotState>,
773        cx: &App,
774    ) -> Option<impl IntoElement + use<>> {
775        let s = state.read(cx);
776        let popover = s.filter_popover()?.clone();
777        let theme = s.theme.clone();
778        let field_name = s
779            .source_columns()
780            .get(popover.field)
781            .map(|c| c.name.clone())
782            .unwrap_or_default();
783
784        let checkbox = |checked: bool| {
785            let mut b = div()
786                .w(px(12.0))
787                .h(px(12.0))
788                .border_1()
789                .border_color(theme.grid_line)
790                .bg(theme.menu_bg)
791                .flex()
792                .items_center()
793                .justify_center();
794            if checked {
795                b = b.child(div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator));
796            }
797            b
798        };
799
800        let state_all = state.clone();
801        let select_all = div()
802            .h(px(22.0))
803            .flex()
804            .items_center()
805            .gap(px(6.0))
806            .cursor_pointer()
807            .child(checkbox(popover.all_checked()))
808            .child("(Select All)")
809            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
810                state_all.update(cx, |s, cx| {
811                    s.toggle_filter_popover_select_all();
812                    cx.notify();
813                });
814            });
815
816        let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
817        for (i, row) in popover
818            .rows
819            .iter()
820            .enumerate()
821            .take(FILTER_POPOVER_MAX_ROWS)
822        {
823            let state_val = state.clone();
824            value_rows.push(
825                div()
826                    .h(px(20.0))
827                    .flex()
828                    .items_center()
829                    .gap(px(6.0))
830                    .cursor_pointer()
831                    .child(checkbox(row.checked))
832                    .child(div().text_color(theme.menu_fg).child(row.label.clone()))
833                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
834                        state_val.update(cx, |s, cx| {
835                            s.toggle_filter_popover_value(i);
836                            cx.notify();
837                        });
838                    })
839                    .into_any_element(),
840            );
841        }
842        let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
843
844        let state_clear = state.clone();
845        let state_close = state.clone();
846        let clear_field = popover.field;
847        let buttons = div()
848            .flex()
849            .gap(px(6.0))
850            .child(
851                div()
852                    .flex_1()
853                    .h(px(24.0))
854                    .flex()
855                    .items_center()
856                    .justify_center()
857                    .border_1()
858                    .border_color(theme.grid_line)
859                    .bg(theme.menu_hover_bg)
860                    .cursor_pointer()
861                    .child("Clear")
862                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
863                        state_clear.update(cx, |s, cx| {
864                            s.clear_filter(clear_field);
865                            cx.notify();
866                        });
867                    }),
868            )
869            .child(
870                div()
871                    .flex_1()
872                    .h(px(24.0))
873                    .flex()
874                    .items_center()
875                    .justify_center()
876                    .border_1()
877                    .border_color(theme.grid_line)
878                    .bg(theme.menu_hover_bg)
879                    .cursor_pointer()
880                    .child("Close")
881                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
882                        state_close.update(cx, |s, cx| {
883                            s.close_filter_popover();
884                            cx.notify();
885                        });
886                    }),
887            );
888
889        let body = div()
890            .flex()
891            .flex_col()
892            .w(px(240.0))
893            .p(px(8.0))
894            .gap(px(6.0))
895            .bg(theme.menu_bg)
896            .border_1()
897            .border_color(theme.grid_line)
898            .text_color(theme.menu_fg)
899            .text_size(px(12.0))
900            .child(
901                div()
902                    .text_color(theme.muted_text)
903                    .text_size(px(11.0))
904                    .child(format!("Filter: {field_name}")),
905            )
906            .child(select_all)
907            .child(
908                div()
909                    .id("pivot-filter-values")
910                    .flex()
911                    .flex_col()
912                    .max_h(px(200.0))
913                    .overflow_y_scroll()
914                    .children(value_rows)
915                    .children(truncated.then(|| {
916                        div()
917                            .text_color(theme.muted_text)
918                            .text_size(px(11.0))
919                            .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
920                    })),
921            )
922            .child(buttons);
923
924        let state_backdrop = state.clone();
925        let overlay = deferred(
926            anchored()
927                .anchor(Corner::TopLeft)
928                .position(point(popover.anchor.x, popover.anchor.y))
929                .child(div().occlude().child(body).on_mouse_down_out(
930                    move |_e: &MouseDownEvent, _window, cx| {
931                        state_backdrop.update(cx, |s, cx| {
932                            if s.filter_popover().is_some() {
933                                s.close_filter_popover();
934                                cx.notify();
935                            }
936                        });
937                    },
938                )),
939        )
940        .with_priority(POPOVER_PRIORITY);
941        Some(overlay)
942    }
943
944    /// The per-field format dialog overlay (chip double-click), if open.
945    /// Every control applies immediately; "Reset" drops the override.
946    #[allow(clippy::too_many_lines)]
947    fn render_format_dialog(
948        state: &Entity<PivotState>,
949        cx: &App,
950    ) -> Option<impl IntoElement + use<>> {
951        use crate::config::{NumberFormat, TextAlignment};
952
953        let s = state.read(cx);
954        let dialog = *s.format_dialog()?;
955        let fmt = s.format_dialog_format()?;
956        let theme = s.theme.clone();
957        let field_name = s
958            .source_columns()
959            .get(dialog.field)
960            .map(|c| c.name.clone())
961            .unwrap_or_default();
962
963        let c_bg = theme.menu_bg;
964        let c_line = theme.grid_line;
965        let c_fg = theme.menu_fg;
966        let c_muted = theme.muted_text;
967        let c_accent = theme.sort_indicator;
968        let c_hover = theme.menu_hover_bg;
969
970        let checkbox = move |checked: bool| {
971            let mut b = div()
972                .w(px(12.0))
973                .h(px(12.0))
974                .border_1()
975                .border_color(c_line)
976                .bg(c_bg)
977                .flex()
978                .items_center()
979                .justify_center();
980            if checked {
981                b = b.child(div().w(px(6.0)).h(px(6.0)).bg(c_accent));
982            }
983            b
984        };
985
986        let check_row = |label: &'static str, checked: bool, apply: fn(&mut NumberFormat)| {
987            let st = state.clone();
988            div()
989                .h(px(22.0))
990                .flex()
991                .items_center()
992                .gap(px(6.0))
993                .cursor_pointer()
994                .child(checkbox(checked))
995                .child(label)
996                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
997                    st.update(cx, |s, cx| {
998                        s.update_format_dialog(apply);
999                        cx.notify();
1000                    });
1001                })
1002        };
1003
1004        // Segmented pair choosing how negatives are written.
1005        let neg_btn = |label: &'static str, parens: bool| {
1006            let st = state.clone();
1007            let selected = fmt.negative_parentheses == parens;
1008            div()
1009                .flex_1()
1010                .h(px(22.0))
1011                .flex()
1012                .items_center()
1013                .justify_center()
1014                .border_1()
1015                .border_color(c_line)
1016                .bg(if selected { c_accent } else { c_hover })
1017                .cursor_pointer()
1018                .child(label)
1019                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1020                    st.update(cx, |s, cx| {
1021                        s.update_format_dialog(|f| f.negative_parentheses = parens);
1022                        cx.notify();
1023                    });
1024                })
1025        };
1026
1027        let dec_btn = |label: &'static str, apply: fn(&mut NumberFormat)| {
1028            let st = state.clone();
1029            div()
1030                .w(px(20.0))
1031                .h(px(20.0))
1032                .flex()
1033                .items_center()
1034                .justify_center()
1035                .border_1()
1036                .border_color(c_line)
1037                .bg(c_hover)
1038                .cursor_pointer()
1039                .child(label)
1040                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1041                    st.update(cx, |s, cx| {
1042                        s.update_format_dialog(apply);
1043                        cx.notify();
1044                    });
1045                })
1046        };
1047
1048        let align_btn = |label: &'static str, value: TextAlignment| {
1049            let st = state.clone();
1050            let selected = fmt.alignment == value;
1051            div()
1052                .flex_1()
1053                .h(px(22.0))
1054                .flex()
1055                .items_center()
1056                .justify_center()
1057                .border_1()
1058                .border_color(c_line)
1059                .bg(if selected { c_accent } else { c_hover })
1060                .cursor_pointer()
1061                .child(label)
1062                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1063                    st.update(cx, |s, cx| {
1064                        s.update_format_dialog(move |f| f.alignment = value);
1065                        cx.notify();
1066                    });
1067                })
1068        };
1069
1070        let st_reset = state.clone();
1071        let st_close = state.clone();
1072        let buttons = div()
1073            .flex()
1074            .gap(px(6.0))
1075            .child(
1076                div()
1077                    .flex_1()
1078                    .h(px(24.0))
1079                    .flex()
1080                    .items_center()
1081                    .justify_center()
1082                    .border_1()
1083                    .border_color(c_line)
1084                    .bg(c_hover)
1085                    .cursor_pointer()
1086                    .child("Reset")
1087                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1088                        st_reset.update(cx, |s, cx| {
1089                            s.reset_format_dialog();
1090                            cx.notify();
1091                        });
1092                    }),
1093            )
1094            .child(
1095                div()
1096                    .flex_1()
1097                    .h(px(24.0))
1098                    .flex()
1099                    .items_center()
1100                    .justify_center()
1101                    .border_1()
1102                    .border_color(c_line)
1103                    .bg(c_hover)
1104                    .cursor_pointer()
1105                    .child("Close")
1106                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1107                        st_close.update(cx, |s, cx| {
1108                            s.close_format_dialog();
1109                            cx.notify();
1110                        });
1111                    }),
1112            );
1113
1114        let body = div()
1115            .flex()
1116            .flex_col()
1117            .w(px(230.0))
1118            .p(px(8.0))
1119            .gap(px(6.0))
1120            .bg(c_bg)
1121            .border_1()
1122            .border_color(c_line)
1123            .text_color(c_fg)
1124            .text_size(px(12.0))
1125            .child(
1126                div()
1127                    .text_color(c_muted)
1128                    .text_size(px(11.0))
1129                    .child(format!("Format: {field_name}")),
1130            )
1131            .child(check_row(
1132                "Negative numbers in red",
1133                fmt.show_negative_red,
1134                |f| f.show_negative_red = !f.show_negative_red,
1135            ))
1136            .child(check_row(
1137                "Thousands separator",
1138                fmt.thousands_separator,
1139                |f| f.thousands_separator = !f.thousands_separator,
1140            ))
1141            .child(
1142                div()
1143                    .text_color(c_muted)
1144                    .text_size(px(11.0))
1145                    .child("Negatives"),
1146            )
1147            .child(
1148                div()
1149                    .flex()
1150                    .gap(px(6.0))
1151                    .child(neg_btn("-1,234", false))
1152                    .child(neg_btn("(1,234)", true)),
1153            )
1154            .child(
1155                div()
1156                    .h(px(22.0))
1157                    .flex()
1158                    .items_center()
1159                    .gap(px(6.0))
1160                    .child(
1161                        div()
1162                            .text_color(c_muted)
1163                            .text_size(px(11.0))
1164                            .child("Decimals"),
1165                    )
1166                    .child(dec_btn("-", |f| f.decimals = f.decimals.saturating_sub(1)))
1167                    .child(
1168                        div()
1169                            .w(px(18.0))
1170                            .flex()
1171                            .justify_center()
1172                            .child(fmt.decimals.to_string()),
1173                    )
1174                    .child(dec_btn("+", |f| f.decimals = (f.decimals + 1).min(8))),
1175            )
1176            .child(
1177                div()
1178                    .text_color(c_muted)
1179                    .text_size(px(11.0))
1180                    .child("Alignment"),
1181            )
1182            .child(
1183                div()
1184                    .flex()
1185                    .gap(px(6.0))
1186                    .child(align_btn("Left", TextAlignment::Left))
1187                    .child(align_btn("Center", TextAlignment::Center))
1188                    .child(align_btn("Right", TextAlignment::Right)),
1189            )
1190            .child(buttons);
1191
1192        let state_backdrop = state.clone();
1193        let overlay = deferred(
1194            anchored()
1195                .anchor(Corner::TopLeft)
1196                .position(point(dialog.anchor.x, dialog.anchor.y))
1197                .child(div().occlude().child(body).on_mouse_down_out(
1198                    move |_e: &MouseDownEvent, _window, cx| {
1199                        state_backdrop.update(cx, |s, cx| {
1200                            if s.format_dialog().is_some() {
1201                                s.close_format_dialog();
1202                                cx.notify();
1203                            }
1204                        });
1205                    },
1206                )),
1207        )
1208        .with_priority(POPOVER_PRIORITY);
1209        Some(overlay)
1210    }
1211}
1212
1213impl Render for PivotSidebar {
1214    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1215        let (theme, columns, config, sidebar_width, save_handler) = {
1216            let s = self.state.read(cx);
1217            (
1218                s.theme.clone(),
1219                s.source_columns().to_vec(),
1220                s.config.clone(),
1221                s.sidebar_width,
1222                s.save_config_handler.clone(),
1223            )
1224        };
1225        let content_w = sidebar_width - SIDEBAR_CONTENT_CHROME;
1226
1227        // Source field list with zone badges.
1228        let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
1229        for (i, col) in columns.iter().enumerate() {
1230            let badge = match config.zone_of(i) {
1231                Some(PivotZone::Rows) => Some("R"),
1232                Some(PivotZone::Columns) => Some("C"),
1233                Some(PivotZone::Values) => Some("V"),
1234                Some(PivotZone::Filters) => Some("F"),
1235                None => None,
1236            };
1237            field_chips.push(Self::chip(
1238                &self.state,
1239                ("pivot-source-field", i),
1240                i,
1241                col.name.clone(),
1242                None,
1243                false,
1244                badge,
1245                content_w - CHIP_CHROME,
1246                window,
1247                cx,
1248            ));
1249        }
1250
1251        let options = div()
1252            .flex()
1253            .flex_col()
1254            .gap(px(4.0))
1255            .child(
1256                div()
1257                    .text_color(theme.muted_text)
1258                    .text_size(px(11.0))
1259                    .child("Totals"),
1260            )
1261            .child(Self::option_row(
1262                &self.state,
1263                "Row subtotals",
1264                config.show_row_subtotals,
1265                |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
1266                cx,
1267            ))
1268            .child(Self::option_row(
1269                &self.state,
1270                "Column subtotal columns",
1271                config.show_column_subtotals,
1272                |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
1273                cx,
1274            ))
1275            .child(Self::option_row(
1276                &self.state,
1277                "Grand total row",
1278                config.show_row_grand_total,
1279                |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
1280                cx,
1281            ))
1282            .child(Self::option_row(
1283                &self.state,
1284                "Grand total column",
1285                config.show_column_grand_total,
1286                |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
1287                cx,
1288            ));
1289
1290        let tools = div()
1291            .flex()
1292            .flex_col()
1293            .gap(px(4.0))
1294            .child(
1295                div()
1296                    .text_color(theme.muted_text)
1297                    .text_size(px(11.0))
1298                    .child("Groups"),
1299            )
1300            .child(
1301                div()
1302                    .flex()
1303                    .gap(px(4.0))
1304                    .child(Self::text_button(
1305                        &self.state,
1306                        "Expand rows",
1307                        |s, _| s.expand_all_rows(),
1308                        cx,
1309                    ))
1310                    .child(Self::text_button(
1311                        &self.state,
1312                        "Collapse rows",
1313                        |s, _| s.collapse_all_rows(),
1314                        cx,
1315                    )),
1316            )
1317            .child(
1318                div()
1319                    .flex()
1320                    .gap(px(4.0))
1321                    .child(Self::text_button(
1322                        &self.state,
1323                        "Expand cols",
1324                        |s, _| s.expand_all_cols(),
1325                        cx,
1326                    ))
1327                    .child(Self::text_button(
1328                        &self.state,
1329                        "Collapse cols",
1330                        |s, _| s.collapse_all_cols(),
1331                        cx,
1332                    )),
1333            )
1334            .child(
1335                div()
1336                    .text_color(theme.muted_text)
1337                    .text_size(px(11.0))
1338                    .child("Export"),
1339            )
1340            .child(Self::text_button(
1341                &self.state,
1342                "Copy pivot as CSV",
1343                |s, cx| {
1344                    let csv = s.to_csv();
1345                    if !csv.is_empty() {
1346                        cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
1347                    }
1348                },
1349                cx,
1350            ));
1351
1352        let fields = div()
1353            .flex()
1354            .flex_col()
1355            .gap(px(3.0))
1356            .child(
1357                div()
1358                    .text_color(theme.muted_text)
1359                    .text_size(px(11.0))
1360                    .child("Drag a field into a layout zone"),
1361            )
1362            .child(
1363                div()
1364                    .id("pivot-field-list")
1365                    .flex()
1366                    .flex_col()
1367                    .gap(px(3.0))
1368                    .max_h(px(220.0))
1369                    .overflow_y_scroll()
1370                    .children(field_chips),
1371            );
1372
1373        // Save-configuration button, rendered in the Layout section header
1374        // next to its title. Only present while a host wired up the action
1375        // (via `PivotState::on_save_config` or the widget builder).
1376        let save_button = save_handler.map(|handler| {
1377            let state_save = self.state.clone();
1378            let icon_color = theme.muted_text;
1379            let hover_bg = theme.pivot_drop_zone_active_bg;
1380            let tip_bg = theme.menu_bg;
1381            let tip_fg = theme.menu_fg;
1382            let tip_border = theme.grid_line;
1383            div()
1384                .id("pivot-save-config")
1385                .p(px(2.0))
1386                .rounded(px(3.0))
1387                .cursor_pointer()
1388                .hover(move |style| style.bg(hover_bg))
1389                .child(disk_icon(icon_color))
1390                .tooltip(move |_window, cx| {
1391                    cx.new(|_| ChipTooltip {
1392                        label: "Save configuration".into(),
1393                        bg: tip_bg,
1394                        fg: tip_fg,
1395                        border: tip_border,
1396                    })
1397                    .into()
1398                })
1399                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1400                    cx.stop_propagation();
1401                    let config = state_save.read(cx).config.clone();
1402                    handler(&config, cx);
1403                })
1404                // The section header toggles on mouse-up; swallow it so a
1405                // click on the save button doesn't also collapse the section.
1406                .on_mouse_up(MouseButton::Left, |_e: &MouseUpEvent, _w, cx| {
1407                    cx.stop_propagation();
1408                })
1409                .into_any_element()
1410        });
1411
1412        let layout = div()
1413            .flex()
1414            .flex_col()
1415            .gap(px(6.0))
1416            .child(Self::zone(
1417                &self.state,
1418                PivotZone::Filters,
1419                "Filters",
1420                content_w,
1421                window,
1422                cx,
1423            ))
1424            .child(Self::zone(
1425                &self.state,
1426                PivotZone::Columns,
1427                "Columns",
1428                content_w,
1429                window,
1430                cx,
1431            ))
1432            .child(Self::zone(
1433                &self.state,
1434                PivotZone::Rows,
1435                "Rows",
1436                content_w,
1437                window,
1438                cx,
1439            ))
1440            .child(Self::zone(
1441                &self.state,
1442                PivotZone::Values,
1443                "Values",
1444                content_w,
1445                window,
1446                cx,
1447            ));
1448
1449        let display = div()
1450            .flex()
1451            .flex_col()
1452            .gap(px(10.0))
1453            .child(options)
1454            .child(tools);
1455
1456        let sidebar = cx.entity().clone();
1457        let is_expanded = |id: &str| self.expanded_sections.iter().any(|section| section == id);
1458        let sections = div()
1459            .flex()
1460            .flex_col()
1461            .border_1()
1462            .border_color(theme.grid_line)
1463            .rounded_lg()
1464            .child(Self::section(
1465                &sidebar,
1466                &theme,
1467                "fields",
1468                "Fields",
1469                None,
1470                is_expanded("fields"),
1471                true,
1472                fields.into_any_element(),
1473            ))
1474            .child(Self::section(
1475                &sidebar,
1476                &theme,
1477                "layout",
1478                "Layout",
1479                save_button,
1480                is_expanded("layout"),
1481                false,
1482                layout.into_any_element(),
1483            ))
1484            .child(Self::section(
1485                &sidebar,
1486                &theme,
1487                "display",
1488                "Display and export",
1489                None,
1490                is_expanded("display"),
1491                false,
1492                display.into_any_element(),
1493            ));
1494
1495        div()
1496            .id("pivot-sidebar")
1497            .h_full()
1498            .flex()
1499            .flex_col()
1500            .gap(px(8.0))
1501            .p(px(8.0))
1502            .bg(theme.menu_bg)
1503            .text_color(theme.menu_fg)
1504            .text_size(px(12.0))
1505            .overflow_y_scroll()
1506            .child(
1507                div()
1508                    .text_color(theme.header_fg)
1509                    .text_size(px(13.0))
1510                    .child("Pivot Controls"),
1511            )
1512            .child(sections)
1513            .children(Self::render_filter_popover(&self.state, cx))
1514            .children(Self::render_format_dialog(&self.state, cx))
1515    }
1516}