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