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
26struct DraggedField {
28 field: usize,
29}
30
31struct 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
54pub struct PivotSidebar {
57 pub state: Entity<PivotState>,
59 expanded_sections: Vec<SharedString>,
60}
61
62impl PivotSidebar {
63 #[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 #[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 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 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 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 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 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 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 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}