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::pivot::aggregation::AggregationFn;
11use crate::pivot::config::PivotZone;
12use crate::pivot::state::PivotState;
13
14use gpui::{
15    anchored, deferred, div, point, px, App, AppContext as _, Context, Corner, Entity,
16    InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Render,
17    SharedString, StatefulInteractiveElement, Styled, Window,
18};
19use gpui_ui_kit::accordion::{Accordion, AccordionItem, AccordionMode, AccordionTheme};
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/// Payload carried by a field-chip drag.
27struct DraggedField {
28    field: usize,
29}
30
31/// The ghost chip rendered under the cursor during a drag.
32struct DragGhost {
33    label: String,
34    bg: gpui::Hsla,
35    fg: gpui::Hsla,
36    border: gpui::Hsla,
37}
38
39impl Render for DragGhost {
40    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
41        div()
42            .px(px(8.0))
43            .py(px(3.0))
44            .rounded(px(4.0))
45            .bg(self.bg)
46            .border_1()
47            .border_color(self.border)
48            .text_color(self.fg)
49            .text_size(px(12.0))
50            .child(self.label.clone())
51    }
52}
53
54/// Drag-and-drop pivot configuration sidebar. Owns nothing but a handle to
55/// the shared [`PivotState`].
56pub struct PivotSidebar {
57    /// The shared pivot state.
58    pub state: Entity<PivotState>,
59    expanded_sections: Vec<SharedString>,
60}
61
62impl PivotSidebar {
63    /// Wrap an existing pivot state entity.
64    #[must_use]
65    pub fn new(state: Entity<PivotState>) -> Self {
66        Self {
67            state,
68            expanded_sections: vec!["fields".into(), "layout".into(), "display".into()],
69        }
70    }
71
72    fn set_section_expanded(&mut self, id: &SharedString, expanded: bool) {
73        if expanded {
74            if !self.expanded_sections.contains(id) {
75                self.expanded_sections.push(id.clone());
76            }
77        } else {
78            self.expanded_sections.retain(|section| section != id);
79        }
80    }
81
82    /// A draggable field chip. `zone` is `None` for the source field list.
83    #[allow(clippy::too_many_arguments)]
84    fn chip(
85        state: &Entity<PivotState>,
86        id: (&'static str, usize),
87        field: usize,
88        label: String,
89        zone: Option<PivotZone>,
90        removable: bool,
91        marker: Option<&'static str>,
92        cx: &App,
93    ) -> gpui::AnyElement {
94        let s = state.read(cx);
95        let theme = s.theme.clone();
96        let ghost_label = label.clone();
97        let ghost_bg = theme.pivot_chip_bg;
98        let ghost_fg = theme.pivot_chip_fg;
99        let ghost_border = theme.grid_line;
100
101        let state_drop = state.clone();
102        let state_remove = state.clone();
103
104        let mut chip = div()
105            .id(id)
106            .px(px(8.0))
107            .py(px(3.0))
108            .rounded(px(4.0))
109            .bg(theme.pivot_chip_bg)
110            .border_1()
111            .border_color(theme.grid_line)
112            .text_color(theme.pivot_chip_fg)
113            .text_size(px(12.0))
114            .flex()
115            .items_center()
116            .gap(px(6.0))
117            .cursor_pointer()
118            .on_drag(
119                DraggedField { field },
120                move |_drag, _offset, _window, cx| {
121                    cx.new(|_| DragGhost {
122                        label: ghost_label.clone(),
123                        bg: ghost_bg,
124                        fg: ghost_fg,
125                        border: ghost_border,
126                    })
127                },
128            )
129            .child(div().flex_1().child(label));
130
131        if let Some(marker) = marker {
132            chip = chip.child(
133                div()
134                    .text_color(theme.sort_indicator)
135                    .text_size(px(11.0))
136                    .child(marker),
137            );
138        }
139
140        if removable {
141            chip = chip.child(
142                div()
143                    .text_color(theme.muted_text)
144                    .cursor_pointer()
145                    .child("✕")
146                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
147                        cx.stop_propagation();
148                        state_remove.update(cx, |s, cx| {
149                            s.config.remove_field(field);
150                            s.recompute();
151                            cx.notify();
152                        });
153                    }),
154            );
155        }
156
157        // Dropping onto a chip inside a zone inserts the dragged field at
158        // this chip's position (reorder support).
159        if let Some(zone) = zone {
160            chip = chip.on_drop::<DraggedField>(move |dragged, _window, cx| {
161                cx.stop_propagation();
162                let dragged_field = dragged.field;
163                state_drop.update(cx, |s, cx| {
164                    let index = s.config.fields_in(zone).iter().position(|&f| f == field);
165                    s.config.move_field(dragged_field, zone, index);
166                    s.recompute();
167                    cx.notify();
168                });
169            });
170        }
171
172        chip.into_any_element()
173    }
174
175    /// One drop zone with its label, hint, and assigned chips.
176    fn zone(
177        state: &Entity<PivotState>,
178        zone: PivotZone,
179        title: &'static str,
180        cx: &App,
181    ) -> gpui::AnyElement {
182        let s = state.read(cx);
183        let theme = s.theme.clone();
184        let fields = s.config.fields_in(zone);
185        let columns = s.source_columns().to_vec();
186        let agg = s.config.aggregation;
187        let agg_menu_open = s.agg_menu_open;
188
189        let state_drop = state.clone();
190        let active_bg = theme.pivot_drop_zone_active_bg;
191
192        let mut chips: Vec<gpui::AnyElement> = Vec::new();
193        for (i, &field) in fields.iter().enumerate() {
194            let name = columns
195                .get(field)
196                .map(|c| c.name.clone())
197                .unwrap_or_else(|| format!("column {field}"));
198            match zone {
199                PivotZone::Values => {
200                    chips.push(Self::values_chip(
201                        state,
202                        field,
203                        agg,
204                        agg_menu_open,
205                        name,
206                        cx,
207                    ));
208                }
209                PivotZone::Filters => {
210                    let filter_on = s.filter_active(field);
211                    let state_open = state.clone();
212                    let chip = div()
213                        .id(("pivot-filter-chip", i))
214                        .on_mouse_down(MouseButton::Left, {
215                            let state_open = state_open.clone();
216                            move |e: &MouseDownEvent, _w, cx| {
217                                state_open.update(cx, |s, cx| {
218                                    s.open_filter_popover(field, e.position);
219                                    cx.notify();
220                                });
221                            }
222                        })
223                        .child(Self::chip(
224                            state,
225                            ("pivot-filter-chip-inner", i),
226                            field,
227                            name,
228                            Some(zone),
229                            true,
230                            filter_on.then_some("●"),
231                            cx,
232                        ));
233                    chips.push(chip.into_any_element());
234                }
235                PivotZone::Rows | PivotZone::Columns => {
236                    let id = match zone {
237                        PivotZone::Rows => ("pivot-row-chip", i),
238                        _ => ("pivot-col-chip", i),
239                    };
240                    chips.push(Self::chip(
241                        state,
242                        id,
243                        field,
244                        name,
245                        Some(zone),
246                        true,
247                        None,
248                        cx,
249                    ));
250                }
251            }
252        }
253
254        let hint = if chips.is_empty() {
255            Some(
256                div()
257                    .text_color(theme.muted_text)
258                    .text_size(px(11.0))
259                    .child("Drag fields here"),
260            )
261        } else {
262            None
263        };
264
265        div()
266            .flex()
267            .flex_col()
268            .gap(px(4.0))
269            .child(
270                div()
271                    .text_color(theme.muted_text)
272                    .text_size(px(11.0))
273                    .child(title),
274            )
275            .child(
276                div()
277                    .min_h(px(40.0))
278                    .p(px(6.0))
279                    .rounded(px(4.0))
280                    .bg(theme.pivot_drop_zone_bg)
281                    .border_1()
282                    .border_color(theme.grid_line)
283                    .flex()
284                    .flex_col()
285                    .gap(px(4.0))
286                    .drag_over::<DraggedField>(move |style, _, _, _| style.bg(active_bg))
287                    .on_drop::<DraggedField>(move |dragged, _window, cx| {
288                        let dragged_field = dragged.field;
289                        state_drop.update(cx, |s, cx| {
290                            s.config.move_field(dragged_field, zone, None);
291                            s.recompute();
292                            cx.notify();
293                        });
294                    })
295                    .children(chips)
296                    .children(hint),
297            )
298            .into_any_element()
299    }
300
301    /// The Values-zone chip: caption plus the aggregation picker.
302    fn values_chip(
303        state: &Entity<PivotState>,
304        field: usize,
305        agg: AggregationFn,
306        menu_open: bool,
307        name: String,
308        cx: &App,
309    ) -> gpui::AnyElement {
310        let s = state.read(cx);
311        let theme = s.theme.clone();
312        let state_toggle = state.clone();
313        let state_remove = state.clone();
314
315        let mut rows: Vec<gpui::AnyElement> = Vec::new();
316        let ghost_label = agg.caption(&name);
317        let ghost_bg = theme.pivot_chip_bg;
318        let ghost_fg = theme.pivot_chip_fg;
319        let ghost_border = theme.grid_line;
320        rows.push(
321            div()
322                .id("pivot-values-chip")
323                .px(px(8.0))
324                .py(px(3.0))
325                .rounded(px(4.0))
326                .bg(theme.pivot_chip_bg)
327                .border_1()
328                .border_color(theme.grid_line)
329                .text_color(theme.pivot_chip_fg)
330                .text_size(px(12.0))
331                .flex()
332                .items_center()
333                .gap(px(6.0))
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                        })
344                    },
345                )
346                .child(div().flex_1().child(agg.caption(&name)))
347                .child(
348                    div()
349                        .cursor_pointer()
350                        .text_color(theme.sort_indicator)
351                        .child(if menu_open { "▲" } else { "▼" })
352                        .on_mouse_down(MouseButton::Left, {
353                            let state_toggle = state_toggle.clone();
354                            move |_e: &MouseDownEvent, _w, cx| {
355                                cx.stop_propagation();
356                                state_toggle.update(cx, |s, cx| {
357                                    s.agg_menu_open = !s.agg_menu_open;
358                                    cx.notify();
359                                });
360                            }
361                        }),
362                )
363                .child(
364                    div()
365                        .text_color(theme.muted_text)
366                        .cursor_pointer()
367                        .child("✕")
368                        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
369                            cx.stop_propagation();
370                            state_remove.update(cx, |s, cx| {
371                                s.config.remove_field(field);
372                                s.recompute();
373                                cx.notify();
374                            });
375                        }),
376                )
377                .into_any_element(),
378        );
379
380        if menu_open {
381            for func in AggregationFn::all() {
382                let selected = func == agg;
383                let state_pick = state.clone();
384                rows.push(
385                    div()
386                        .px(px(8.0))
387                        .py(px(2.0))
388                        .rounded(px(3.0))
389                        .bg(if selected {
390                            theme.menu_hover_bg
391                        } else {
392                            theme.menu_bg
393                        })
394                        .text_color(theme.menu_fg)
395                        .text_size(px(12.0))
396                        .cursor_pointer()
397                        .child(func.label())
398                        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
399                            state_pick.update(cx, |s, cx| {
400                                s.config.aggregation = func;
401                                s.agg_menu_open = false;
402                                s.recompute();
403                                cx.notify();
404                            });
405                        })
406                        .into_any_element(),
407                );
408            }
409        }
410
411        div()
412            .flex()
413            .flex_col()
414            .gap(px(2.0))
415            .children(rows)
416            .into_any_element()
417    }
418
419    /// A labelled checkbox row bound to one totals option.
420    fn option_row(
421        state: &Entity<PivotState>,
422        label: &'static str,
423        checked: bool,
424        apply: impl Fn(&mut PivotState) + 'static,
425        cx: &App,
426    ) -> gpui::AnyElement {
427        let theme = state.read(cx).theme.clone();
428        let state_toggle = state.clone();
429        let boxed = div()
430            .w(px(12.0))
431            .h(px(12.0))
432            .border_1()
433            .border_color(theme.grid_line)
434            .bg(theme.menu_bg)
435            .flex()
436            .items_center()
437            .justify_center()
438            .children(checked.then(|| div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator)));
439        div()
440            .flex()
441            .items_center()
442            .gap(px(6.0))
443            .cursor_pointer()
444            .text_size(px(12.0))
445            .text_color(theme.menu_fg)
446            .child(boxed)
447            .child(label)
448            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
449                state_toggle.update(cx, |s, cx| {
450                    apply(s);
451                    s.recompute();
452                    cx.notify();
453                });
454            })
455            .into_any_element()
456    }
457
458    /// Small inline text button.
459    fn text_button(
460        state: &Entity<PivotState>,
461        label: &'static str,
462        apply: impl Fn(&mut PivotState, &mut App) + 'static,
463        cx: &App,
464    ) -> gpui::AnyElement {
465        let theme = state.read(cx).theme.clone();
466        let state_btn = state.clone();
467        div()
468            .px(px(6.0))
469            .py(px(2.0))
470            .rounded(px(3.0))
471            .border_1()
472            .border_color(theme.grid_line)
473            .bg(theme.pivot_drop_zone_bg)
474            .text_color(theme.menu_fg)
475            .text_size(px(11.0))
476            .cursor_pointer()
477            .child(label)
478            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
479                state_btn.update(cx, |s, cx| {
480                    apply(s, cx);
481                    cx.notify();
482                });
483            })
484            .into_any_element()
485    }
486
487    /// The Filters-zone checklist popover overlay, if open.
488    fn render_filter_popover(
489        state: &Entity<PivotState>,
490        cx: &App,
491    ) -> Option<impl IntoElement + use<>> {
492        let s = state.read(cx);
493        let popover = s.filter_popover()?.clone();
494        let theme = s.theme.clone();
495        let field_name = s
496            .source_columns()
497            .get(popover.field)
498            .map(|c| c.name.clone())
499            .unwrap_or_default();
500
501        let checkbox = |checked: bool| {
502            let mut b = div()
503                .w(px(12.0))
504                .h(px(12.0))
505                .border_1()
506                .border_color(theme.grid_line)
507                .bg(theme.menu_bg)
508                .flex()
509                .items_center()
510                .justify_center();
511            if checked {
512                b = b.child(div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator));
513            }
514            b
515        };
516
517        let state_all = state.clone();
518        let select_all = div()
519            .h(px(22.0))
520            .flex()
521            .items_center()
522            .gap(px(6.0))
523            .cursor_pointer()
524            .child(checkbox(popover.all_checked()))
525            .child("(Select All)")
526            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
527                state_all.update(cx, |s, cx| {
528                    s.toggle_filter_popover_select_all();
529                    cx.notify();
530                });
531            });
532
533        let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
534        for (i, row) in popover
535            .rows
536            .iter()
537            .enumerate()
538            .take(FILTER_POPOVER_MAX_ROWS)
539        {
540            let state_val = state.clone();
541            value_rows.push(
542                div()
543                    .h(px(20.0))
544                    .flex()
545                    .items_center()
546                    .gap(px(6.0))
547                    .cursor_pointer()
548                    .child(checkbox(row.checked))
549                    .child(div().text_color(theme.menu_fg).child(row.label.clone()))
550                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
551                        state_val.update(cx, |s, cx| {
552                            s.toggle_filter_popover_value(i);
553                            cx.notify();
554                        });
555                    })
556                    .into_any_element(),
557            );
558        }
559        let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
560
561        let state_clear = state.clone();
562        let state_close = state.clone();
563        let clear_field = popover.field;
564        let buttons = div()
565            .flex()
566            .gap(px(6.0))
567            .child(
568                div()
569                    .flex_1()
570                    .h(px(24.0))
571                    .flex()
572                    .items_center()
573                    .justify_center()
574                    .border_1()
575                    .border_color(theme.grid_line)
576                    .bg(theme.menu_hover_bg)
577                    .cursor_pointer()
578                    .child("Clear")
579                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
580                        state_clear.update(cx, |s, cx| {
581                            s.clear_filter(clear_field);
582                            cx.notify();
583                        });
584                    }),
585            )
586            .child(
587                div()
588                    .flex_1()
589                    .h(px(24.0))
590                    .flex()
591                    .items_center()
592                    .justify_center()
593                    .border_1()
594                    .border_color(theme.grid_line)
595                    .bg(theme.menu_hover_bg)
596                    .cursor_pointer()
597                    .child("Close")
598                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
599                        state_close.update(cx, |s, cx| {
600                            s.close_filter_popover();
601                            cx.notify();
602                        });
603                    }),
604            );
605
606        let body = div()
607            .flex()
608            .flex_col()
609            .w(px(240.0))
610            .p(px(8.0))
611            .gap(px(6.0))
612            .bg(theme.menu_bg)
613            .border_1()
614            .border_color(theme.grid_line)
615            .text_color(theme.menu_fg)
616            .text_size(px(12.0))
617            .child(
618                div()
619                    .text_color(theme.muted_text)
620                    .text_size(px(11.0))
621                    .child(format!("Filter: {field_name}")),
622            )
623            .child(select_all)
624            .child(
625                div()
626                    .id("pivot-filter-values")
627                    .flex()
628                    .flex_col()
629                    .max_h(px(200.0))
630                    .overflow_y_scroll()
631                    .children(value_rows)
632                    .children(truncated.then(|| {
633                        div()
634                            .text_color(theme.muted_text)
635                            .text_size(px(11.0))
636                            .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
637                    })),
638            )
639            .child(buttons);
640
641        let state_backdrop = state.clone();
642        let overlay = deferred(
643            anchored()
644                .anchor(Corner::TopLeft)
645                .position(point(popover.anchor.x, popover.anchor.y))
646                .child(div().occlude().child(body).on_mouse_down_out(
647                    move |_e: &MouseDownEvent, _window, cx| {
648                        state_backdrop.update(cx, |s, cx| {
649                            if s.filter_popover().is_some() {
650                                s.close_filter_popover();
651                                cx.notify();
652                            }
653                        });
654                    },
655                )),
656        )
657        .with_priority(POPOVER_PRIORITY);
658        Some(overlay)
659    }
660}
661
662impl Render for PivotSidebar {
663    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
664        let (theme, columns, config) = {
665            let s = self.state.read(cx);
666            (
667                s.theme.clone(),
668                s.source_columns().to_vec(),
669                s.config.clone(),
670            )
671        };
672
673        // Source field list with zone badges.
674        let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
675        for (i, col) in columns.iter().enumerate() {
676            let badge = match config.zone_of(i) {
677                Some(PivotZone::Rows) => Some("R"),
678                Some(PivotZone::Columns) => Some("C"),
679                Some(PivotZone::Values) => Some("V"),
680                Some(PivotZone::Filters) => Some("F"),
681                None => None,
682            };
683            field_chips.push(Self::chip(
684                &self.state,
685                ("pivot-source-field", i),
686                i,
687                col.name.clone(),
688                None,
689                false,
690                badge,
691                cx,
692            ));
693        }
694
695        let options = div()
696            .flex()
697            .flex_col()
698            .gap(px(4.0))
699            .child(
700                div()
701                    .text_color(theme.muted_text)
702                    .text_size(px(11.0))
703                    .child("Totals"),
704            )
705            .child(Self::option_row(
706                &self.state,
707                "Row subtotals",
708                config.show_row_subtotals,
709                |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
710                cx,
711            ))
712            .child(Self::option_row(
713                &self.state,
714                "Column subtotal columns",
715                config.show_column_subtotals,
716                |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
717                cx,
718            ))
719            .child(Self::option_row(
720                &self.state,
721                "Grand total row",
722                config.show_row_grand_total,
723                |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
724                cx,
725            ))
726            .child(Self::option_row(
727                &self.state,
728                "Grand total column",
729                config.show_column_grand_total,
730                |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
731                cx,
732            ));
733
734        let tools = div()
735            .flex()
736            .flex_col()
737            .gap(px(4.0))
738            .child(
739                div()
740                    .text_color(theme.muted_text)
741                    .text_size(px(11.0))
742                    .child("Groups"),
743            )
744            .child(
745                div()
746                    .flex()
747                    .gap(px(4.0))
748                    .child(Self::text_button(
749                        &self.state,
750                        "Expand rows",
751                        |s, _| s.expand_all_rows(),
752                        cx,
753                    ))
754                    .child(Self::text_button(
755                        &self.state,
756                        "Collapse rows",
757                        |s, _| s.collapse_all_rows(),
758                        cx,
759                    )),
760            )
761            .child(
762                div()
763                    .flex()
764                    .gap(px(4.0))
765                    .child(Self::text_button(
766                        &self.state,
767                        "Expand cols",
768                        |s, _| s.expand_all_cols(),
769                        cx,
770                    ))
771                    .child(Self::text_button(
772                        &self.state,
773                        "Collapse cols",
774                        |s, _| s.collapse_all_cols(),
775                        cx,
776                    )),
777            )
778            .child(
779                div()
780                    .text_color(theme.muted_text)
781                    .text_size(px(11.0))
782                    .child("Export"),
783            )
784            .child(Self::text_button(
785                &self.state,
786                "Copy pivot as CSV",
787                |s, cx| {
788                    let csv = s.to_csv();
789                    if !csv.is_empty() {
790                        cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
791                    }
792                },
793                cx,
794            ));
795
796        let fields = div()
797            .flex()
798            .flex_col()
799            .gap(px(3.0))
800            .child(
801                div()
802                    .text_color(theme.muted_text)
803                    .text_size(px(11.0))
804                    .child("Drag a field into a layout zone"),
805            )
806            .child(
807                div()
808                    .id("pivot-field-list")
809                    .flex()
810                    .flex_col()
811                    .gap(px(3.0))
812                    .max_h(px(220.0))
813                    .overflow_y_scroll()
814                    .children(field_chips),
815            );
816
817        let layout = div()
818            .flex()
819            .flex_col()
820            .gap(px(6.0))
821            .child(Self::zone(&self.state, PivotZone::Filters, "Filters", cx))
822            .child(Self::zone(&self.state, PivotZone::Columns, "Columns", cx))
823            .child(Self::zone(&self.state, PivotZone::Rows, "Rows", cx))
824            .child(Self::zone(&self.state, PivotZone::Values, "Values", cx));
825
826        let display = div()
827            .flex()
828            .flex_col()
829            .gap(px(10.0))
830            .child(options)
831            .child(tools);
832
833        let accordion_theme = AccordionTheme {
834            header_bg: theme.header_bg.into(),
835            header_hover_bg: theme.menu_hover_bg.into(),
836            content_bg: theme.menu_bg.into(),
837            border: theme.grid_line.into(),
838            title_color: theme.header_fg.into(),
839            indicator_color: theme.muted_text.into(),
840        };
841        let sidebar = cx.entity().clone();
842        let accordion = Accordion::new()
843            .mode(AccordionMode::Multiple)
844            .expanded(self.expanded_sections.clone())
845            .theme(accordion_theme)
846            .item(AccordionItem::new("fields", "Fields").content(fields))
847            .item(AccordionItem::new("layout", "Layout").content(layout))
848            .item(AccordionItem::new("display", "Display and export").content(display))
849            .on_change(move |id, expanded, _window, cx| {
850                sidebar.update(cx, |this, cx| {
851                    this.set_section_expanded(id, expanded);
852                    cx.notify();
853                });
854            });
855
856        div()
857            .id("pivot-sidebar")
858            .h_full()
859            .flex()
860            .flex_col()
861            .gap(px(8.0))
862            .p(px(8.0))
863            .bg(theme.menu_bg)
864            .text_color(theme.menu_fg)
865            .text_size(px(12.0))
866            .overflow_y_scroll()
867            .child(
868                div()
869                    .text_color(theme.header_fg)
870                    .text_size(px(13.0))
871                    .child("Pivot Controls"),
872            )
873            .child(accordion)
874            .children(Self::render_filter_popover(&self.state, cx))
875    }
876}