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
37/// Fixed outer height of every field chip (source list, zone chips, drag
38/// ghost). An explicit integer height keeps the stacked-chip rhythm exact:
39/// with padding-derived heights the fractional text line height made the
40/// 3px list gaps render as anything from 2px to 4px.
41const CHIP_HEIGHT: f32 = 24.0;
42/// Chip label font size.
43const CHIP_FONT_SIZE: f32 = 12.0;
44/// Zone-marker / badge font size.
45const MARKER_FONT_SIZE: f32 = 11.0;
46
47/// Measured pixel width of `text` at `size` in the window's default UI font.
48fn measure_text(window: &Window, text: &str, size: f32) -> f32 {
49    if text.is_empty() {
50        return 0.0;
51    }
52    let run = gpui::TextRun {
53        len: text.len(),
54        color: gpui::Hsla::default(),
55        font: window.text_style().font(),
56        background_color: None,
57        underline: None,
58        strikethrough: None,
59    };
60    let line = window.text_system().shape_line(
61        SharedString::from(text.to_owned()),
62        px(size),
63        &[run],
64        None,
65    );
66    f32::from(line.width)
67}
68
69/// Hover tooltip showing a chip's full label when its text is chopped.
70struct ChipTooltip {
71    label: SharedString,
72    bg: gpui::Hsla,
73    fg: gpui::Hsla,
74    border: gpui::Hsla,
75}
76
77impl Render for ChipTooltip {
78    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
79        div()
80            .px(px(8.0))
81            .py(px(4.0))
82            .rounded(px(4.0))
83            .bg(self.bg)
84            .border_1()
85            .border_color(self.border)
86            .text_color(self.fg)
87            .text_size(px(CHIP_FONT_SIZE))
88            .child(self.label.clone())
89    }
90}
91
92/// A little floppy-disk glyph drawn with divs (no icon font dependency).
93fn disk_icon(color: gpui::Hsla) -> gpui::Div {
94    div()
95        .w(px(13.0))
96        .h(px(13.0))
97        .rounded(px(2.0))
98        .border_1()
99        .border_color(color)
100        .relative()
101        .child(
102            div()
103                .absolute()
104                .top(px(0.0))
105                .left(px(3.0))
106                .w(px(5.0))
107                .h(px(4.0))
108                .border_1()
109                .border_color(color),
110        )
111        .child(
112            div()
113                .absolute()
114                .bottom(px(1.0))
115                .left(px(2.0))
116                .w(px(7.0))
117                .h(px(4.0))
118                .bg(color),
119        )
120}
121
122/// Payload carried by a field-chip drag.
123struct DraggedField {
124    field: usize,
125}
126
127/// The ghost chip rendered under the cursor during a drag.
128struct DragGhost {
129    label: String,
130    bg: gpui::Hsla,
131    fg: gpui::Hsla,
132    border: gpui::Hsla,
133}
134
135impl Render for DragGhost {
136    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
137        div()
138            .px(px(8.0))
139            .h(px(CHIP_HEIGHT))
140            .flex()
141            .items_center()
142            .rounded(px(4.0))
143            .bg(self.bg)
144            .border_1()
145            .border_color(self.border)
146            .text_color(self.fg)
147            .text_size(px(12.0))
148            .child(self.label.clone())
149    }
150}
151
152/// Drag-and-drop pivot configuration sidebar. Owns nothing but a handle to
153/// the shared [`PivotState`].
154pub struct PivotSidebar {
155    /// The shared pivot state.
156    pub state: Entity<PivotState>,
157    expanded_sections: Vec<SharedString>,
158}
159
160impl PivotSidebar {
161    /// Wrap an existing pivot state entity.
162    #[must_use]
163    pub fn new(state: Entity<PivotState>) -> Self {
164        Self {
165            state,
166            expanded_sections: vec!["fields".into(), "layout".into(), "display".into()],
167        }
168    }
169
170    fn set_section_expanded(&mut self, id: &SharedString, expanded: bool) {
171        if expanded {
172            if !self.expanded_sections.contains(id) {
173                self.expanded_sections.push(id.clone());
174            }
175        } else {
176            self.expanded_sections.retain(|section| section != id);
177        }
178    }
179
180    /// One collapsible sidebar section: a clickable header row (title, an
181    /// optional extra control next to the title, expand indicator) over an
182    /// optional content body. Hand-rolled — instead of the `gpui-ui-kit`
183    /// accordion, whose header only accepts a title string — so the Layout
184    /// header can host the save-configuration button.
185    #[allow(clippy::too_many_arguments)]
186    fn section(
187        sidebar: &Entity<PivotSidebar>,
188        theme: &GridTheme,
189        id: &'static str,
190        title: &'static str,
191        header_extra: Option<gpui::AnyElement>,
192        expanded: bool,
193        first: bool,
194        content: gpui::AnyElement,
195    ) -> gpui::AnyElement {
196        let sidebar = sidebar.clone();
197        let section_id: SharedString = id.into();
198        let hover_bg = theme.menu_hover_bg;
199
200        let mut header = div()
201            .id(SharedString::from(format!("pivot-section-{id}")))
202            .flex()
203            .items_center()
204            .justify_between()
205            .px(px(16.0))
206            .py(px(12.0))
207            .bg(theme.header_bg)
208            .cursor_pointer()
209            .hover(move |style| style.bg(hover_bg))
210            .on_mouse_up(MouseButton::Left, move |_e: &MouseUpEvent, _w, cx| {
211                sidebar.update(cx, |this, cx| {
212                    this.set_section_expanded(&section_id, !expanded);
213                    cx.notify();
214                });
215            })
216            .child(
217                div()
218                    .flex()
219                    .items_center()
220                    .gap(px(8.0))
221                    .child(
222                        div()
223                            .text_size(px(14.0))
224                            .font_weight(FontWeight::MEDIUM)
225                            .text_color(theme.header_fg)
226                            .child(title),
227                    )
228                    .children(header_extra),
229            )
230            .child(
231                div()
232                    .text_size(px(12.0))
233                    .text_color(theme.muted_text)
234                    .child(if expanded { "▼" } else { "▶" }),
235            );
236        if !first {
237            header = header.border_t_1().border_color(theme.grid_line);
238        }
239
240        let mut wrapper = div().flex().flex_col().child(header);
241        if expanded {
242            wrapper = wrapper.child(
243                div()
244                    .px(px(16.0))
245                    .py(px(12.0))
246                    .bg(theme.menu_bg)
247                    .border_t_1()
248                    .border_color(theme.grid_line)
249                    .child(content),
250            );
251        }
252        wrapper.into_any_element()
253    }
254
255    /// A draggable field chip. `zone` is `None` for the source field list.
256    /// `label_budget` is the chip's inner content width; when the label (plus
257    /// its trailing glyphs) can't fit, the text is ellipsized and a hover
258    /// tooltip shows the full name.
259    #[allow(clippy::too_many_arguments)]
260    fn chip(
261        state: &Entity<PivotState>,
262        id: (&'static str, usize),
263        field: usize,
264        label: String,
265        zone: Option<PivotZone>,
266        removable: bool,
267        marker: Option<&'static str>,
268        label_budget: f32,
269        window: &Window,
270        cx: &App,
271    ) -> gpui::AnyElement {
272        let s = state.read(cx);
273        let theme = s.theme.clone();
274        let ghost_label = label.clone();
275        let ghost_bg = theme.pivot_chip_bg;
276        let ghost_fg = theme.pivot_chip_fg;
277        let ghost_border = theme.grid_line;
278
279        let state_drop = state.clone();
280        let state_remove = state.clone();
281
282        let mut available = label_budget;
283        if let Some(marker) = marker {
284            available -= CHIP_GAP + measure_text(window, marker, MARKER_FONT_SIZE);
285        }
286        if removable {
287            available -= CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE);
288        }
289        let chopped = measure_text(window, &label, CHIP_FONT_SIZE) > available + 0.5;
290
291        let mut chip = div()
292            .id(id)
293            .px(px(8.0))
294            .h(px(CHIP_HEIGHT))
295            .flex_none()
296            .rounded(px(4.0))
297            .bg(theme.pivot_chip_bg)
298            .border_1()
299            .border_color(theme.grid_line)
300            .text_color(theme.pivot_chip_fg)
301            .text_size(px(CHIP_FONT_SIZE))
302            .flex()
303            .items_center()
304            .gap(px(CHIP_GAP))
305            .cursor_pointer()
306            .on_drag(
307                DraggedField { field },
308                move |_drag, _offset, _window, cx| {
309                    cx.new(|_| DragGhost {
310                        label: ghost_label.clone(),
311                        bg: ghost_bg,
312                        fg: ghost_fg,
313                        border: ghost_border,
314                    })
315                },
316            )
317            .child(
318                div()
319                    .flex_1()
320                    .min_w(px(0.0))
321                    .truncate()
322                    .child(label.clone()),
323            );
324
325        if chopped {
326            let tip_label: SharedString = label.into();
327            let tip_bg = theme.menu_bg;
328            let tip_fg = theme.menu_fg;
329            let tip_border = theme.grid_line;
330            chip = chip.tooltip(move |_window, cx| {
331                cx.new(|_| ChipTooltip {
332                    label: tip_label.clone(),
333                    bg: tip_bg,
334                    fg: tip_fg,
335                    border: tip_border,
336                })
337                .into()
338            });
339        }
340
341        if let Some(marker) = marker {
342            chip = chip.child(
343                div()
344                    .flex_none()
345                    .text_color(theme.sort_indicator)
346                    .text_size(px(MARKER_FONT_SIZE))
347                    .child(marker),
348            );
349        }
350
351        if removable {
352            chip = chip.child(
353                div()
354                    .flex_none()
355                    .text_color(theme.muted_text)
356                    .cursor_pointer()
357                    .child("✕")
358                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
359                        cx.stop_propagation();
360                        state_remove.update(cx, |s, cx| {
361                            s.config.remove_field(field);
362                            s.recompute();
363                            cx.notify();
364                        });
365                    }),
366            );
367        }
368
369        // Dropping onto a chip inside a zone inserts the dragged field at
370        // this chip's position (reorder support). Double-clicking a zone chip
371        // opens the per-field format dialog.
372        if let Some(zone) = zone {
373            let state_dialog = state.clone();
374            chip = chip
375                .on_drop::<DraggedField>(move |dragged, _window, cx| {
376                    cx.stop_propagation();
377                    let dragged_field = dragged.field;
378                    state_drop.update(cx, |s, cx| {
379                        let index = s.config.fields_in(zone).iter().position(|&f| f == field);
380                        s.config.move_field(dragged_field, zone, index);
381                        s.recompute();
382                        cx.notify();
383                    });
384                })
385                .on_mouse_down(MouseButton::Left, move |e: &MouseDownEvent, _w, cx| {
386                    if e.click_count == 2 {
387                        cx.stop_propagation();
388                        state_dialog.update(cx, |s, cx| {
389                            s.close_filter_popover();
390                            s.open_format_dialog(field, zone, e.position);
391                            cx.notify();
392                        });
393                    }
394                });
395        }
396
397        chip.into_any_element()
398    }
399
400    /// One drop zone with its label, hint, and assigned chips. `content_w` is
401    /// the accordion content width available to the zone box.
402    fn zone(
403        state: &Entity<PivotState>,
404        zone: PivotZone,
405        title: &'static str,
406        content_w: f32,
407        window: &Window,
408        cx: &App,
409    ) -> gpui::AnyElement {
410        let s = state.read(cx);
411        let theme = s.theme.clone();
412        let fields = s.config.fields_in(zone);
413        let columns = s.source_columns().to_vec();
414        let agg = s.config.aggregation;
415        let agg_menu_open = s.agg_menu_open;
416
417        let state_drop = state.clone();
418        let active_bg = theme.pivot_drop_zone_active_bg;
419        let label_budget = content_w - ZONE_CHROME - CHIP_CHROME;
420
421        let mut chips: Vec<gpui::AnyElement> = Vec::new();
422        for (i, &field) in fields.iter().enumerate() {
423            let name = columns
424                .get(field)
425                .map(|c| c.name.clone())
426                .unwrap_or_else(|| format!("column {field}"));
427            match zone {
428                PivotZone::Values => {
429                    chips.push(Self::values_chip(
430                        state,
431                        field,
432                        agg,
433                        agg_menu_open,
434                        name,
435                        label_budget,
436                        window,
437                        cx,
438                    ));
439                }
440                PivotZone::Filters => {
441                    let filter_on = s.filter_active(field);
442                    let state_open = state.clone();
443                    let chip = div()
444                        .id(("pivot-filter-chip", i))
445                        .on_mouse_down(MouseButton::Left, {
446                            let state_open = state_open.clone();
447                            move |e: &MouseDownEvent, _w, cx| {
448                                // Double-clicks open the format dialog (see
449                                // the inner chip); only single clicks open
450                                // the checklist. The popover is anchored a
451                                // little below the cursor so the second
452                                // click of a double-click still reaches the
453                                // chip instead of the freshly opened popover.
454                                if e.click_count != 1 {
455                                    return;
456                                }
457                                let anchor = point(e.position.x, e.position.y + px(20.0));
458                                state_open.update(cx, |s, cx| {
459                                    s.open_filter_popover(field, anchor);
460                                    cx.notify();
461                                });
462                            }
463                        })
464                        .child(Self::chip(
465                            state,
466                            ("pivot-filter-chip-inner", i),
467                            field,
468                            name,
469                            Some(zone),
470                            true,
471                            filter_on.then_some("●"),
472                            label_budget,
473                            window,
474                            cx,
475                        ));
476                    chips.push(chip.into_any_element());
477                }
478                PivotZone::Rows | PivotZone::Columns => {
479                    let id = match zone {
480                        PivotZone::Rows => ("pivot-row-chip", i),
481                        _ => ("pivot-col-chip", i),
482                    };
483                    chips.push(Self::chip(
484                        state,
485                        id,
486                        field,
487                        name,
488                        Some(zone),
489                        true,
490                        None,
491                        label_budget,
492                        window,
493                        cx,
494                    ));
495                }
496            }
497        }
498
499        let hint = if chips.is_empty() {
500            Some(
501                div()
502                    .text_color(theme.muted_text)
503                    .text_size(px(11.0))
504                    .child("Drag fields here"),
505            )
506        } else {
507            None
508        };
509
510        div()
511            .flex()
512            .flex_col()
513            .gap(px(4.0))
514            .child(
515                div()
516                    .text_color(theme.muted_text)
517                    .text_size(px(11.0))
518                    .child(title),
519            )
520            .child(
521                div()
522                    .min_h(px(40.0))
523                    .p(px(6.0))
524                    .rounded(px(4.0))
525                    .bg(theme.pivot_drop_zone_bg)
526                    .border_1()
527                    .border_color(theme.grid_line)
528                    .flex()
529                    .flex_col()
530                    .gap(px(4.0))
531                    .drag_over::<DraggedField>(move |style, _, _, _| style.bg(active_bg))
532                    .on_drop::<DraggedField>(move |dragged, _window, cx| {
533                        let dragged_field = dragged.field;
534                        state_drop.update(cx, |s, cx| {
535                            s.config.move_field(dragged_field, zone, None);
536                            s.recompute();
537                            cx.notify();
538                        });
539                    })
540                    .children(chips)
541                    .children(hint),
542            )
543            .into_any_element()
544    }
545
546    /// The Values-zone chip: caption plus the aggregation picker. The caption
547    /// is ellipsized so both the picker arrow and the remove button always
548    /// fit; a hover tooltip shows the full caption when chopped.
549    #[allow(clippy::too_many_arguments)]
550    fn values_chip(
551        state: &Entity<PivotState>,
552        field: usize,
553        agg: AggregationFn,
554        menu_open: bool,
555        name: String,
556        label_budget: f32,
557        window: &Window,
558        cx: &App,
559    ) -> gpui::AnyElement {
560        let s = state.read(cx);
561        let theme = s.theme.clone();
562        let state_toggle = state.clone();
563        let state_remove = state.clone();
564
565        let mut rows: Vec<gpui::AnyElement> = Vec::new();
566        let caption = agg.caption(&name);
567        let ghost_label = caption.clone();
568        let ghost_bg = theme.pivot_chip_bg;
569        let ghost_fg = theme.pivot_chip_fg;
570        let ghost_border = theme.grid_line;
571
572        let arrow = if menu_open { "▲" } else { "▼" };
573        let available = label_budget
574            - (CHIP_GAP + measure_text(window, arrow, CHIP_FONT_SIZE))
575            - (CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE));
576        let chopped = measure_text(window, &caption, CHIP_FONT_SIZE) > available + 0.5;
577
578        let mut chip = div()
579            .id("pivot-values-chip")
580            .px(px(8.0))
581            .h(px(CHIP_HEIGHT))
582            .flex_none()
583            .rounded(px(4.0))
584            .bg(theme.pivot_chip_bg)
585            .border_1()
586            .border_color(theme.grid_line)
587            .text_color(theme.pivot_chip_fg)
588            .text_size(px(CHIP_FONT_SIZE))
589            .flex()
590            .items_center()
591            .gap(px(CHIP_GAP))
592            .cursor_pointer()
593            .on_drag(
594                DraggedField { field },
595                move |_drag, _offset, _window, cx| {
596                    cx.new(|_| DragGhost {
597                        label: ghost_label.clone(),
598                        bg: ghost_bg,
599                        fg: ghost_fg,
600                        border: ghost_border,
601                    })
602                },
603            )
604            .child(
605                div()
606                    .flex_1()
607                    .min_w(px(0.0))
608                    .truncate()
609                    .child(caption.clone()),
610            )
611            .child(
612                div()
613                    .flex_none()
614                    .cursor_pointer()
615                    .text_color(theme.sort_indicator)
616                    .child(arrow)
617                    .on_mouse_down(MouseButton::Left, {
618                        let state_toggle = state_toggle.clone();
619                        move |_e: &MouseDownEvent, _w, cx| {
620                            cx.stop_propagation();
621                            state_toggle.update(cx, |s, cx| {
622                                s.agg_menu_open = !s.agg_menu_open;
623                                cx.notify();
624                            });
625                        }
626                    }),
627            )
628            .child(
629                div()
630                    .flex_none()
631                    .text_color(theme.muted_text)
632                    .cursor_pointer()
633                    .child("✕")
634                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
635                        cx.stop_propagation();
636                        state_remove.update(cx, |s, cx| {
637                            s.config.remove_field(field);
638                            s.recompute();
639                            cx.notify();
640                        });
641                    }),
642            );
643
644        if chopped {
645            let tip_label: SharedString = caption.into();
646            let tip_bg = theme.menu_bg;
647            let tip_fg = theme.menu_fg;
648            let tip_border = theme.grid_line;
649            chip = chip.tooltip(move |_window, cx| {
650                cx.new(|_| ChipTooltip {
651                    label: tip_label.clone(),
652                    bg: tip_bg,
653                    fg: tip_fg,
654                    border: tip_border,
655                })
656                .into()
657            });
658        }
659
660        let state_dialog = state.clone();
661        chip = chip.on_mouse_down(MouseButton::Left, move |e: &MouseDownEvent, _w, cx| {
662            if e.click_count == 2 {
663                cx.stop_propagation();
664                state_dialog.update(cx, |s, cx| {
665                    s.open_format_dialog(field, PivotZone::Values, e.position);
666                    cx.notify();
667                });
668            }
669        });
670
671        rows.push(chip.into_any_element());
672
673        if menu_open {
674            for func in AggregationFn::all() {
675                let selected = func == agg;
676                let state_pick = state.clone();
677                rows.push(
678                    div()
679                        .px(px(8.0))
680                        .py(px(2.0))
681                        .rounded(px(3.0))
682                        .bg(if selected {
683                            theme.menu_hover_bg
684                        } else {
685                            theme.menu_bg
686                        })
687                        .text_color(theme.menu_fg)
688                        .text_size(px(12.0))
689                        .cursor_pointer()
690                        .child(func.label())
691                        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
692                            state_pick.update(cx, |s, cx| {
693                                s.config.aggregation = func;
694                                s.agg_menu_open = false;
695                                s.recompute();
696                                cx.notify();
697                            });
698                        })
699                        .into_any_element(),
700                );
701            }
702        }
703
704        div()
705            .flex()
706            .flex_col()
707            .gap(px(2.0))
708            .children(rows)
709            .into_any_element()
710    }
711
712    /// A labelled checkbox row bound to one totals option.
713    fn option_row(
714        state: &Entity<PivotState>,
715        label: &'static str,
716        checked: bool,
717        apply: impl Fn(&mut PivotState) + 'static,
718        cx: &App,
719    ) -> gpui::AnyElement {
720        let theme = state.read(cx).theme.clone();
721        let state_toggle = state.clone();
722        let boxed = div()
723            .w(px(12.0))
724            .h(px(12.0))
725            .border_1()
726            .border_color(theme.grid_line)
727            .bg(theme.menu_bg)
728            .flex()
729            .items_center()
730            .justify_center()
731            .children(checked.then(|| div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator)));
732        div()
733            .flex()
734            .items_center()
735            .gap(px(6.0))
736            .cursor_pointer()
737            .text_size(px(12.0))
738            .text_color(theme.menu_fg)
739            .child(boxed)
740            .child(label)
741            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
742                state_toggle.update(cx, |s, cx| {
743                    apply(s);
744                    s.recompute();
745                    cx.notify();
746                });
747            })
748            .into_any_element()
749    }
750
751    /// Small inline text button.
752    fn text_button(
753        state: &Entity<PivotState>,
754        label: &'static str,
755        apply: impl Fn(&mut PivotState, &mut App) + 'static,
756        cx: &App,
757    ) -> gpui::AnyElement {
758        let theme = state.read(cx).theme.clone();
759        let state_btn = state.clone();
760        div()
761            .px(px(6.0))
762            .py(px(2.0))
763            .rounded(px(3.0))
764            .border_1()
765            .border_color(theme.grid_line)
766            .bg(theme.pivot_drop_zone_bg)
767            .text_color(theme.menu_fg)
768            .text_size(px(11.0))
769            .cursor_pointer()
770            .child(label)
771            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
772                state_btn.update(cx, |s, cx| {
773                    apply(s, cx);
774                    cx.notify();
775                });
776            })
777            .into_any_element()
778    }
779
780    /// The Filters-zone checklist popover overlay, if open.
781    fn render_filter_popover(
782        state: &Entity<PivotState>,
783        cx: &App,
784    ) -> Option<impl IntoElement + use<>> {
785        let s = state.read(cx);
786        let popover = s.filter_popover()?.clone();
787        let theme = s.theme.clone();
788        let field_name = s
789            .source_columns()
790            .get(popover.field)
791            .map(|c| c.name.clone())
792            .unwrap_or_default();
793
794        let checkbox = |checked: bool| {
795            let mut b = div()
796                .w(px(12.0))
797                .h(px(12.0))
798                .border_1()
799                .border_color(theme.grid_line)
800                .bg(theme.menu_bg)
801                .flex()
802                .items_center()
803                .justify_center();
804            if checked {
805                b = b.child(div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator));
806            }
807            b
808        };
809
810        let state_all = state.clone();
811        let select_all = div()
812            .h(px(22.0))
813            .flex()
814            .items_center()
815            .gap(px(6.0))
816            .cursor_pointer()
817            .child(checkbox(popover.all_checked()))
818            .child("(Select All)")
819            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
820                state_all.update(cx, |s, cx| {
821                    s.toggle_filter_popover_select_all();
822                    cx.notify();
823                });
824            });
825
826        let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
827        for (i, row) in popover
828            .rows
829            .iter()
830            .enumerate()
831            .take(FILTER_POPOVER_MAX_ROWS)
832        {
833            let state_val = state.clone();
834            value_rows.push(
835                div()
836                    .h(px(20.0))
837                    .flex()
838                    .items_center()
839                    .gap(px(6.0))
840                    .cursor_pointer()
841                    .child(checkbox(row.checked))
842                    .child(div().text_color(theme.menu_fg).child(row.label.clone()))
843                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
844                        state_val.update(cx, |s, cx| {
845                            s.toggle_filter_popover_value(i);
846                            cx.notify();
847                        });
848                    })
849                    .into_any_element(),
850            );
851        }
852        let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
853
854        let state_clear = state.clone();
855        let state_close = state.clone();
856        let clear_field = popover.field;
857        let buttons = div()
858            .flex()
859            .gap(px(6.0))
860            .child(
861                div()
862                    .flex_1()
863                    .h(px(24.0))
864                    .flex()
865                    .items_center()
866                    .justify_center()
867                    .border_1()
868                    .border_color(theme.grid_line)
869                    .bg(theme.menu_hover_bg)
870                    .cursor_pointer()
871                    .child("Clear")
872                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
873                        state_clear.update(cx, |s, cx| {
874                            s.clear_filter(clear_field);
875                            cx.notify();
876                        });
877                    }),
878            )
879            .child(
880                div()
881                    .flex_1()
882                    .h(px(24.0))
883                    .flex()
884                    .items_center()
885                    .justify_center()
886                    .border_1()
887                    .border_color(theme.grid_line)
888                    .bg(theme.menu_hover_bg)
889                    .cursor_pointer()
890                    .child("Close")
891                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
892                        state_close.update(cx, |s, cx| {
893                            s.close_filter_popover();
894                            cx.notify();
895                        });
896                    }),
897            );
898
899        let body = div()
900            .flex()
901            .flex_col()
902            .w(px(240.0))
903            .p(px(8.0))
904            .gap(px(6.0))
905            .bg(theme.menu_bg)
906            .border_1()
907            .border_color(theme.grid_line)
908            .text_color(theme.menu_fg)
909            .text_size(px(12.0))
910            .child(
911                div()
912                    .text_color(theme.muted_text)
913                    .text_size(px(11.0))
914                    .child(format!("Filter: {field_name}")),
915            )
916            .child(select_all)
917            .child(
918                div()
919                    .id("pivot-filter-values")
920                    .flex()
921                    .flex_col()
922                    .max_h(px(200.0))
923                    .overflow_y_scroll()
924                    .children(value_rows)
925                    .children(truncated.then(|| {
926                        div()
927                            .text_color(theme.muted_text)
928                            .text_size(px(11.0))
929                            .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
930                    })),
931            )
932            .child(buttons);
933
934        let state_backdrop = state.clone();
935        let overlay = deferred(
936            anchored()
937                .anchor(Corner::TopLeft)
938                .position(point(popover.anchor.x, popover.anchor.y))
939                .child(div().occlude().child(body).on_mouse_down_out(
940                    move |_e: &MouseDownEvent, _window, cx| {
941                        state_backdrop.update(cx, |s, cx| {
942                            if s.filter_popover().is_some() {
943                                s.close_filter_popover();
944                                cx.notify();
945                            }
946                        });
947                    },
948                )),
949        )
950        .with_priority(POPOVER_PRIORITY);
951        Some(overlay)
952    }
953
954    /// The per-field format dialog overlay (chip double-click), if open.
955    /// Every control applies immediately; "Reset" drops the override.
956    #[allow(clippy::too_many_lines)]
957    fn render_format_dialog(
958        state: &Entity<PivotState>,
959        cx: &App,
960    ) -> Option<impl IntoElement + use<>> {
961        use crate::config::{NumberFormat, TextAlignment};
962
963        let s = state.read(cx);
964        let dialog = *s.format_dialog()?;
965        let fmt = s.format_dialog_format()?;
966        let theme = s.theme.clone();
967        let field_name = s
968            .source_columns()
969            .get(dialog.field)
970            .map(|c| c.name.clone())
971            .unwrap_or_default();
972
973        let c_bg = theme.menu_bg;
974        let c_line = theme.grid_line;
975        let c_fg = theme.menu_fg;
976        let c_muted = theme.muted_text;
977        let c_accent = theme.sort_indicator;
978        let c_hover = theme.menu_hover_bg;
979
980        let checkbox = move |checked: bool| {
981            let mut b = div()
982                .w(px(12.0))
983                .h(px(12.0))
984                .border_1()
985                .border_color(c_line)
986                .bg(c_bg)
987                .flex()
988                .items_center()
989                .justify_center();
990            if checked {
991                b = b.child(div().w(px(6.0)).h(px(6.0)).bg(c_accent));
992            }
993            b
994        };
995
996        let check_row = |label: &'static str, checked: bool, apply: fn(&mut NumberFormat)| {
997            let st = state.clone();
998            div()
999                .h(px(22.0))
1000                .flex()
1001                .items_center()
1002                .gap(px(6.0))
1003                .cursor_pointer()
1004                .child(checkbox(checked))
1005                .child(label)
1006                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1007                    st.update(cx, |s, cx| {
1008                        s.update_format_dialog(apply);
1009                        cx.notify();
1010                    });
1011                })
1012        };
1013
1014        // Segmented pair choosing how negatives are written.
1015        let neg_btn = |label: &'static str, parens: bool| {
1016            let st = state.clone();
1017            let selected = fmt.negative_parentheses == parens;
1018            div()
1019                .flex_1()
1020                .h(px(22.0))
1021                .flex()
1022                .items_center()
1023                .justify_center()
1024                .border_1()
1025                .border_color(c_line)
1026                .bg(if selected { c_accent } else { c_hover })
1027                .cursor_pointer()
1028                .child(label)
1029                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1030                    st.update(cx, |s, cx| {
1031                        s.update_format_dialog(|f| f.negative_parentheses = parens);
1032                        cx.notify();
1033                    });
1034                })
1035        };
1036
1037        let dec_btn = |label: &'static str, apply: fn(&mut NumberFormat)| {
1038            let st = state.clone();
1039            div()
1040                .w(px(20.0))
1041                .h(px(20.0))
1042                .flex()
1043                .items_center()
1044                .justify_center()
1045                .border_1()
1046                .border_color(c_line)
1047                .bg(c_hover)
1048                .cursor_pointer()
1049                .child(label)
1050                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1051                    st.update(cx, |s, cx| {
1052                        s.update_format_dialog(apply);
1053                        cx.notify();
1054                    });
1055                })
1056        };
1057
1058        let align_btn = |label: &'static str, value: TextAlignment| {
1059            let st = state.clone();
1060            let selected = fmt.alignment == value;
1061            div()
1062                .flex_1()
1063                .h(px(22.0))
1064                .flex()
1065                .items_center()
1066                .justify_center()
1067                .border_1()
1068                .border_color(c_line)
1069                .bg(if selected { c_accent } else { c_hover })
1070                .cursor_pointer()
1071                .child(label)
1072                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1073                    st.update(cx, |s, cx| {
1074                        s.update_format_dialog(move |f| f.alignment = value);
1075                        cx.notify();
1076                    });
1077                })
1078        };
1079
1080        let st_reset = state.clone();
1081        let st_close = state.clone();
1082        let buttons = div()
1083            .flex()
1084            .gap(px(6.0))
1085            .child(
1086                div()
1087                    .flex_1()
1088                    .h(px(24.0))
1089                    .flex()
1090                    .items_center()
1091                    .justify_center()
1092                    .border_1()
1093                    .border_color(c_line)
1094                    .bg(c_hover)
1095                    .cursor_pointer()
1096                    .child("Reset")
1097                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1098                        st_reset.update(cx, |s, cx| {
1099                            s.reset_format_dialog();
1100                            cx.notify();
1101                        });
1102                    }),
1103            )
1104            .child(
1105                div()
1106                    .flex_1()
1107                    .h(px(24.0))
1108                    .flex()
1109                    .items_center()
1110                    .justify_center()
1111                    .border_1()
1112                    .border_color(c_line)
1113                    .bg(c_hover)
1114                    .cursor_pointer()
1115                    .child("Close")
1116                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1117                        st_close.update(cx, |s, cx| {
1118                            s.close_format_dialog();
1119                            cx.notify();
1120                        });
1121                    }),
1122            );
1123
1124        let body = div()
1125            .flex()
1126            .flex_col()
1127            .w(px(230.0))
1128            .p(px(8.0))
1129            .gap(px(6.0))
1130            .bg(c_bg)
1131            .border_1()
1132            .border_color(c_line)
1133            .text_color(c_fg)
1134            .text_size(px(12.0))
1135            .child(
1136                div()
1137                    .text_color(c_muted)
1138                    .text_size(px(11.0))
1139                    .child(format!("Format: {field_name}")),
1140            )
1141            .child(check_row(
1142                "Negative numbers in red",
1143                fmt.show_negative_red,
1144                |f| f.show_negative_red = !f.show_negative_red,
1145            ))
1146            .child(check_row(
1147                "Thousands separator",
1148                fmt.thousands_separator,
1149                |f| f.thousands_separator = !f.thousands_separator,
1150            ))
1151            .child(
1152                div()
1153                    .text_color(c_muted)
1154                    .text_size(px(11.0))
1155                    .child("Negatives"),
1156            )
1157            .child(
1158                div()
1159                    .flex()
1160                    .gap(px(6.0))
1161                    .child(neg_btn("-1,234", false))
1162                    .child(neg_btn("(1,234)", true)),
1163            )
1164            .child(
1165                div()
1166                    .h(px(22.0))
1167                    .flex()
1168                    .items_center()
1169                    .gap(px(6.0))
1170                    .child(
1171                        div()
1172                            .text_color(c_muted)
1173                            .text_size(px(11.0))
1174                            .child("Decimals"),
1175                    )
1176                    .child(dec_btn("-", |f| f.decimals = f.decimals.saturating_sub(1)))
1177                    .child(
1178                        div()
1179                            .w(px(18.0))
1180                            .flex()
1181                            .justify_center()
1182                            .child(fmt.decimals.to_string()),
1183                    )
1184                    .child(dec_btn("+", |f| f.decimals = (f.decimals + 1).min(8))),
1185            )
1186            .child(
1187                div()
1188                    .text_color(c_muted)
1189                    .text_size(px(11.0))
1190                    .child("Alignment"),
1191            )
1192            .child(
1193                div()
1194                    .flex()
1195                    .gap(px(6.0))
1196                    .child(align_btn("Left", TextAlignment::Left))
1197                    .child(align_btn("Center", TextAlignment::Center))
1198                    .child(align_btn("Right", TextAlignment::Right)),
1199            )
1200            .child(buttons);
1201
1202        let state_backdrop = state.clone();
1203        let overlay = deferred(
1204            anchored()
1205                .anchor(Corner::TopLeft)
1206                .position(point(dialog.anchor.x, dialog.anchor.y))
1207                .child(div().occlude().child(body).on_mouse_down_out(
1208                    move |_e: &MouseDownEvent, _window, cx| {
1209                        state_backdrop.update(cx, |s, cx| {
1210                            if s.format_dialog().is_some() {
1211                                s.close_format_dialog();
1212                                cx.notify();
1213                            }
1214                        });
1215                    },
1216                )),
1217        )
1218        .with_priority(POPOVER_PRIORITY);
1219        Some(overlay)
1220    }
1221}
1222
1223impl Render for PivotSidebar {
1224    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1225        let (theme, columns, config, sidebar_width, save_handler) = {
1226            let s = self.state.read(cx);
1227            (
1228                s.theme.clone(),
1229                s.source_columns().to_vec(),
1230                s.config.clone(),
1231                s.sidebar_width,
1232                s.save_config_handler.clone(),
1233            )
1234        };
1235        let content_w = sidebar_width - SIDEBAR_CONTENT_CHROME;
1236
1237        // Source field list with zone badges.
1238        let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
1239        for (i, col) in columns.iter().enumerate() {
1240            let badge = match config.zone_of(i) {
1241                Some(PivotZone::Rows) => Some("R"),
1242                Some(PivotZone::Columns) => Some("C"),
1243                Some(PivotZone::Values) => Some("V"),
1244                Some(PivotZone::Filters) => Some("F"),
1245                None => None,
1246            };
1247            field_chips.push(Self::chip(
1248                &self.state,
1249                ("pivot-source-field", i),
1250                i,
1251                col.name.clone(),
1252                None,
1253                false,
1254                badge,
1255                content_w - CHIP_CHROME,
1256                window,
1257                cx,
1258            ));
1259        }
1260
1261        let options = div()
1262            .flex()
1263            .flex_col()
1264            .gap(px(4.0))
1265            .child(
1266                div()
1267                    .text_color(theme.muted_text)
1268                    .text_size(px(11.0))
1269                    .child("Totals"),
1270            )
1271            .child(Self::option_row(
1272                &self.state,
1273                "Row subtotals",
1274                config.show_row_subtotals,
1275                |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
1276                cx,
1277            ))
1278            .child(Self::option_row(
1279                &self.state,
1280                "Column subtotal columns",
1281                config.show_column_subtotals,
1282                |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
1283                cx,
1284            ))
1285            .child(Self::option_row(
1286                &self.state,
1287                "Grand total row",
1288                config.show_row_grand_total,
1289                |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
1290                cx,
1291            ))
1292            .child(Self::option_row(
1293                &self.state,
1294                "Grand total column",
1295                config.show_column_grand_total,
1296                |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
1297                cx,
1298            ));
1299
1300        let tools = div()
1301            .flex()
1302            .flex_col()
1303            .gap(px(4.0))
1304            .child(
1305                div()
1306                    .text_color(theme.muted_text)
1307                    .text_size(px(11.0))
1308                    .child("Groups"),
1309            )
1310            .child(
1311                div()
1312                    .flex()
1313                    .gap(px(4.0))
1314                    .child(Self::text_button(
1315                        &self.state,
1316                        "Expand rows",
1317                        |s, _| s.expand_all_rows(),
1318                        cx,
1319                    ))
1320                    .child(Self::text_button(
1321                        &self.state,
1322                        "Collapse rows",
1323                        |s, _| s.collapse_all_rows(),
1324                        cx,
1325                    )),
1326            )
1327            .child(
1328                div()
1329                    .flex()
1330                    .gap(px(4.0))
1331                    .child(Self::text_button(
1332                        &self.state,
1333                        "Expand cols",
1334                        |s, _| s.expand_all_cols(),
1335                        cx,
1336                    ))
1337                    .child(Self::text_button(
1338                        &self.state,
1339                        "Collapse cols",
1340                        |s, _| s.collapse_all_cols(),
1341                        cx,
1342                    )),
1343            )
1344            .child(
1345                div()
1346                    .text_color(theme.muted_text)
1347                    .text_size(px(11.0))
1348                    .child("Export"),
1349            )
1350            .child(Self::text_button(
1351                &self.state,
1352                "Copy pivot as CSV",
1353                |s, cx| {
1354                    let csv = s.to_csv();
1355                    if !csv.is_empty() {
1356                        cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
1357                    }
1358                },
1359                cx,
1360            ));
1361
1362        let fields = div()
1363            .flex()
1364            .flex_col()
1365            .gap(px(8.0))
1366            .child(
1367                div()
1368                    .text_color(theme.muted_text)
1369                    .text_size(px(11.0))
1370                    .child("Drag a field into a layout zone"),
1371            )
1372            .child(
1373                div()
1374                    .id("pivot-field-list")
1375                    .flex()
1376                    .flex_col()
1377                    .gap(px(4.0))
1378                    .max_h(px(220.0))
1379                    .overflow_y_scroll()
1380                    .children(field_chips),
1381            );
1382
1383        // Save-configuration button, rendered in the Layout section header
1384        // next to its title. Only present while a host wired up the action
1385        // (via `PivotState::on_save_config` or the widget builder).
1386        let save_button = save_handler.map(|handler| {
1387            let state_save = self.state.clone();
1388            let icon_color = theme.muted_text;
1389            let hover_bg = theme.pivot_drop_zone_active_bg;
1390            let tip_bg = theme.menu_bg;
1391            let tip_fg = theme.menu_fg;
1392            let tip_border = theme.grid_line;
1393            div()
1394                .id("pivot-save-config")
1395                .p(px(2.0))
1396                .rounded(px(3.0))
1397                .cursor_pointer()
1398                .hover(move |style| style.bg(hover_bg))
1399                .child(disk_icon(icon_color))
1400                .tooltip(move |_window, cx| {
1401                    cx.new(|_| ChipTooltip {
1402                        label: "Save configuration".into(),
1403                        bg: tip_bg,
1404                        fg: tip_fg,
1405                        border: tip_border,
1406                    })
1407                    .into()
1408                })
1409                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1410                    cx.stop_propagation();
1411                    let config = state_save.read(cx).config.clone();
1412                    handler(&config, cx);
1413                })
1414                // The section header toggles on mouse-up; swallow it so a
1415                // click on the save button doesn't also collapse the section.
1416                .on_mouse_up(MouseButton::Left, |_e: &MouseUpEvent, _w, cx| {
1417                    cx.stop_propagation();
1418                })
1419                .into_any_element()
1420        });
1421
1422        let layout = div()
1423            .flex()
1424            .flex_col()
1425            .gap(px(8.0))
1426            .child(Self::zone(
1427                &self.state,
1428                PivotZone::Filters,
1429                "Filters",
1430                content_w,
1431                window,
1432                cx,
1433            ))
1434            .child(Self::zone(
1435                &self.state,
1436                PivotZone::Columns,
1437                "Columns",
1438                content_w,
1439                window,
1440                cx,
1441            ))
1442            .child(Self::zone(
1443                &self.state,
1444                PivotZone::Rows,
1445                "Rows",
1446                content_w,
1447                window,
1448                cx,
1449            ))
1450            .child(Self::zone(
1451                &self.state,
1452                PivotZone::Values,
1453                "Values",
1454                content_w,
1455                window,
1456                cx,
1457            ));
1458
1459        let display = div()
1460            .flex()
1461            .flex_col()
1462            .gap(px(10.0))
1463            .child(options)
1464            .child(tools);
1465
1466        let sidebar = cx.entity().clone();
1467        let is_expanded = |id: &str| self.expanded_sections.iter().any(|section| section == id);
1468        let sections = div()
1469            .flex()
1470            .flex_col()
1471            .border_1()
1472            .border_color(theme.grid_line)
1473            .rounded_lg()
1474            .child(Self::section(
1475                &sidebar,
1476                &theme,
1477                "fields",
1478                "Fields",
1479                None,
1480                is_expanded("fields"),
1481                true,
1482                fields.into_any_element(),
1483            ))
1484            .child(Self::section(
1485                &sidebar,
1486                &theme,
1487                "layout",
1488                "Layout",
1489                save_button,
1490                is_expanded("layout"),
1491                false,
1492                layout.into_any_element(),
1493            ))
1494            .child(Self::section(
1495                &sidebar,
1496                &theme,
1497                "display",
1498                "Display and export",
1499                None,
1500                is_expanded("display"),
1501                false,
1502                display.into_any_element(),
1503            ));
1504
1505        div()
1506            .id("pivot-sidebar")
1507            .h_full()
1508            .flex()
1509            .flex_col()
1510            .gap(px(8.0))
1511            .p(px(8.0))
1512            .bg(theme.menu_bg)
1513            .text_color(theme.menu_fg)
1514            .text_size(px(12.0))
1515            .overflow_y_scroll()
1516            .child(
1517                div()
1518                    .text_color(theme.header_fg)
1519                    .text_size(px(13.0))
1520                    .child("Pivot Controls"),
1521            )
1522            .child(sections)
1523            .children(Self::render_filter_popover(&self.state, cx))
1524            .children(Self::render_format_dialog(&self.state, cx))
1525    }
1526}