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