1use 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
21const POPOVER_PRIORITY: usize = 1_000_000;
23const FILTER_POPOVER_MAX_ROWS: usize = 200;
25
26const SIDEBAR_CONTENT_CHROME: f32 = 50.0;
29const ZONE_CHROME: f32 = 14.0;
31const CHIP_CHROME: f32 = 18.0;
33const CHIP_GAP: f32 = 6.0;
36const CHIP_FONT_SIZE: f32 = 12.0;
38const MARKER_FONT_SIZE: f32 = 11.0;
40
41fn measure_text(window: &Window, text: &str, size: f32) -> f32 {
43 if text.is_empty() {
44 return 0.0;
45 }
46 let run = gpui::TextRun {
47 len: text.len(),
48 color: gpui::Hsla::default(),
49 font: window.text_style().font(),
50 background_color: None,
51 underline: None,
52 strikethrough: None,
53 };
54 let line = window.text_system().shape_line(
55 SharedString::from(text.to_owned()),
56 px(size),
57 &[run],
58 None,
59 );
60 f32::from(line.width)
61}
62
63struct ChipTooltip {
65 label: SharedString,
66 bg: gpui::Hsla,
67 fg: gpui::Hsla,
68 border: gpui::Hsla,
69}
70
71impl Render for ChipTooltip {
72 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
73 div()
74 .px(px(8.0))
75 .py(px(4.0))
76 .rounded(px(4.0))
77 .bg(self.bg)
78 .border_1()
79 .border_color(self.border)
80 .text_color(self.fg)
81 .text_size(px(CHIP_FONT_SIZE))
82 .child(self.label.clone())
83 }
84}
85
86fn disk_icon(color: gpui::Hsla) -> gpui::Div {
88 div()
89 .w(px(13.0))
90 .h(px(13.0))
91 .rounded(px(2.0))
92 .border_1()
93 .border_color(color)
94 .relative()
95 .child(
96 div()
97 .absolute()
98 .top(px(0.0))
99 .left(px(3.0))
100 .w(px(5.0))
101 .h(px(4.0))
102 .border_1()
103 .border_color(color),
104 )
105 .child(
106 div()
107 .absolute()
108 .bottom(px(1.0))
109 .left(px(2.0))
110 .w(px(7.0))
111 .h(px(4.0))
112 .bg(color),
113 )
114}
115
116struct DraggedField {
118 field: usize,
119}
120
121struct DragGhost {
123 label: String,
124 bg: gpui::Hsla,
125 fg: gpui::Hsla,
126 border: gpui::Hsla,
127}
128
129impl Render for DragGhost {
130 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
131 div()
132 .px(px(8.0))
133 .py(px(3.0))
134 .rounded(px(4.0))
135 .bg(self.bg)
136 .border_1()
137 .border_color(self.border)
138 .text_color(self.fg)
139 .text_size(px(12.0))
140 .child(self.label.clone())
141 }
142}
143
144pub struct PivotSidebar {
147 pub state: Entity<PivotState>,
149 expanded_sections: Vec<SharedString>,
150}
151
152impl PivotSidebar {
153 #[must_use]
155 pub fn new(state: Entity<PivotState>) -> Self {
156 Self {
157 state,
158 expanded_sections: vec!["fields".into(), "layout".into(), "display".into()],
159 }
160 }
161
162 fn set_section_expanded(&mut self, id: &SharedString, expanded: bool) {
163 if expanded {
164 if !self.expanded_sections.contains(id) {
165 self.expanded_sections.push(id.clone());
166 }
167 } else {
168 self.expanded_sections.retain(|section| section != id);
169 }
170 }
171
172 #[allow(clippy::too_many_arguments)]
177 fn chip(
178 state: &Entity<PivotState>,
179 id: (&'static str, usize),
180 field: usize,
181 label: String,
182 zone: Option<PivotZone>,
183 removable: bool,
184 marker: Option<&'static str>,
185 label_budget: f32,
186 window: &Window,
187 cx: &App,
188 ) -> gpui::AnyElement {
189 let s = state.read(cx);
190 let theme = s.theme.clone();
191 let ghost_label = label.clone();
192 let ghost_bg = theme.pivot_chip_bg;
193 let ghost_fg = theme.pivot_chip_fg;
194 let ghost_border = theme.grid_line;
195
196 let state_drop = state.clone();
197 let state_remove = state.clone();
198
199 let mut available = label_budget;
200 if let Some(marker) = marker {
201 available -= CHIP_GAP + measure_text(window, marker, MARKER_FONT_SIZE);
202 }
203 if removable {
204 available -= CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE);
205 }
206 let chopped = measure_text(window, &label, CHIP_FONT_SIZE) > available + 0.5;
207
208 let mut chip = div()
209 .id(id)
210 .px(px(8.0))
211 .py(px(3.0))
212 .rounded(px(4.0))
213 .bg(theme.pivot_chip_bg)
214 .border_1()
215 .border_color(theme.grid_line)
216 .text_color(theme.pivot_chip_fg)
217 .text_size(px(CHIP_FONT_SIZE))
218 .flex()
219 .items_center()
220 .gap(px(CHIP_GAP))
221 .cursor_pointer()
222 .on_drag(
223 DraggedField { field },
224 move |_drag, _offset, _window, cx| {
225 cx.new(|_| DragGhost {
226 label: ghost_label.clone(),
227 bg: ghost_bg,
228 fg: ghost_fg,
229 border: ghost_border,
230 })
231 },
232 )
233 .child(
234 div()
235 .flex_1()
236 .min_w(px(0.0))
237 .truncate()
238 .child(label.clone()),
239 );
240
241 if chopped {
242 let tip_label: SharedString = label.into();
243 let tip_bg = theme.menu_bg;
244 let tip_fg = theme.menu_fg;
245 let tip_border = theme.grid_line;
246 chip = chip.tooltip(move |_window, cx| {
247 cx.new(|_| ChipTooltip {
248 label: tip_label.clone(),
249 bg: tip_bg,
250 fg: tip_fg,
251 border: tip_border,
252 })
253 .into()
254 });
255 }
256
257 if let Some(marker) = marker {
258 chip = chip.child(
259 div()
260 .flex_none()
261 .text_color(theme.sort_indicator)
262 .text_size(px(MARKER_FONT_SIZE))
263 .child(marker),
264 );
265 }
266
267 if removable {
268 chip = chip.child(
269 div()
270 .flex_none()
271 .text_color(theme.muted_text)
272 .cursor_pointer()
273 .child("✕")
274 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
275 cx.stop_propagation();
276 state_remove.update(cx, |s, cx| {
277 s.config.remove_field(field);
278 s.recompute();
279 cx.notify();
280 });
281 }),
282 );
283 }
284
285 if let Some(zone) = zone {
288 chip = chip.on_drop::<DraggedField>(move |dragged, _window, cx| {
289 cx.stop_propagation();
290 let dragged_field = dragged.field;
291 state_drop.update(cx, |s, cx| {
292 let index = s.config.fields_in(zone).iter().position(|&f| f == field);
293 s.config.move_field(dragged_field, zone, index);
294 s.recompute();
295 cx.notify();
296 });
297 });
298 }
299
300 chip.into_any_element()
301 }
302
303 fn zone(
306 state: &Entity<PivotState>,
307 zone: PivotZone,
308 title: &'static str,
309 content_w: f32,
310 window: &Window,
311 cx: &App,
312 ) -> gpui::AnyElement {
313 let s = state.read(cx);
314 let theme = s.theme.clone();
315 let fields = s.config.fields_in(zone);
316 let columns = s.source_columns().to_vec();
317 let agg = s.config.aggregation;
318 let agg_menu_open = s.agg_menu_open;
319
320 let state_drop = state.clone();
321 let active_bg = theme.pivot_drop_zone_active_bg;
322 let label_budget = content_w - ZONE_CHROME - CHIP_CHROME;
323
324 let mut chips: Vec<gpui::AnyElement> = Vec::new();
325 for (i, &field) in fields.iter().enumerate() {
326 let name = columns
327 .get(field)
328 .map(|c| c.name.clone())
329 .unwrap_or_else(|| format!("column {field}"));
330 match zone {
331 PivotZone::Values => {
332 chips.push(Self::values_chip(
333 state,
334 field,
335 agg,
336 agg_menu_open,
337 name,
338 label_budget,
339 window,
340 cx,
341 ));
342 }
343 PivotZone::Filters => {
344 let filter_on = s.filter_active(field);
345 let state_open = state.clone();
346 let chip = div()
347 .id(("pivot-filter-chip", i))
348 .on_mouse_down(MouseButton::Left, {
349 let state_open = state_open.clone();
350 move |e: &MouseDownEvent, _w, cx| {
351 state_open.update(cx, |s, cx| {
352 s.open_filter_popover(field, e.position);
353 cx.notify();
354 });
355 }
356 })
357 .child(Self::chip(
358 state,
359 ("pivot-filter-chip-inner", i),
360 field,
361 name,
362 Some(zone),
363 true,
364 filter_on.then_some("●"),
365 label_budget,
366 window,
367 cx,
368 ));
369 chips.push(chip.into_any_element());
370 }
371 PivotZone::Rows | PivotZone::Columns => {
372 let id = match zone {
373 PivotZone::Rows => ("pivot-row-chip", i),
374 _ => ("pivot-col-chip", i),
375 };
376 chips.push(Self::chip(
377 state,
378 id,
379 field,
380 name,
381 Some(zone),
382 true,
383 None,
384 label_budget,
385 window,
386 cx,
387 ));
388 }
389 }
390 }
391
392 let hint = if chips.is_empty() {
393 Some(
394 div()
395 .text_color(theme.muted_text)
396 .text_size(px(11.0))
397 .child("Drag fields here"),
398 )
399 } else {
400 None
401 };
402
403 div()
404 .flex()
405 .flex_col()
406 .gap(px(4.0))
407 .child(
408 div()
409 .text_color(theme.muted_text)
410 .text_size(px(11.0))
411 .child(title),
412 )
413 .child(
414 div()
415 .min_h(px(40.0))
416 .p(px(6.0))
417 .rounded(px(4.0))
418 .bg(theme.pivot_drop_zone_bg)
419 .border_1()
420 .border_color(theme.grid_line)
421 .flex()
422 .flex_col()
423 .gap(px(4.0))
424 .drag_over::<DraggedField>(move |style, _, _, _| style.bg(active_bg))
425 .on_drop::<DraggedField>(move |dragged, _window, cx| {
426 let dragged_field = dragged.field;
427 state_drop.update(cx, |s, cx| {
428 s.config.move_field(dragged_field, zone, None);
429 s.recompute();
430 cx.notify();
431 });
432 })
433 .children(chips)
434 .children(hint),
435 )
436 .into_any_element()
437 }
438
439 #[allow(clippy::too_many_arguments)]
443 fn values_chip(
444 state: &Entity<PivotState>,
445 field: usize,
446 agg: AggregationFn,
447 menu_open: bool,
448 name: String,
449 label_budget: f32,
450 window: &Window,
451 cx: &App,
452 ) -> gpui::AnyElement {
453 let s = state.read(cx);
454 let theme = s.theme.clone();
455 let state_toggle = state.clone();
456 let state_remove = state.clone();
457
458 let mut rows: Vec<gpui::AnyElement> = Vec::new();
459 let caption = agg.caption(&name);
460 let ghost_label = caption.clone();
461 let ghost_bg = theme.pivot_chip_bg;
462 let ghost_fg = theme.pivot_chip_fg;
463 let ghost_border = theme.grid_line;
464
465 let arrow = if menu_open { "▲" } else { "▼" };
466 let available = label_budget
467 - (CHIP_GAP + measure_text(window, arrow, CHIP_FONT_SIZE))
468 - (CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE));
469 let chopped = measure_text(window, &caption, CHIP_FONT_SIZE) > available + 0.5;
470
471 let mut chip = div()
472 .id("pivot-values-chip")
473 .px(px(8.0))
474 .py(px(3.0))
475 .rounded(px(4.0))
476 .bg(theme.pivot_chip_bg)
477 .border_1()
478 .border_color(theme.grid_line)
479 .text_color(theme.pivot_chip_fg)
480 .text_size(px(CHIP_FONT_SIZE))
481 .flex()
482 .items_center()
483 .gap(px(CHIP_GAP))
484 .cursor_pointer()
485 .on_drag(
486 DraggedField { field },
487 move |_drag, _offset, _window, cx| {
488 cx.new(|_| DragGhost {
489 label: ghost_label.clone(),
490 bg: ghost_bg,
491 fg: ghost_fg,
492 border: ghost_border,
493 })
494 },
495 )
496 .child(
497 div()
498 .flex_1()
499 .min_w(px(0.0))
500 .truncate()
501 .child(caption.clone()),
502 )
503 .child(
504 div()
505 .flex_none()
506 .cursor_pointer()
507 .text_color(theme.sort_indicator)
508 .child(arrow)
509 .on_mouse_down(MouseButton::Left, {
510 let state_toggle = state_toggle.clone();
511 move |_e: &MouseDownEvent, _w, cx| {
512 cx.stop_propagation();
513 state_toggle.update(cx, |s, cx| {
514 s.agg_menu_open = !s.agg_menu_open;
515 cx.notify();
516 });
517 }
518 }),
519 )
520 .child(
521 div()
522 .flex_none()
523 .text_color(theme.muted_text)
524 .cursor_pointer()
525 .child("✕")
526 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
527 cx.stop_propagation();
528 state_remove.update(cx, |s, cx| {
529 s.config.remove_field(field);
530 s.recompute();
531 cx.notify();
532 });
533 }),
534 );
535
536 if chopped {
537 let tip_label: SharedString = caption.into();
538 let tip_bg = theme.menu_bg;
539 let tip_fg = theme.menu_fg;
540 let tip_border = theme.grid_line;
541 chip = chip.tooltip(move |_window, cx| {
542 cx.new(|_| ChipTooltip {
543 label: tip_label.clone(),
544 bg: tip_bg,
545 fg: tip_fg,
546 border: tip_border,
547 })
548 .into()
549 });
550 }
551
552 rows.push(chip.into_any_element());
553
554 if menu_open {
555 for func in AggregationFn::all() {
556 let selected = func == agg;
557 let state_pick = state.clone();
558 rows.push(
559 div()
560 .px(px(8.0))
561 .py(px(2.0))
562 .rounded(px(3.0))
563 .bg(if selected {
564 theme.menu_hover_bg
565 } else {
566 theme.menu_bg
567 })
568 .text_color(theme.menu_fg)
569 .text_size(px(12.0))
570 .cursor_pointer()
571 .child(func.label())
572 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
573 state_pick.update(cx, |s, cx| {
574 s.config.aggregation = func;
575 s.agg_menu_open = false;
576 s.recompute();
577 cx.notify();
578 });
579 })
580 .into_any_element(),
581 );
582 }
583 }
584
585 div()
586 .flex()
587 .flex_col()
588 .gap(px(2.0))
589 .children(rows)
590 .into_any_element()
591 }
592
593 fn option_row(
595 state: &Entity<PivotState>,
596 label: &'static str,
597 checked: bool,
598 apply: impl Fn(&mut PivotState) + 'static,
599 cx: &App,
600 ) -> gpui::AnyElement {
601 let theme = state.read(cx).theme.clone();
602 let state_toggle = state.clone();
603 let boxed = div()
604 .w(px(12.0))
605 .h(px(12.0))
606 .border_1()
607 .border_color(theme.grid_line)
608 .bg(theme.menu_bg)
609 .flex()
610 .items_center()
611 .justify_center()
612 .children(checked.then(|| div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator)));
613 div()
614 .flex()
615 .items_center()
616 .gap(px(6.0))
617 .cursor_pointer()
618 .text_size(px(12.0))
619 .text_color(theme.menu_fg)
620 .child(boxed)
621 .child(label)
622 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
623 state_toggle.update(cx, |s, cx| {
624 apply(s);
625 s.recompute();
626 cx.notify();
627 });
628 })
629 .into_any_element()
630 }
631
632 fn text_button(
634 state: &Entity<PivotState>,
635 label: &'static str,
636 apply: impl Fn(&mut PivotState, &mut App) + 'static,
637 cx: &App,
638 ) -> gpui::AnyElement {
639 let theme = state.read(cx).theme.clone();
640 let state_btn = state.clone();
641 div()
642 .px(px(6.0))
643 .py(px(2.0))
644 .rounded(px(3.0))
645 .border_1()
646 .border_color(theme.grid_line)
647 .bg(theme.pivot_drop_zone_bg)
648 .text_color(theme.menu_fg)
649 .text_size(px(11.0))
650 .cursor_pointer()
651 .child(label)
652 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
653 state_btn.update(cx, |s, cx| {
654 apply(s, cx);
655 cx.notify();
656 });
657 })
658 .into_any_element()
659 }
660
661 fn render_filter_popover(
663 state: &Entity<PivotState>,
664 cx: &App,
665 ) -> Option<impl IntoElement + use<>> {
666 let s = state.read(cx);
667 let popover = s.filter_popover()?.clone();
668 let theme = s.theme.clone();
669 let field_name = s
670 .source_columns()
671 .get(popover.field)
672 .map(|c| c.name.clone())
673 .unwrap_or_default();
674
675 let checkbox = |checked: bool| {
676 let mut b = div()
677 .w(px(12.0))
678 .h(px(12.0))
679 .border_1()
680 .border_color(theme.grid_line)
681 .bg(theme.menu_bg)
682 .flex()
683 .items_center()
684 .justify_center();
685 if checked {
686 b = b.child(div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator));
687 }
688 b
689 };
690
691 let state_all = state.clone();
692 let select_all = div()
693 .h(px(22.0))
694 .flex()
695 .items_center()
696 .gap(px(6.0))
697 .cursor_pointer()
698 .child(checkbox(popover.all_checked()))
699 .child("(Select All)")
700 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
701 state_all.update(cx, |s, cx| {
702 s.toggle_filter_popover_select_all();
703 cx.notify();
704 });
705 });
706
707 let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
708 for (i, row) in popover
709 .rows
710 .iter()
711 .enumerate()
712 .take(FILTER_POPOVER_MAX_ROWS)
713 {
714 let state_val = state.clone();
715 value_rows.push(
716 div()
717 .h(px(20.0))
718 .flex()
719 .items_center()
720 .gap(px(6.0))
721 .cursor_pointer()
722 .child(checkbox(row.checked))
723 .child(div().text_color(theme.menu_fg).child(row.label.clone()))
724 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
725 state_val.update(cx, |s, cx| {
726 s.toggle_filter_popover_value(i);
727 cx.notify();
728 });
729 })
730 .into_any_element(),
731 );
732 }
733 let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
734
735 let state_clear = state.clone();
736 let state_close = state.clone();
737 let clear_field = popover.field;
738 let buttons = div()
739 .flex()
740 .gap(px(6.0))
741 .child(
742 div()
743 .flex_1()
744 .h(px(24.0))
745 .flex()
746 .items_center()
747 .justify_center()
748 .border_1()
749 .border_color(theme.grid_line)
750 .bg(theme.menu_hover_bg)
751 .cursor_pointer()
752 .child("Clear")
753 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
754 state_clear.update(cx, |s, cx| {
755 s.clear_filter(clear_field);
756 cx.notify();
757 });
758 }),
759 )
760 .child(
761 div()
762 .flex_1()
763 .h(px(24.0))
764 .flex()
765 .items_center()
766 .justify_center()
767 .border_1()
768 .border_color(theme.grid_line)
769 .bg(theme.menu_hover_bg)
770 .cursor_pointer()
771 .child("Close")
772 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
773 state_close.update(cx, |s, cx| {
774 s.close_filter_popover();
775 cx.notify();
776 });
777 }),
778 );
779
780 let body = div()
781 .flex()
782 .flex_col()
783 .w(px(240.0))
784 .p(px(8.0))
785 .gap(px(6.0))
786 .bg(theme.menu_bg)
787 .border_1()
788 .border_color(theme.grid_line)
789 .text_color(theme.menu_fg)
790 .text_size(px(12.0))
791 .child(
792 div()
793 .text_color(theme.muted_text)
794 .text_size(px(11.0))
795 .child(format!("Filter: {field_name}")),
796 )
797 .child(select_all)
798 .child(
799 div()
800 .id("pivot-filter-values")
801 .flex()
802 .flex_col()
803 .max_h(px(200.0))
804 .overflow_y_scroll()
805 .children(value_rows)
806 .children(truncated.then(|| {
807 div()
808 .text_color(theme.muted_text)
809 .text_size(px(11.0))
810 .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
811 })),
812 )
813 .child(buttons);
814
815 let state_backdrop = state.clone();
816 let overlay = deferred(
817 anchored()
818 .anchor(Corner::TopLeft)
819 .position(point(popover.anchor.x, popover.anchor.y))
820 .child(div().occlude().child(body).on_mouse_down_out(
821 move |_e: &MouseDownEvent, _window, cx| {
822 state_backdrop.update(cx, |s, cx| {
823 if s.filter_popover().is_some() {
824 s.close_filter_popover();
825 cx.notify();
826 }
827 });
828 },
829 )),
830 )
831 .with_priority(POPOVER_PRIORITY);
832 Some(overlay)
833 }
834}
835
836impl Render for PivotSidebar {
837 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
838 let (theme, columns, config, sidebar_width, save_handler) = {
839 let s = self.state.read(cx);
840 (
841 s.theme.clone(),
842 s.source_columns().to_vec(),
843 s.config.clone(),
844 s.sidebar_width,
845 s.save_config_handler.clone(),
846 )
847 };
848 let content_w = sidebar_width - SIDEBAR_CONTENT_CHROME;
849
850 let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
852 for (i, col) in columns.iter().enumerate() {
853 let badge = match config.zone_of(i) {
854 Some(PivotZone::Rows) => Some("R"),
855 Some(PivotZone::Columns) => Some("C"),
856 Some(PivotZone::Values) => Some("V"),
857 Some(PivotZone::Filters) => Some("F"),
858 None => None,
859 };
860 field_chips.push(Self::chip(
861 &self.state,
862 ("pivot-source-field", i),
863 i,
864 col.name.clone(),
865 None,
866 false,
867 badge,
868 content_w - CHIP_CHROME,
869 window,
870 cx,
871 ));
872 }
873
874 let options = div()
875 .flex()
876 .flex_col()
877 .gap(px(4.0))
878 .child(
879 div()
880 .text_color(theme.muted_text)
881 .text_size(px(11.0))
882 .child("Totals"),
883 )
884 .child(Self::option_row(
885 &self.state,
886 "Row subtotals",
887 config.show_row_subtotals,
888 |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
889 cx,
890 ))
891 .child(Self::option_row(
892 &self.state,
893 "Column subtotal columns",
894 config.show_column_subtotals,
895 |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
896 cx,
897 ))
898 .child(Self::option_row(
899 &self.state,
900 "Grand total row",
901 config.show_row_grand_total,
902 |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
903 cx,
904 ))
905 .child(Self::option_row(
906 &self.state,
907 "Grand total column",
908 config.show_column_grand_total,
909 |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
910 cx,
911 ));
912
913 let tools = div()
914 .flex()
915 .flex_col()
916 .gap(px(4.0))
917 .child(
918 div()
919 .text_color(theme.muted_text)
920 .text_size(px(11.0))
921 .child("Groups"),
922 )
923 .child(
924 div()
925 .flex()
926 .gap(px(4.0))
927 .child(Self::text_button(
928 &self.state,
929 "Expand rows",
930 |s, _| s.expand_all_rows(),
931 cx,
932 ))
933 .child(Self::text_button(
934 &self.state,
935 "Collapse rows",
936 |s, _| s.collapse_all_rows(),
937 cx,
938 )),
939 )
940 .child(
941 div()
942 .flex()
943 .gap(px(4.0))
944 .child(Self::text_button(
945 &self.state,
946 "Expand cols",
947 |s, _| s.expand_all_cols(),
948 cx,
949 ))
950 .child(Self::text_button(
951 &self.state,
952 "Collapse cols",
953 |s, _| s.collapse_all_cols(),
954 cx,
955 )),
956 )
957 .child(
958 div()
959 .text_color(theme.muted_text)
960 .text_size(px(11.0))
961 .child("Export"),
962 )
963 .child(Self::text_button(
964 &self.state,
965 "Copy pivot as CSV",
966 |s, cx| {
967 let csv = s.to_csv();
968 if !csv.is_empty() {
969 cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
970 }
971 },
972 cx,
973 ));
974
975 let fields = div()
976 .flex()
977 .flex_col()
978 .gap(px(3.0))
979 .child(
980 div()
981 .text_color(theme.muted_text)
982 .text_size(px(11.0))
983 .child("Drag a field into a layout zone"),
984 )
985 .child(
986 div()
987 .id("pivot-field-list")
988 .flex()
989 .flex_col()
990 .gap(px(3.0))
991 .max_h(px(220.0))
992 .overflow_y_scroll()
993 .children(field_chips),
994 );
995
996 let save_row = save_handler.map(|handler| {
999 let state_save = self.state.clone();
1000 let icon_color = theme.muted_text;
1001 let hover_bg = theme.menu_hover_bg;
1002 let tip_bg = theme.menu_bg;
1003 let tip_fg = theme.menu_fg;
1004 let tip_border = theme.grid_line;
1005 div().flex().justify_end().child(
1006 div()
1007 .id("pivot-save-config")
1008 .p(px(3.0))
1009 .rounded(px(3.0))
1010 .cursor_pointer()
1011 .hover(move |style| style.bg(hover_bg))
1012 .child(disk_icon(icon_color))
1013 .tooltip(move |_window, cx| {
1014 cx.new(|_| ChipTooltip {
1015 label: "Save configuration".into(),
1016 bg: tip_bg,
1017 fg: tip_fg,
1018 border: tip_border,
1019 })
1020 .into()
1021 })
1022 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1023 cx.stop_propagation();
1024 let config = state_save.read(cx).config.clone();
1025 handler(&config, cx);
1026 }),
1027 )
1028 });
1029
1030 let layout = div()
1031 .flex()
1032 .flex_col()
1033 .gap(px(6.0))
1034 .children(save_row)
1035 .child(Self::zone(
1036 &self.state,
1037 PivotZone::Filters,
1038 "Filters",
1039 content_w,
1040 window,
1041 cx,
1042 ))
1043 .child(Self::zone(
1044 &self.state,
1045 PivotZone::Columns,
1046 "Columns",
1047 content_w,
1048 window,
1049 cx,
1050 ))
1051 .child(Self::zone(
1052 &self.state,
1053 PivotZone::Rows,
1054 "Rows",
1055 content_w,
1056 window,
1057 cx,
1058 ))
1059 .child(Self::zone(
1060 &self.state,
1061 PivotZone::Values,
1062 "Values",
1063 content_w,
1064 window,
1065 cx,
1066 ));
1067
1068 let display = div()
1069 .flex()
1070 .flex_col()
1071 .gap(px(10.0))
1072 .child(options)
1073 .child(tools);
1074
1075 let accordion_theme = AccordionTheme {
1076 header_bg: theme.header_bg.into(),
1077 header_hover_bg: theme.menu_hover_bg.into(),
1078 content_bg: theme.menu_bg.into(),
1079 border: theme.grid_line.into(),
1080 title_color: theme.header_fg.into(),
1081 indicator_color: theme.muted_text.into(),
1082 };
1083 let sidebar = cx.entity().clone();
1084 let accordion = Accordion::new()
1085 .mode(AccordionMode::Multiple)
1086 .expanded(self.expanded_sections.clone())
1087 .theme(accordion_theme)
1088 .item(AccordionItem::new("fields", "Fields").content(fields))
1089 .item(AccordionItem::new("layout", "Layout").content(layout))
1090 .item(AccordionItem::new("display", "Display and export").content(display))
1091 .on_change(move |id, expanded, _window, cx| {
1092 sidebar.update(cx, |this, cx| {
1093 this.set_section_expanded(id, expanded);
1094 cx.notify();
1095 });
1096 });
1097
1098 div()
1099 .id("pivot-sidebar")
1100 .h_full()
1101 .flex()
1102 .flex_col()
1103 .gap(px(8.0))
1104 .p(px(8.0))
1105 .bg(theme.menu_bg)
1106 .text_color(theme.menu_fg)
1107 .text_size(px(12.0))
1108 .overflow_y_scroll()
1109 .child(
1110 div()
1111 .text_color(theme.header_fg)
1112 .text_size(px(13.0))
1113 .child("Pivot Controls"),
1114 )
1115 .child(accordion)
1116 .children(Self::render_filter_popover(&self.state, cx))
1117 }
1118}