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 (12×2).
28const SIDEBAR_CONTENT_CHROME: f32 = 42.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    animations: bool,
134}
135
136impl Render for DragGhost {
137    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
138        let chip = div()
139            .px(px(8.0))
140            .h(px(CHIP_HEIGHT))
141            .flex()
142            .items_center()
143            .rounded(px(4.0))
144            .bg(self.bg)
145            .border_1()
146            .border_color(self.border)
147            .text_color(self.fg)
148            .text_size(px(12.0))
149            .child(self.label.clone());
150        // The ghost is a direct extension of the pointer; a quick fade reads as
151        // the chip lifting off the sidebar rather than teleporting in.
152        crate::grid::motion::fade_in(
153            chip,
154            "pivot-drag-ghost",
155            crate::grid::motion::GHOST_ENTER_MS,
156            self.animations,
157        )
158    }
159}
160
161/// Drag-and-drop pivot configuration sidebar. Owns nothing but a handle to
162/// the shared [`PivotState`].
163pub struct PivotSidebar {
164    /// The shared pivot state.
165    pub state: Entity<PivotState>,
166    expanded_sections: Vec<SharedString>,
167}
168
169impl PivotSidebar {
170    /// Wrap an existing pivot state entity.
171    #[must_use]
172    pub fn new(state: Entity<PivotState>) -> Self {
173        Self {
174            state,
175            expanded_sections: vec!["fields".into(), "layout".into(), "display".into()],
176        }
177    }
178
179    fn set_section_expanded(&mut self, id: &SharedString, expanded: bool) {
180        if expanded {
181            if !self.expanded_sections.contains(id) {
182                self.expanded_sections.push(id.clone());
183            }
184        } else {
185            self.expanded_sections.retain(|section| section != id);
186        }
187    }
188
189    /// One collapsible sidebar section: a clickable header row (title, an
190    /// optional extra control next to the title, expand indicator) over an
191    /// optional content body. Hand-rolled — instead of the `gpui-ui-kit`
192    /// accordion, whose header only accepts a title string — so the Layout
193    /// header can host the save-configuration button.
194    #[allow(clippy::too_many_arguments)]
195    fn section(
196        sidebar: &Entity<PivotSidebar>,
197        theme: &GridTheme,
198        id: &'static str,
199        title: &'static str,
200        header_extra: Option<gpui::AnyElement>,
201        expanded: bool,
202        first: bool,
203        animations: bool,
204        content: gpui::AnyElement,
205    ) -> gpui::AnyElement {
206        let sidebar = sidebar.clone();
207        let section_id: SharedString = id.into();
208        let hover_bg = theme.menu_hover_bg;
209
210        let mut header = div()
211            .id(SharedString::from(format!("pivot-section-{id}")))
212            .flex()
213            .items_center()
214            .justify_between()
215            .px(px(12.0))
216            .py(px(12.0))
217            .bg(theme.header_bg)
218            .cursor_pointer()
219            .hover(move |style| style.bg(hover_bg))
220            .on_mouse_up(MouseButton::Left, move |_e: &MouseUpEvent, _w, cx| {
221                sidebar.update(cx, |this, cx| {
222                    this.set_section_expanded(&section_id, !expanded);
223                    cx.notify();
224                });
225            })
226            .child(
227                div()
228                    .flex()
229                    .items_center()
230                    .gap(px(8.0))
231                    .child(
232                        div()
233                            .text_size(px(14.0))
234                            .font_weight(FontWeight::MEDIUM)
235                            .text_color(theme.header_fg)
236                            .child(title),
237                    )
238                    .children(header_extra),
239            )
240            .child(
241                div()
242                    .text_size(px(12.0))
243                    .text_color(theme.muted_text)
244                    .child(if expanded { "▼" } else { "▶" }),
245            );
246        if !first {
247            header = header.border_t_1().border_color(theme.grid_line);
248        }
249
250        let mut wrapper = div().flex().flex_col().child(header);
251        if expanded {
252            let body = div()
253                .px(px(12.0))
254                .py(px(12.0))
255                .bg(theme.menu_bg)
256                .border_t_1()
257                .border_color(theme.grid_line)
258                .child(content);
259            // The chevron flips instantly; the revealed body fades in so the
260            // expand reads as a reveal, not a jump. No height tween — animating
261            // layout height janks, and an opacity fade is the native feel.
262            wrapper = wrapper.child(crate::grid::motion::fade_in(
263                body,
264                SharedString::from(format!("pivot-section-body-{id}")),
265                crate::grid::motion::SECTION_ENTER_MS,
266                animations,
267            ));
268        }
269        wrapper.into_any_element()
270    }
271
272    /// A draggable field chip. `zone` is `None` for the source field list.
273    /// `label_budget` is the chip's inner content width; when the label (plus
274    /// its trailing glyphs) can't fit, the text is ellipsized and a hover
275    /// tooltip shows the full name.
276    #[allow(clippy::too_many_arguments)]
277    fn chip(
278        state: &Entity<PivotState>,
279        id: (&'static str, usize),
280        field: usize,
281        label: String,
282        zone: Option<PivotZone>,
283        removable: bool,
284        marker: Option<&'static str>,
285        label_budget: f32,
286        window: &Window,
287        cx: &App,
288    ) -> gpui::AnyElement {
289        let s = state.read(cx);
290        let theme = s.theme.clone();
291        let animations = s.animations;
292        let ghost_label = label.clone();
293        let ghost_bg = theme.pivot_chip_bg;
294        let ghost_fg = theme.pivot_chip_fg;
295        let ghost_border = theme.grid_line;
296
297        // The accent-filled chip marks a field that is *placed* in a layout
298        // zone (Rows / Columns / Values / Filters). The source-list inventory
299        // (`zone == None`) is not active state, so it recedes to a quiet
300        // surface one step off the panel — the committed accent stays reserved
301        // for the placements, totals, and selection that actually carry it.
302        let (chip_bg, chip_fg) = if zone.is_some() {
303            (theme.pivot_chip_bg, theme.pivot_chip_fg)
304        } else {
305            (theme.header_bg, theme.menu_fg)
306        };
307
308        let state_drop = state.clone();
309        let state_remove = state.clone();
310
311        let mut available = label_budget;
312        if let Some(marker) = marker {
313            available -= CHIP_GAP + measure_text(window, marker, MARKER_FONT_SIZE);
314        }
315        if removable {
316            available -= CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE);
317        }
318        let chopped = measure_text(window, &label, CHIP_FONT_SIZE) > available + 0.5;
319
320        let mut chip = div()
321            .id(id)
322            .px(px(8.0))
323            .h(px(CHIP_HEIGHT))
324            .flex_none()
325            .rounded(px(4.0))
326            .bg(chip_bg)
327            .border_1()
328            .border_color(theme.grid_line)
329            .text_color(chip_fg)
330            .text_size(px(CHIP_FONT_SIZE))
331            .flex()
332            .items_center()
333            .gap(px(CHIP_GAP))
334            .cursor_pointer()
335            .on_drag(
336                DraggedField { field },
337                move |_drag, _offset, _window, cx| {
338                    cx.new(|_| DragGhost {
339                        label: ghost_label.clone(),
340                        bg: ghost_bg,
341                        fg: ghost_fg,
342                        border: ghost_border,
343                        animations,
344                    })
345                },
346            )
347            .child(
348                div()
349                    .flex_1()
350                    .min_w(px(0.0))
351                    .truncate()
352                    .child(label.clone()),
353            );
354
355        if chopped {
356            let tip_label: SharedString = label.into();
357            let tip_bg = theme.menu_bg;
358            let tip_fg = theme.menu_fg;
359            let tip_border = theme.grid_line;
360            chip = chip.tooltip(move |_window, cx| {
361                cx.new(|_| ChipTooltip {
362                    label: tip_label.clone(),
363                    bg: tip_bg,
364                    fg: tip_fg,
365                    border: tip_border,
366                })
367                .into()
368            });
369        }
370
371        if let Some(marker) = marker {
372            chip = chip.child(
373                div()
374                    .flex_none()
375                    .text_color(theme.sort_indicator)
376                    .text_size(px(MARKER_FONT_SIZE))
377                    .child(marker),
378            );
379        }
380
381        if removable {
382            chip = chip.child(
383                div()
384                    .flex_none()
385                    .text_color(theme.muted_text)
386                    .cursor_pointer()
387                    .child("✕")
388                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
389                        cx.stop_propagation();
390                        state_remove.update(cx, |s, cx| {
391                            s.config.remove_field(field);
392                            s.recompute();
393                            cx.notify();
394                        });
395                    }),
396            );
397        }
398
399        // Dropping onto a chip inside a zone inserts the dragged field at
400        // this chip's position (reorder support). Double-clicking a zone chip
401        // opens the per-field format dialog.
402        if let Some(zone) = zone {
403            let state_dialog = state.clone();
404            chip = chip
405                .on_drop::<DraggedField>(move |dragged, _window, cx| {
406                    cx.stop_propagation();
407                    let dragged_field = dragged.field;
408                    state_drop.update(cx, |s, cx| {
409                        let index = s.config.fields_in(zone).iter().position(|&f| f == field);
410                        s.config.move_field(dragged_field, zone, index);
411                        s.recompute();
412                        cx.notify();
413                    });
414                })
415                .on_mouse_down(MouseButton::Left, move |e: &MouseDownEvent, _w, cx| {
416                    if e.click_count == 2 {
417                        cx.stop_propagation();
418                        state_dialog.update(cx, |s, cx| {
419                            s.close_filter_popover();
420                            s.open_format_dialog(field, zone, e.position);
421                            cx.notify();
422                        });
423                    }
424                });
425        }
426
427        chip.into_any_element()
428    }
429
430    /// One drop zone with its label, hint, and assigned chips. `content_w` is
431    /// the accordion content width available to the zone box.
432    fn zone(
433        state: &Entity<PivotState>,
434        zone: PivotZone,
435        title: &'static str,
436        content_w: f32,
437        window: &Window,
438        cx: &App,
439    ) -> gpui::AnyElement {
440        let s = state.read(cx);
441        let theme = s.theme.clone();
442        let fields = s.config.fields_in(zone);
443        let columns = s.source_columns().to_vec();
444        let agg = s.config.aggregation;
445        let agg_menu_open = s.agg_menu_open;
446
447        let state_drop = state.clone();
448        let active_bg = theme.pivot_drop_zone_active_bg;
449        let label_budget = content_w - ZONE_CHROME - CHIP_CHROME;
450
451        let mut chips: Vec<gpui::AnyElement> = Vec::new();
452        for (i, &field) in fields.iter().enumerate() {
453            let name = columns
454                .get(field)
455                .map(|c| c.name.clone())
456                .unwrap_or_else(|| format!("column {field}"));
457            match zone {
458                PivotZone::Values => {
459                    chips.push(Self::values_chip(
460                        state,
461                        field,
462                        agg,
463                        agg_menu_open,
464                        name,
465                        label_budget,
466                        window,
467                        cx,
468                    ));
469                }
470                PivotZone::Filters => {
471                    let filter_on = s.filter_active(field);
472                    let state_open = state.clone();
473                    let chip = div()
474                        .id(("pivot-filter-chip", i))
475                        .on_mouse_down(MouseButton::Left, {
476                            let state_open = state_open.clone();
477                            move |e: &MouseDownEvent, _w, cx| {
478                                // Double-clicks open the format dialog (see
479                                // the inner chip); only single clicks open
480                                // the checklist. The popover is anchored a
481                                // little below the cursor so the second
482                                // click of a double-click still reaches the
483                                // chip instead of the freshly opened popover.
484                                if e.click_count != 1 {
485                                    return;
486                                }
487                                let anchor = point(e.position.x, e.position.y + px(20.0));
488                                state_open.update(cx, |s, cx| {
489                                    s.open_filter_popover(field, anchor);
490                                    cx.notify();
491                                });
492                            }
493                        })
494                        .child(Self::chip(
495                            state,
496                            ("pivot-filter-chip-inner", i),
497                            field,
498                            name,
499                            Some(zone),
500                            true,
501                            filter_on.then_some("🔽"),
502                            label_budget,
503                            window,
504                            cx,
505                        ));
506                    chips.push(chip.into_any_element());
507                }
508                PivotZone::Rows | PivotZone::Columns => {
509                    let id = match zone {
510                        PivotZone::Rows => ("pivot-row-chip", i),
511                        _ => ("pivot-col-chip", i),
512                    };
513                    chips.push(Self::chip(
514                        state,
515                        id,
516                        field,
517                        name,
518                        Some(zone),
519                        true,
520                        None,
521                        label_budget,
522                        window,
523                        cx,
524                    ));
525                }
526            }
527        }
528
529        let hint = if chips.is_empty() {
530            Some(
531                div()
532                    .text_color(theme.muted_text)
533                    .text_size(px(11.0))
534                    .child("Drag fields here"),
535            )
536        } else {
537            None
538        };
539
540        div()
541            .flex()
542            .flex_col()
543            .gap(px(4.0))
544            .child(
545                div()
546                    .text_color(theme.muted_text)
547                    .text_size(px(11.0))
548                    .child(title),
549            )
550            .child(
551                div()
552                    .min_h(px(40.0))
553                    .p(px(6.0))
554                    .rounded(px(4.0))
555                    .bg(theme.pivot_drop_zone_bg)
556                    .border_1()
557                    .border_color(theme.grid_line)
558                    .flex()
559                    .flex_col()
560                    .gap(px(4.0))
561                    .drag_over::<DraggedField>(move |style, _, _, _| style.bg(active_bg))
562                    .on_drop::<DraggedField>(move |dragged, _window, cx| {
563                        let dragged_field = dragged.field;
564                        state_drop.update(cx, |s, cx| {
565                            s.config.move_field(dragged_field, zone, None);
566                            s.recompute();
567                            cx.notify();
568                        });
569                    })
570                    .children(chips)
571                    .children(hint),
572            )
573            .into_any_element()
574    }
575
576    /// The Values-zone chip: caption plus the aggregation picker. The caption
577    /// is ellipsized so both the picker arrow and the remove button always
578    /// fit; a hover tooltip shows the full caption when chopped.
579    #[allow(clippy::too_many_arguments)]
580    fn values_chip(
581        state: &Entity<PivotState>,
582        field: usize,
583        agg: AggregationFn,
584        menu_open: bool,
585        name: String,
586        label_budget: f32,
587        window: &Window,
588        cx: &App,
589    ) -> gpui::AnyElement {
590        let s = state.read(cx);
591        let theme = s.theme.clone();
592        let animations = s.animations;
593        let state_toggle = state.clone();
594        let state_remove = state.clone();
595
596        let mut rows: Vec<gpui::AnyElement> = Vec::new();
597        let caption = agg.caption(&name);
598        let ghost_label = caption.clone();
599        let ghost_bg = theme.pivot_chip_bg;
600        let ghost_fg = theme.pivot_chip_fg;
601        let ghost_border = theme.grid_line;
602
603        let arrow = if menu_open { "▲" } else { "▼" };
604        let available = label_budget
605            - (CHIP_GAP + measure_text(window, arrow, CHIP_FONT_SIZE))
606            - (CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE));
607        let chopped = measure_text(window, &caption, CHIP_FONT_SIZE) > available + 0.5;
608
609        let mut chip = div()
610            .id("pivot-values-chip")
611            .px(px(8.0))
612            .h(px(CHIP_HEIGHT))
613            .flex_none()
614            .rounded(px(4.0))
615            .bg(theme.pivot_chip_bg)
616            .border_1()
617            .border_color(theme.grid_line)
618            .text_color(theme.pivot_chip_fg)
619            .text_size(px(CHIP_FONT_SIZE))
620            .flex()
621            .items_center()
622            .gap(px(CHIP_GAP))
623            .cursor_pointer()
624            .on_drag(
625                DraggedField { field },
626                move |_drag, _offset, _window, cx| {
627                    cx.new(|_| DragGhost {
628                        label: ghost_label.clone(),
629                        bg: ghost_bg,
630                        fg: ghost_fg,
631                        border: ghost_border,
632                        animations,
633                    })
634                },
635            )
636            .child(
637                div()
638                    .flex_1()
639                    .min_w(px(0.0))
640                    .truncate()
641                    .child(caption.clone()),
642            )
643            .child(
644                div()
645                    .flex_none()
646                    .cursor_pointer()
647                    .text_color(theme.sort_indicator)
648                    .child(arrow)
649                    .on_mouse_down(MouseButton::Left, {
650                        let state_toggle = state_toggle.clone();
651                        move |_e: &MouseDownEvent, _w, cx| {
652                            cx.stop_propagation();
653                            state_toggle.update(cx, |s, cx| {
654                                s.agg_menu_open = !s.agg_menu_open;
655                                cx.notify();
656                            });
657                        }
658                    }),
659            )
660            .child(
661                div()
662                    .flex_none()
663                    .text_color(theme.muted_text)
664                    .cursor_pointer()
665                    .child("✕")
666                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
667                        cx.stop_propagation();
668                        state_remove.update(cx, |s, cx| {
669                            s.config.remove_field(field);
670                            s.recompute();
671                            cx.notify();
672                        });
673                    }),
674            );
675
676        if chopped {
677            let tip_label: SharedString = caption.into();
678            let tip_bg = theme.menu_bg;
679            let tip_fg = theme.menu_fg;
680            let tip_border = theme.grid_line;
681            chip = chip.tooltip(move |_window, cx| {
682                cx.new(|_| ChipTooltip {
683                    label: tip_label.clone(),
684                    bg: tip_bg,
685                    fg: tip_fg,
686                    border: tip_border,
687                })
688                .into()
689            });
690        }
691
692        let state_dialog = state.clone();
693        chip = chip.on_mouse_down(MouseButton::Left, move |e: &MouseDownEvent, _w, cx| {
694            if e.click_count == 2 {
695                cx.stop_propagation();
696                state_dialog.update(cx, |s, cx| {
697                    s.open_format_dialog(field, PivotZone::Values, e.position);
698                    cx.notify();
699                });
700            }
701        });
702
703        rows.push(chip.into_any_element());
704
705        if menu_open {
706            for func in AggregationFn::all() {
707                let selected = func == agg;
708                let state_pick = state.clone();
709                rows.push(
710                    div()
711                        .px(px(8.0))
712                        .py(px(2.0))
713                        .rounded(px(3.0))
714                        .bg(if selected {
715                            theme.menu_hover_bg
716                        } else {
717                            theme.menu_bg
718                        })
719                        .text_color(theme.menu_fg)
720                        .text_size(px(12.0))
721                        .cursor_pointer()
722                        .child(func.label())
723                        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
724                            state_pick.update(cx, |s, cx| {
725                                s.config.aggregation = func;
726                                s.agg_menu_open = false;
727                                s.recompute();
728                                cx.notify();
729                            });
730                        })
731                        .into_any_element(),
732                );
733            }
734        }
735
736        div()
737            .flex()
738            .flex_col()
739            .gap(px(2.0))
740            .children(rows)
741            .into_any_element()
742    }
743
744    /// A labelled checkbox row bound to one totals option.
745    fn option_row(
746        state: &Entity<PivotState>,
747        label: &'static str,
748        checked: bool,
749        apply: impl Fn(&mut PivotState) + 'static,
750        cx: &App,
751    ) -> gpui::AnyElement {
752        let theme = state.read(cx).theme.clone();
753        let state_toggle = state.clone();
754        let hover_bg = theme.menu_hover_bg;
755        let boxed = crate::grid::checkbox(checked, &theme);
756        div()
757            .id(SharedString::from(format!("pivot-option-{label}")))
758            .flex()
759            .items_center()
760            .gap(px(8.0))
761            .px(px(4.0))
762            .py(px(3.0))
763            .rounded(px(4.0))
764            .cursor_pointer()
765            .hover(move |style| style.bg(hover_bg))
766            .text_size(px(13.0))
767            .text_color(theme.menu_fg)
768            .child(boxed)
769            .child(label)
770            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
771                state_toggle.update(cx, |s, cx| {
772                    apply(s);
773                    s.recompute();
774                    cx.notify();
775                });
776            })
777            .into_any_element()
778    }
779
780    /// Small inline text button.
781    fn text_button(
782        state: &Entity<PivotState>,
783        label: &'static str,
784        apply: impl Fn(&mut PivotState, &mut App) + 'static,
785        cx: &App,
786    ) -> gpui::AnyElement {
787        let theme = state.read(cx).theme.clone();
788        let state_btn = state.clone();
789        let hover_bg = theme.menu_hover_bg;
790        div()
791            .id(SharedString::from(format!("pivot-button-{label}")))
792            .px(px(10.0))
793            .py(px(4.0))
794            .rounded(px(4.0))
795            .border_1()
796            .border_color(theme.grid_line)
797            .bg(theme.pivot_drop_zone_bg)
798            .hover(move |style| style.bg(hover_bg))
799            .text_color(theme.menu_fg)
800            .text_size(px(12.0))
801            .cursor_pointer()
802            .child(label)
803            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
804                state_btn.update(cx, |s, cx| {
805                    apply(s, cx);
806                    cx.notify();
807                });
808            })
809            .into_any_element()
810    }
811
812    /// The Filters-zone checklist popover overlay, if open.
813    fn render_filter_popover(
814        state: &Entity<PivotState>,
815        cx: &App,
816    ) -> Option<impl IntoElement + use<>> {
817        let s = state.read(cx);
818        let popover = s.filter_popover()?.clone();
819        let theme = s.theme.clone();
820        let animations = s.animations;
821        let field_name = s
822            .source_columns()
823            .get(popover.field)
824            .map(|c| c.name.clone())
825            .unwrap_or_default();
826
827        let checkbox = |checked: bool| crate::grid::checkbox(checked, &theme);
828        let hover_bg = theme.menu_hover_bg;
829
830        let state_all = state.clone();
831        let select_all = div()
832            .id("pivot-filter-select-all")
833            .h(px(22.0))
834            .flex()
835            .items_center()
836            .gap(px(8.0))
837            .px(px(4.0))
838            .rounded(px(4.0))
839            .cursor_pointer()
840            .hover(move |style| style.bg(hover_bg))
841            .child(checkbox(popover.all_checked()))
842            .child("(Select All)")
843            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
844                state_all.update(cx, |s, cx| {
845                    s.toggle_filter_popover_select_all();
846                    cx.notify();
847                });
848            });
849
850        let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
851        for (i, row) in popover
852            .rows
853            .iter()
854            .enumerate()
855            .take(FILTER_POPOVER_MAX_ROWS)
856        {
857            let state_val = state.clone();
858            value_rows.push(
859                div()
860                    .id(("pivot-filter-value", i))
861                    .h(px(22.0))
862                    .flex()
863                    .items_center()
864                    .gap(px(8.0))
865                    .px(px(4.0))
866                    .rounded(px(4.0))
867                    .cursor_pointer()
868                    .hover(move |style| style.bg(hover_bg))
869                    .child(checkbox(row.checked))
870                    .child(div().text_color(theme.menu_fg).child(row.label.clone()))
871                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
872                        state_val.update(cx, |s, cx| {
873                            s.toggle_filter_popover_value(i);
874                            cx.notify();
875                        });
876                    })
877                    .into_any_element(),
878            );
879        }
880        let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
881
882        let state_clear = state.clone();
883        let state_close = state.clone();
884        let clear_field = popover.field;
885        let buttons = div()
886            .flex()
887            .gap(px(6.0))
888            .child(
889                div()
890                    .flex_1()
891                    .h(px(24.0))
892                    .flex()
893                    .items_center()
894                    .justify_center()
895                    .border_1()
896                    .border_color(theme.grid_line)
897                    .bg(theme.menu_hover_bg)
898                    .cursor_pointer()
899                    .child("Clear")
900                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
901                        state_clear.update(cx, |s, cx| {
902                            s.clear_filter(clear_field);
903                            cx.notify();
904                        });
905                    }),
906            )
907            .child(
908                div()
909                    .flex_1()
910                    .h(px(24.0))
911                    .flex()
912                    .items_center()
913                    .justify_center()
914                    .border_1()
915                    .border_color(theme.grid_line)
916                    .bg(theme.menu_hover_bg)
917                    .cursor_pointer()
918                    .child("Close")
919                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
920                        state_close.update(cx, |s, cx| {
921                            s.close_filter_popover();
922                            cx.notify();
923                        });
924                    }),
925            );
926
927        let body = div()
928            .flex()
929            .flex_col()
930            .w(px(240.0))
931            .p(px(10.0))
932            .gap(px(8.0))
933            .bg(theme.menu_bg)
934            .border_1()
935            .border_color(theme.grid_line)
936            .text_color(theme.menu_fg)
937            .text_size(px(13.0))
938            .child(
939                div()
940                    .text_color(theme.muted_text)
941                    .text_size(px(11.0))
942                    .child(format!("Filter: {field_name}")),
943            )
944            .child(select_all)
945            .child(
946                div()
947                    .id("pivot-filter-values")
948                    .flex()
949                    .flex_col()
950                    .max_h(px(200.0))
951                    .overflow_y_scroll()
952                    // Own the wheel in the popover's value list so it doesn't
953                    // also scroll the sidebar behind it (see the field list).
954                    .occlude()
955                    .children(value_rows)
956                    .children(truncated.then(|| {
957                        div()
958                            .text_color(theme.muted_text)
959                            .text_size(px(11.0))
960                            .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
961                    })),
962            )
963            .child(buttons);
964
965        let state_backdrop = state.clone();
966        let overlay = deferred(
967            anchored()
968                .anchor(Corner::TopLeft)
969                .position(point(popover.anchor.x, popover.anchor.y))
970                .child(
971                    div()
972                        .occlude()
973                        .child(crate::grid::motion::pop_in(
974                            body,
975                            "pivot-filter-popover",
976                            animations,
977                        ))
978                        .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
979                            state_backdrop.update(cx, |s, cx| {
980                                if s.filter_popover().is_some() {
981                                    s.close_filter_popover();
982                                    cx.notify();
983                                }
984                            });
985                        }),
986                ),
987        )
988        .with_priority(POPOVER_PRIORITY);
989        Some(overlay)
990    }
991
992    /// The per-field format dialog overlay (chip double-click), if open.
993    /// Every control applies immediately; "Reset" drops the override.
994    #[allow(clippy::too_many_lines)]
995    fn render_format_dialog(
996        state: &Entity<PivotState>,
997        cx: &App,
998    ) -> Option<impl IntoElement + use<>> {
999        use crate::config::{NumberFormat, TextAlignment};
1000
1001        let s = state.read(cx);
1002        let dialog = *s.format_dialog()?;
1003        let fmt = s.format_dialog_format()?;
1004        let theme = s.theme.clone();
1005        let animations = s.animations;
1006        let field_name = s
1007            .source_columns()
1008            .get(dialog.field)
1009            .map(|c| c.name.clone())
1010            .unwrap_or_default();
1011
1012        let c_bg = theme.menu_bg;
1013        let c_line = theme.grid_line;
1014        let c_fg = theme.menu_fg;
1015        let c_muted = theme.muted_text;
1016        let c_accent = theme.sort_indicator;
1017        let c_hover = theme.menu_hover_bg;
1018
1019        let checkbox = {
1020            let theme = theme.clone();
1021            move |checked: bool| crate::grid::checkbox(checked, &theme)
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(
1236                    div()
1237                        .occlude()
1238                        .child(crate::grid::motion::pop_in(
1239                            body,
1240                            "pivot-format-dialog",
1241                            animations,
1242                        ))
1243                        .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
1244                            state_backdrop.update(cx, |s, cx| {
1245                                if s.format_dialog().is_some() {
1246                                    s.close_format_dialog();
1247                                    cx.notify();
1248                                }
1249                            });
1250                        }),
1251                ),
1252        )
1253        .with_priority(POPOVER_PRIORITY);
1254        Some(overlay)
1255    }
1256}
1257
1258impl Render for PivotSidebar {
1259    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1260        let (theme, columns, config, sidebar_width, save_handler, animations) = {
1261            let s = self.state.read(cx);
1262            (
1263                s.theme.clone(),
1264                s.source_columns().to_vec(),
1265                s.config.clone(),
1266                s.sidebar_width,
1267                s.save_config_handler.clone(),
1268                s.animations,
1269            )
1270        };
1271        let content_w = sidebar_width - SIDEBAR_CONTENT_CHROME;
1272
1273        // Source field list with zone badges.
1274        let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
1275        for (i, col) in columns.iter().enumerate() {
1276            let badge = match config.zone_of(i) {
1277                Some(PivotZone::Rows) => Some("R"),
1278                Some(PivotZone::Columns) => Some("C"),
1279                Some(PivotZone::Values) => Some("V"),
1280                Some(PivotZone::Filters) => Some("F"),
1281                None => None,
1282            };
1283            field_chips.push(Self::chip(
1284                &self.state,
1285                ("pivot-source-field", i),
1286                i,
1287                col.name.clone(),
1288                None,
1289                false,
1290                badge,
1291                content_w - CHIP_CHROME,
1292                window,
1293                cx,
1294            ));
1295        }
1296
1297        let options = div()
1298            .flex()
1299            .flex_col()
1300            .gap(px(6.0))
1301            .child(
1302                div()
1303                    .text_color(theme.muted_text)
1304                    .text_size(px(11.0))
1305                    .child("Layout"),
1306            )
1307            .child(Self::option_row(
1308                &self.state,
1309                "Flat rows (no hierarchy)",
1310                config.flat_rows,
1311                |s| s.config.flat_rows = !s.config.flat_rows,
1312                cx,
1313            ))
1314            .child(
1315                div()
1316                    .text_color(theme.muted_text)
1317                    .text_size(px(11.0))
1318                    .child("Totals"),
1319            )
1320            .child(Self::option_row(
1321                &self.state,
1322                "Row subtotals",
1323                config.show_row_subtotals,
1324                |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
1325                cx,
1326            ))
1327            .child(Self::option_row(
1328                &self.state,
1329                "Column subtotal columns",
1330                config.show_column_subtotals,
1331                |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
1332                cx,
1333            ))
1334            .child(Self::option_row(
1335                &self.state,
1336                "Grand total row",
1337                config.show_row_grand_total,
1338                |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
1339                cx,
1340            ))
1341            .child(Self::option_row(
1342                &self.state,
1343                "Grand total column",
1344                config.show_column_grand_total,
1345                |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
1346                cx,
1347            ));
1348
1349        let tools = div()
1350            .flex()
1351            .flex_col()
1352            .gap(px(6.0))
1353            .child(
1354                div()
1355                    .text_color(theme.muted_text)
1356                    .text_size(px(11.0))
1357                    .child("Groups"),
1358            )
1359            .child(
1360                div()
1361                    .flex()
1362                    .gap(px(4.0))
1363                    .child(Self::text_button(
1364                        &self.state,
1365                        "Expand rows",
1366                        |s, _| s.expand_all_rows(),
1367                        cx,
1368                    ))
1369                    .child(Self::text_button(
1370                        &self.state,
1371                        "Collapse rows",
1372                        |s, _| s.collapse_all_rows(),
1373                        cx,
1374                    )),
1375            )
1376            .child(
1377                div()
1378                    .flex()
1379                    .gap(px(4.0))
1380                    .child(Self::text_button(
1381                        &self.state,
1382                        "Expand cols",
1383                        |s, _| s.expand_all_cols(),
1384                        cx,
1385                    ))
1386                    .child(Self::text_button(
1387                        &self.state,
1388                        "Collapse cols",
1389                        |s, _| s.collapse_all_cols(),
1390                        cx,
1391                    )),
1392            )
1393            .child(
1394                div()
1395                    .text_color(theme.muted_text)
1396                    .text_size(px(11.0))
1397                    .child("Export"),
1398            )
1399            .child(Self::text_button(
1400                &self.state,
1401                "Copy pivot as CSV",
1402                |s, cx| {
1403                    let csv = s.to_csv();
1404                    if !csv.is_empty() {
1405                        cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
1406                    }
1407                },
1408                cx,
1409            ));
1410
1411        let fields = div()
1412            .flex()
1413            .flex_col()
1414            .gap(px(8.0))
1415            .child(
1416                div()
1417                    .text_color(theme.muted_text)
1418                    .text_size(px(11.0))
1419                    .child("Drag a field into a layout zone"),
1420            )
1421            .child(
1422                div()
1423                    .id("pivot-field-list")
1424                    .flex()
1425                    .flex_col()
1426                    .gap(px(4.0))
1427                    .max_h(px(220.0))
1428                    .overflow_y_scroll()
1429                    // Own the wheel within the list's own area. Without this
1430                    // the outer `#pivot-sidebar` scroll container (also under
1431                    // the cursor) receives the same wheel event — GPUI's
1432                    // overflow-scroll handler never stops propagation — so the
1433                    // whole control panel scrolled along with the list. As a
1434                    // `BlockMouse` hitbox the list truncates the scroll
1435                    // hit-test, leaving the sidebar out of it.
1436                    .occlude()
1437                    .children(field_chips),
1438            );
1439
1440        // Save-configuration button, rendered in the Layout section header
1441        // next to its title. Only present while a host wired up the action
1442        // (via `PivotState::on_save_config` or the widget builder).
1443        let save_button = save_handler.map(|handler| {
1444            let state_save = self.state.clone();
1445            let icon_color = theme.muted_text;
1446            let hover_bg = theme.pivot_drop_zone_active_bg;
1447            let tip_bg = theme.menu_bg;
1448            let tip_fg = theme.menu_fg;
1449            let tip_border = theme.grid_line;
1450            div()
1451                .id("pivot-save-config")
1452                .p(px(2.0))
1453                .rounded(px(3.0))
1454                .cursor_pointer()
1455                .hover(move |style| style.bg(hover_bg))
1456                .child(disk_icon(icon_color))
1457                .tooltip(move |_window, cx| {
1458                    cx.new(|_| ChipTooltip {
1459                        label: "Save configuration".into(),
1460                        bg: tip_bg,
1461                        fg: tip_fg,
1462                        border: tip_border,
1463                    })
1464                    .into()
1465                })
1466                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1467                    cx.stop_propagation();
1468                    let config = state_save.read(cx).config.clone();
1469                    handler(&config, cx);
1470                })
1471                // The section header toggles on mouse-up; swallow it so a
1472                // click on the save button doesn't also collapse the section.
1473                .on_mouse_up(MouseButton::Left, |_e: &MouseUpEvent, _w, cx| {
1474                    cx.stop_propagation();
1475                })
1476                .into_any_element()
1477        });
1478
1479        let layout = div()
1480            .flex()
1481            .flex_col()
1482            .gap(px(8.0))
1483            .child(Self::zone(
1484                &self.state,
1485                PivotZone::Filters,
1486                "Filters",
1487                content_w,
1488                window,
1489                cx,
1490            ))
1491            .child(Self::zone(
1492                &self.state,
1493                PivotZone::Columns,
1494                "Columns",
1495                content_w,
1496                window,
1497                cx,
1498            ))
1499            .child(Self::zone(
1500                &self.state,
1501                PivotZone::Rows,
1502                "Rows",
1503                content_w,
1504                window,
1505                cx,
1506            ))
1507            .child(Self::zone(
1508                &self.state,
1509                PivotZone::Values,
1510                "Values",
1511                content_w,
1512                window,
1513                cx,
1514            ));
1515
1516        let display = div()
1517            .flex()
1518            .flex_col()
1519            .gap(px(10.0))
1520            .child(options)
1521            .child(tools);
1522
1523        let sidebar = cx.entity().clone();
1524        let is_expanded = |id: &str| self.expanded_sections.iter().any(|section| section == id);
1525        let sections = div()
1526            .flex()
1527            .flex_col()
1528            .border_1()
1529            .border_color(theme.grid_line)
1530            .rounded_lg()
1531            .child(Self::section(
1532                &sidebar,
1533                &theme,
1534                "fields",
1535                "Fields",
1536                None,
1537                is_expanded("fields"),
1538                true,
1539                animations,
1540                fields.into_any_element(),
1541            ))
1542            .child(Self::section(
1543                &sidebar,
1544                &theme,
1545                "layout",
1546                "Layout",
1547                save_button,
1548                is_expanded("layout"),
1549                false,
1550                animations,
1551                layout.into_any_element(),
1552            ))
1553            .child(Self::section(
1554                &sidebar,
1555                &theme,
1556                "display",
1557                "Display and export",
1558                None,
1559                is_expanded("display"),
1560                false,
1561                animations,
1562                display.into_any_element(),
1563            ));
1564
1565        div()
1566            .id("pivot-sidebar")
1567            .h_full()
1568            .flex()
1569            .flex_col()
1570            .gap(px(8.0))
1571            .p(px(8.0))
1572            .bg(theme.menu_bg)
1573            .text_color(theme.menu_fg)
1574            .text_size(px(12.0))
1575            .overflow_y_scroll()
1576            .child(
1577                div()
1578                    .text_color(theme.header_fg)
1579                    .text_size(px(14.0))
1580                    .font_weight(FontWeight::SEMIBOLD)
1581                    .child("Pivot Controls"),
1582            )
1583            .child(sections)
1584            .children(Self::render_filter_popover(&self.state, cx))
1585            .children(Self::render_format_dialog(&self.state, cx))
1586    }
1587}