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 hover_bg = theme.menu_hover_bg;
723        // Checked: accent-filled box with a knockout check (the theme's `bg`
724        // reads against the accent in both light and dark). Unchecked: an
725        // outlined empty box.
726        let boxed = div()
727            .w(px(16.0))
728            .h(px(16.0))
729            .flex_none()
730            .rounded(px(3.0))
731            .border_1()
732            .border_color(if checked {
733                theme.sort_indicator
734            } else {
735                theme.grid_line
736            })
737            .bg(if checked {
738                theme.sort_indicator
739            } else {
740                theme.menu_bg
741            })
742            .flex()
743            .items_center()
744            .justify_center()
745            .text_size(px(12.0))
746            .font_weight(FontWeight::SEMIBOLD)
747            .text_color(theme.bg)
748            .children(checked.then_some("✓"));
749        div()
750            .id(SharedString::from(format!("pivot-option-{label}")))
751            .flex()
752            .items_center()
753            .gap(px(8.0))
754            .px(px(4.0))
755            .py(px(3.0))
756            .rounded(px(4.0))
757            .cursor_pointer()
758            .hover(move |style| style.bg(hover_bg))
759            .text_size(px(13.0))
760            .text_color(theme.menu_fg)
761            .child(boxed)
762            .child(label)
763            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
764                state_toggle.update(cx, |s, cx| {
765                    apply(s);
766                    s.recompute();
767                    cx.notify();
768                });
769            })
770            .into_any_element()
771    }
772
773    /// Small inline text button.
774    fn text_button(
775        state: &Entity<PivotState>,
776        label: &'static str,
777        apply: impl Fn(&mut PivotState, &mut App) + 'static,
778        cx: &App,
779    ) -> gpui::AnyElement {
780        let theme = state.read(cx).theme.clone();
781        let state_btn = state.clone();
782        let hover_bg = theme.menu_hover_bg;
783        div()
784            .id(SharedString::from(format!("pivot-button-{label}")))
785            .px(px(10.0))
786            .py(px(4.0))
787            .rounded(px(4.0))
788            .border_1()
789            .border_color(theme.grid_line)
790            .bg(theme.pivot_drop_zone_bg)
791            .hover(move |style| style.bg(hover_bg))
792            .text_color(theme.menu_fg)
793            .text_size(px(12.0))
794            .cursor_pointer()
795            .child(label)
796            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
797                state_btn.update(cx, |s, cx| {
798                    apply(s, cx);
799                    cx.notify();
800                });
801            })
802            .into_any_element()
803    }
804
805    /// The Filters-zone checklist popover overlay, if open.
806    fn render_filter_popover(
807        state: &Entity<PivotState>,
808        cx: &App,
809    ) -> Option<impl IntoElement + use<>> {
810        let s = state.read(cx);
811        let popover = s.filter_popover()?.clone();
812        let theme = s.theme.clone();
813        let field_name = s
814            .source_columns()
815            .get(popover.field)
816            .map(|c| c.name.clone())
817            .unwrap_or_default();
818
819        let checkbox = |checked: bool| {
820            let mut b = div()
821                .w(px(12.0))
822                .h(px(12.0))
823                .border_1()
824                .border_color(theme.grid_line)
825                .bg(theme.menu_bg)
826                .flex()
827                .items_center()
828                .justify_center();
829            if checked {
830                b = b.child(div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator));
831            }
832            b
833        };
834
835        let state_all = state.clone();
836        let select_all = div()
837            .h(px(22.0))
838            .flex()
839            .items_center()
840            .gap(px(6.0))
841            .cursor_pointer()
842            .child(checkbox(popover.all_checked()))
843            .child("(Select All)")
844            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
845                state_all.update(cx, |s, cx| {
846                    s.toggle_filter_popover_select_all();
847                    cx.notify();
848                });
849            });
850
851        let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
852        for (i, row) in popover
853            .rows
854            .iter()
855            .enumerate()
856            .take(FILTER_POPOVER_MAX_ROWS)
857        {
858            let state_val = state.clone();
859            value_rows.push(
860                div()
861                    .h(px(20.0))
862                    .flex()
863                    .items_center()
864                    .gap(px(6.0))
865                    .cursor_pointer()
866                    .child(checkbox(row.checked))
867                    .child(div().text_color(theme.menu_fg).child(row.label.clone()))
868                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
869                        state_val.update(cx, |s, cx| {
870                            s.toggle_filter_popover_value(i);
871                            cx.notify();
872                        });
873                    })
874                    .into_any_element(),
875            );
876        }
877        let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
878
879        let state_clear = state.clone();
880        let state_close = state.clone();
881        let clear_field = popover.field;
882        let buttons = div()
883            .flex()
884            .gap(px(6.0))
885            .child(
886                div()
887                    .flex_1()
888                    .h(px(24.0))
889                    .flex()
890                    .items_center()
891                    .justify_center()
892                    .border_1()
893                    .border_color(theme.grid_line)
894                    .bg(theme.menu_hover_bg)
895                    .cursor_pointer()
896                    .child("Clear")
897                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
898                        state_clear.update(cx, |s, cx| {
899                            s.clear_filter(clear_field);
900                            cx.notify();
901                        });
902                    }),
903            )
904            .child(
905                div()
906                    .flex_1()
907                    .h(px(24.0))
908                    .flex()
909                    .items_center()
910                    .justify_center()
911                    .border_1()
912                    .border_color(theme.grid_line)
913                    .bg(theme.menu_hover_bg)
914                    .cursor_pointer()
915                    .child("Close")
916                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
917                        state_close.update(cx, |s, cx| {
918                            s.close_filter_popover();
919                            cx.notify();
920                        });
921                    }),
922            );
923
924        let body = div()
925            .flex()
926            .flex_col()
927            .w(px(240.0))
928            .p(px(8.0))
929            .gap(px(6.0))
930            .bg(theme.menu_bg)
931            .border_1()
932            .border_color(theme.grid_line)
933            .text_color(theme.menu_fg)
934            .text_size(px(12.0))
935            .child(
936                div()
937                    .text_color(theme.muted_text)
938                    .text_size(px(11.0))
939                    .child(format!("Filter: {field_name}")),
940            )
941            .child(select_all)
942            .child(
943                div()
944                    .id("pivot-filter-values")
945                    .flex()
946                    .flex_col()
947                    .max_h(px(200.0))
948                    .overflow_y_scroll()
949                    // Own the wheel in the popover's value list so it doesn't
950                    // also scroll the sidebar behind it (see the field list).
951                    .occlude()
952                    .children(value_rows)
953                    .children(truncated.then(|| {
954                        div()
955                            .text_color(theme.muted_text)
956                            .text_size(px(11.0))
957                            .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
958                    })),
959            )
960            .child(buttons);
961
962        let state_backdrop = state.clone();
963        let overlay = deferred(
964            anchored()
965                .anchor(Corner::TopLeft)
966                .position(point(popover.anchor.x, popover.anchor.y))
967                .child(div().occlude().child(body).on_mouse_down_out(
968                    move |_e: &MouseDownEvent, _window, cx| {
969                        state_backdrop.update(cx, |s, cx| {
970                            if s.filter_popover().is_some() {
971                                s.close_filter_popover();
972                                cx.notify();
973                            }
974                        });
975                    },
976                )),
977        )
978        .with_priority(POPOVER_PRIORITY);
979        Some(overlay)
980    }
981
982    /// The per-field format dialog overlay (chip double-click), if open.
983    /// Every control applies immediately; "Reset" drops the override.
984    #[allow(clippy::too_many_lines)]
985    fn render_format_dialog(
986        state: &Entity<PivotState>,
987        cx: &App,
988    ) -> Option<impl IntoElement + use<>> {
989        use crate::config::{NumberFormat, TextAlignment};
990
991        let s = state.read(cx);
992        let dialog = *s.format_dialog()?;
993        let fmt = s.format_dialog_format()?;
994        let theme = s.theme.clone();
995        let field_name = s
996            .source_columns()
997            .get(dialog.field)
998            .map(|c| c.name.clone())
999            .unwrap_or_default();
1000
1001        let c_bg = theme.menu_bg;
1002        let c_line = theme.grid_line;
1003        let c_fg = theme.menu_fg;
1004        let c_muted = theme.muted_text;
1005        let c_accent = theme.sort_indicator;
1006        let c_hover = theme.menu_hover_bg;
1007
1008        let checkbox = move |checked: bool| {
1009            let mut b = div()
1010                .w(px(12.0))
1011                .h(px(12.0))
1012                .border_1()
1013                .border_color(c_line)
1014                .bg(c_bg)
1015                .flex()
1016                .items_center()
1017                .justify_center();
1018            if checked {
1019                b = b.child(div().w(px(6.0)).h(px(6.0)).bg(c_accent));
1020            }
1021            b
1022        };
1023
1024        let check_row = |label: &'static str, checked: bool, apply: fn(&mut NumberFormat)| {
1025            let st = state.clone();
1026            div()
1027                .h(px(22.0))
1028                .flex()
1029                .items_center()
1030                .gap(px(6.0))
1031                .cursor_pointer()
1032                .child(checkbox(checked))
1033                .child(label)
1034                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1035                    st.update(cx, |s, cx| {
1036                        s.update_format_dialog(apply);
1037                        cx.notify();
1038                    });
1039                })
1040        };
1041
1042        // Segmented pair choosing how negatives are written.
1043        let neg_btn = |label: &'static str, parens: bool| {
1044            let st = state.clone();
1045            let selected = fmt.negative_parentheses == parens;
1046            div()
1047                .flex_1()
1048                .h(px(22.0))
1049                .flex()
1050                .items_center()
1051                .justify_center()
1052                .border_1()
1053                .border_color(c_line)
1054                .bg(if selected { c_accent } else { c_hover })
1055                .cursor_pointer()
1056                .child(label)
1057                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1058                    st.update(cx, |s, cx| {
1059                        s.update_format_dialog(|f| f.negative_parentheses = parens);
1060                        cx.notify();
1061                    });
1062                })
1063        };
1064
1065        let dec_btn = |label: &'static str, apply: fn(&mut NumberFormat)| {
1066            let st = state.clone();
1067            div()
1068                .w(px(20.0))
1069                .h(px(20.0))
1070                .flex()
1071                .items_center()
1072                .justify_center()
1073                .border_1()
1074                .border_color(c_line)
1075                .bg(c_hover)
1076                .cursor_pointer()
1077                .child(label)
1078                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1079                    st.update(cx, |s, cx| {
1080                        s.update_format_dialog(apply);
1081                        cx.notify();
1082                    });
1083                })
1084        };
1085
1086        let align_btn = |label: &'static str, value: TextAlignment| {
1087            let st = state.clone();
1088            let selected = fmt.alignment == value;
1089            div()
1090                .flex_1()
1091                .h(px(22.0))
1092                .flex()
1093                .items_center()
1094                .justify_center()
1095                .border_1()
1096                .border_color(c_line)
1097                .bg(if selected { c_accent } else { c_hover })
1098                .cursor_pointer()
1099                .child(label)
1100                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1101                    st.update(cx, |s, cx| {
1102                        s.update_format_dialog(move |f| f.alignment = value);
1103                        cx.notify();
1104                    });
1105                })
1106        };
1107
1108        let st_reset = state.clone();
1109        let st_close = state.clone();
1110        let buttons = div()
1111            .flex()
1112            .gap(px(6.0))
1113            .child(
1114                div()
1115                    .flex_1()
1116                    .h(px(24.0))
1117                    .flex()
1118                    .items_center()
1119                    .justify_center()
1120                    .border_1()
1121                    .border_color(c_line)
1122                    .bg(c_hover)
1123                    .cursor_pointer()
1124                    .child("Reset")
1125                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1126                        st_reset.update(cx, |s, cx| {
1127                            s.reset_format_dialog();
1128                            cx.notify();
1129                        });
1130                    }),
1131            )
1132            .child(
1133                div()
1134                    .flex_1()
1135                    .h(px(24.0))
1136                    .flex()
1137                    .items_center()
1138                    .justify_center()
1139                    .border_1()
1140                    .border_color(c_line)
1141                    .bg(c_hover)
1142                    .cursor_pointer()
1143                    .child("Close")
1144                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1145                        st_close.update(cx, |s, cx| {
1146                            s.close_format_dialog();
1147                            cx.notify();
1148                        });
1149                    }),
1150            );
1151
1152        let body = div()
1153            .flex()
1154            .flex_col()
1155            .w(px(230.0))
1156            .p(px(8.0))
1157            .gap(px(6.0))
1158            .bg(c_bg)
1159            .border_1()
1160            .border_color(c_line)
1161            .text_color(c_fg)
1162            .text_size(px(12.0))
1163            .child(
1164                div()
1165                    .text_color(c_muted)
1166                    .text_size(px(11.0))
1167                    .child(format!("Format: {field_name}")),
1168            )
1169            .child(check_row(
1170                "Negative numbers in red",
1171                fmt.show_negative_red,
1172                |f| f.show_negative_red = !f.show_negative_red,
1173            ))
1174            .child(check_row(
1175                "Thousands separator",
1176                fmt.thousands_separator,
1177                |f| f.thousands_separator = !f.thousands_separator,
1178            ))
1179            .child(
1180                div()
1181                    .text_color(c_muted)
1182                    .text_size(px(11.0))
1183                    .child("Negatives"),
1184            )
1185            .child(
1186                div()
1187                    .flex()
1188                    .gap(px(6.0))
1189                    .child(neg_btn("-1,234", false))
1190                    .child(neg_btn("(1,234)", true)),
1191            )
1192            .child(
1193                div()
1194                    .h(px(22.0))
1195                    .flex()
1196                    .items_center()
1197                    .gap(px(6.0))
1198                    .child(
1199                        div()
1200                            .text_color(c_muted)
1201                            .text_size(px(11.0))
1202                            .child("Decimals"),
1203                    )
1204                    .child(dec_btn("-", |f| f.decimals = f.decimals.saturating_sub(1)))
1205                    .child(
1206                        div()
1207                            .w(px(18.0))
1208                            .flex()
1209                            .justify_center()
1210                            .child(fmt.decimals.to_string()),
1211                    )
1212                    .child(dec_btn("+", |f| f.decimals = (f.decimals + 1).min(8))),
1213            )
1214            .child(
1215                div()
1216                    .text_color(c_muted)
1217                    .text_size(px(11.0))
1218                    .child("Alignment"),
1219            )
1220            .child(
1221                div()
1222                    .flex()
1223                    .gap(px(6.0))
1224                    .child(align_btn("Left", TextAlignment::Left))
1225                    .child(align_btn("Center", TextAlignment::Center))
1226                    .child(align_btn("Right", TextAlignment::Right)),
1227            )
1228            .child(buttons);
1229
1230        let state_backdrop = state.clone();
1231        let overlay = deferred(
1232            anchored()
1233                .anchor(Corner::TopLeft)
1234                .position(point(dialog.anchor.x, dialog.anchor.y))
1235                .child(div().occlude().child(body).on_mouse_down_out(
1236                    move |_e: &MouseDownEvent, _window, cx| {
1237                        state_backdrop.update(cx, |s, cx| {
1238                            if s.format_dialog().is_some() {
1239                                s.close_format_dialog();
1240                                cx.notify();
1241                            }
1242                        });
1243                    },
1244                )),
1245        )
1246        .with_priority(POPOVER_PRIORITY);
1247        Some(overlay)
1248    }
1249}
1250
1251impl Render for PivotSidebar {
1252    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1253        let (theme, columns, config, sidebar_width, save_handler) = {
1254            let s = self.state.read(cx);
1255            (
1256                s.theme.clone(),
1257                s.source_columns().to_vec(),
1258                s.config.clone(),
1259                s.sidebar_width,
1260                s.save_config_handler.clone(),
1261            )
1262        };
1263        let content_w = sidebar_width - SIDEBAR_CONTENT_CHROME;
1264
1265        // Source field list with zone badges.
1266        let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
1267        for (i, col) in columns.iter().enumerate() {
1268            let badge = match config.zone_of(i) {
1269                Some(PivotZone::Rows) => Some("R"),
1270                Some(PivotZone::Columns) => Some("C"),
1271                Some(PivotZone::Values) => Some("V"),
1272                Some(PivotZone::Filters) => Some("F"),
1273                None => None,
1274            };
1275            field_chips.push(Self::chip(
1276                &self.state,
1277                ("pivot-source-field", i),
1278                i,
1279                col.name.clone(),
1280                None,
1281                false,
1282                badge,
1283                content_w - CHIP_CHROME,
1284                window,
1285                cx,
1286            ));
1287        }
1288
1289        let options = div()
1290            .flex()
1291            .flex_col()
1292            .gap(px(6.0))
1293            .child(
1294                div()
1295                    .text_color(theme.muted_text)
1296                    .text_size(px(12.0))
1297                    .child("Totals"),
1298            )
1299            .child(Self::option_row(
1300                &self.state,
1301                "Row subtotals",
1302                config.show_row_subtotals,
1303                |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
1304                cx,
1305            ))
1306            .child(Self::option_row(
1307                &self.state,
1308                "Column subtotal columns",
1309                config.show_column_subtotals,
1310                |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
1311                cx,
1312            ))
1313            .child(Self::option_row(
1314                &self.state,
1315                "Grand total row",
1316                config.show_row_grand_total,
1317                |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
1318                cx,
1319            ))
1320            .child(Self::option_row(
1321                &self.state,
1322                "Grand total column",
1323                config.show_column_grand_total,
1324                |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
1325                cx,
1326            ));
1327
1328        let tools = div()
1329            .flex()
1330            .flex_col()
1331            .gap(px(6.0))
1332            .child(
1333                div()
1334                    .text_color(theme.muted_text)
1335                    .text_size(px(12.0))
1336                    .child("Groups"),
1337            )
1338            .child(
1339                div()
1340                    .flex()
1341                    .gap(px(4.0))
1342                    .child(Self::text_button(
1343                        &self.state,
1344                        "Expand rows",
1345                        |s, _| s.expand_all_rows(),
1346                        cx,
1347                    ))
1348                    .child(Self::text_button(
1349                        &self.state,
1350                        "Collapse rows",
1351                        |s, _| s.collapse_all_rows(),
1352                        cx,
1353                    )),
1354            )
1355            .child(
1356                div()
1357                    .flex()
1358                    .gap(px(4.0))
1359                    .child(Self::text_button(
1360                        &self.state,
1361                        "Expand cols",
1362                        |s, _| s.expand_all_cols(),
1363                        cx,
1364                    ))
1365                    .child(Self::text_button(
1366                        &self.state,
1367                        "Collapse cols",
1368                        |s, _| s.collapse_all_cols(),
1369                        cx,
1370                    )),
1371            )
1372            .child(
1373                div()
1374                    .text_color(theme.muted_text)
1375                    .text_size(px(12.0))
1376                    .child("Export"),
1377            )
1378            .child(Self::text_button(
1379                &self.state,
1380                "Copy pivot as CSV",
1381                |s, cx| {
1382                    let csv = s.to_csv();
1383                    if !csv.is_empty() {
1384                        cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
1385                    }
1386                },
1387                cx,
1388            ));
1389
1390        let fields = div()
1391            .flex()
1392            .flex_col()
1393            .gap(px(8.0))
1394            .child(
1395                div()
1396                    .text_color(theme.muted_text)
1397                    .text_size(px(11.0))
1398                    .child("Drag a field into a layout zone"),
1399            )
1400            .child(
1401                div()
1402                    .id("pivot-field-list")
1403                    .flex()
1404                    .flex_col()
1405                    .gap(px(4.0))
1406                    .max_h(px(220.0))
1407                    .overflow_y_scroll()
1408                    // Own the wheel within the list's own area. Without this
1409                    // the outer `#pivot-sidebar` scroll container (also under
1410                    // the cursor) receives the same wheel event — GPUI's
1411                    // overflow-scroll handler never stops propagation — so the
1412                    // whole control panel scrolled along with the list. As a
1413                    // `BlockMouse` hitbox the list truncates the scroll
1414                    // hit-test, leaving the sidebar out of it.
1415                    .occlude()
1416                    .children(field_chips),
1417            );
1418
1419        // Save-configuration button, rendered in the Layout section header
1420        // next to its title. Only present while a host wired up the action
1421        // (via `PivotState::on_save_config` or the widget builder).
1422        let save_button = save_handler.map(|handler| {
1423            let state_save = self.state.clone();
1424            let icon_color = theme.muted_text;
1425            let hover_bg = theme.pivot_drop_zone_active_bg;
1426            let tip_bg = theme.menu_bg;
1427            let tip_fg = theme.menu_fg;
1428            let tip_border = theme.grid_line;
1429            div()
1430                .id("pivot-save-config")
1431                .p(px(2.0))
1432                .rounded(px(3.0))
1433                .cursor_pointer()
1434                .hover(move |style| style.bg(hover_bg))
1435                .child(disk_icon(icon_color))
1436                .tooltip(move |_window, cx| {
1437                    cx.new(|_| ChipTooltip {
1438                        label: "Save configuration".into(),
1439                        bg: tip_bg,
1440                        fg: tip_fg,
1441                        border: tip_border,
1442                    })
1443                    .into()
1444                })
1445                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1446                    cx.stop_propagation();
1447                    let config = state_save.read(cx).config.clone();
1448                    handler(&config, cx);
1449                })
1450                // The section header toggles on mouse-up; swallow it so a
1451                // click on the save button doesn't also collapse the section.
1452                .on_mouse_up(MouseButton::Left, |_e: &MouseUpEvent, _w, cx| {
1453                    cx.stop_propagation();
1454                })
1455                .into_any_element()
1456        });
1457
1458        let layout = div()
1459            .flex()
1460            .flex_col()
1461            .gap(px(8.0))
1462            .child(Self::zone(
1463                &self.state,
1464                PivotZone::Filters,
1465                "Filters",
1466                content_w,
1467                window,
1468                cx,
1469            ))
1470            .child(Self::zone(
1471                &self.state,
1472                PivotZone::Columns,
1473                "Columns",
1474                content_w,
1475                window,
1476                cx,
1477            ))
1478            .child(Self::zone(
1479                &self.state,
1480                PivotZone::Rows,
1481                "Rows",
1482                content_w,
1483                window,
1484                cx,
1485            ))
1486            .child(Self::zone(
1487                &self.state,
1488                PivotZone::Values,
1489                "Values",
1490                content_w,
1491                window,
1492                cx,
1493            ));
1494
1495        let display = div()
1496            .flex()
1497            .flex_col()
1498            .gap(px(10.0))
1499            .child(options)
1500            .child(tools);
1501
1502        let sidebar = cx.entity().clone();
1503        let is_expanded = |id: &str| self.expanded_sections.iter().any(|section| section == id);
1504        let sections = div()
1505            .flex()
1506            .flex_col()
1507            .border_1()
1508            .border_color(theme.grid_line)
1509            .rounded_lg()
1510            .child(Self::section(
1511                &sidebar,
1512                &theme,
1513                "fields",
1514                "Fields",
1515                None,
1516                is_expanded("fields"),
1517                true,
1518                fields.into_any_element(),
1519            ))
1520            .child(Self::section(
1521                &sidebar,
1522                &theme,
1523                "layout",
1524                "Layout",
1525                save_button,
1526                is_expanded("layout"),
1527                false,
1528                layout.into_any_element(),
1529            ))
1530            .child(Self::section(
1531                &sidebar,
1532                &theme,
1533                "display",
1534                "Display and export",
1535                None,
1536                is_expanded("display"),
1537                false,
1538                display.into_any_element(),
1539            ));
1540
1541        div()
1542            .id("pivot-sidebar")
1543            .h_full()
1544            .flex()
1545            .flex_col()
1546            .gap(px(8.0))
1547            .p(px(8.0))
1548            .bg(theme.menu_bg)
1549            .text_color(theme.menu_fg)
1550            .text_size(px(12.0))
1551            .overflow_y_scroll()
1552            .child(
1553                div()
1554                    .text_color(theme.header_fg)
1555                    .text_size(px(13.0))
1556                    .child("Pivot Controls"),
1557            )
1558            .child(sections)
1559            .children(Self::render_filter_popover(&self.state, cx))
1560            .children(Self::render_format_dialog(&self.state, cx))
1561    }
1562}