1use crate::grid::theme::GridTheme;
11use crate::pivot::aggregation::AggregationFn;
12use crate::pivot::config::PivotZone;
13use crate::pivot::state::PivotState;
14
15use gpui::{
16 anchored, deferred, div, point, px, App, AppContext as _, Context, Corner, Entity, FontWeight,
17 InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement,
18 Render, SharedString, StatefulInteractiveElement, Styled, Window,
19};
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)]
178 fn section(
179 sidebar: &Entity<PivotSidebar>,
180 theme: &GridTheme,
181 id: &'static str,
182 title: &'static str,
183 header_extra: Option<gpui::AnyElement>,
184 expanded: bool,
185 first: bool,
186 content: gpui::AnyElement,
187 ) -> gpui::AnyElement {
188 let sidebar = sidebar.clone();
189 let section_id: SharedString = id.into();
190 let hover_bg = theme.menu_hover_bg;
191
192 let mut header = div()
193 .id(SharedString::from(format!("pivot-section-{id}")))
194 .flex()
195 .items_center()
196 .justify_between()
197 .px(px(16.0))
198 .py(px(12.0))
199 .bg(theme.header_bg)
200 .cursor_pointer()
201 .hover(move |style| style.bg(hover_bg))
202 .on_mouse_up(MouseButton::Left, move |_e: &MouseUpEvent, _w, cx| {
203 sidebar.update(cx, |this, cx| {
204 this.set_section_expanded(§ion_id, !expanded);
205 cx.notify();
206 });
207 })
208 .child(
209 div()
210 .flex()
211 .items_center()
212 .gap(px(8.0))
213 .child(
214 div()
215 .text_size(px(14.0))
216 .font_weight(FontWeight::MEDIUM)
217 .text_color(theme.header_fg)
218 .child(title),
219 )
220 .children(header_extra),
221 )
222 .child(
223 div()
224 .text_size(px(12.0))
225 .text_color(theme.muted_text)
226 .child(if expanded { "▼" } else { "▶" }),
227 );
228 if !first {
229 header = header.border_t_1().border_color(theme.grid_line);
230 }
231
232 let mut wrapper = div().flex().flex_col().child(header);
233 if expanded {
234 wrapper = wrapper.child(
235 div()
236 .px(px(16.0))
237 .py(px(12.0))
238 .bg(theme.menu_bg)
239 .border_t_1()
240 .border_color(theme.grid_line)
241 .child(content),
242 );
243 }
244 wrapper.into_any_element()
245 }
246
247 #[allow(clippy::too_many_arguments)]
252 fn chip(
253 state: &Entity<PivotState>,
254 id: (&'static str, usize),
255 field: usize,
256 label: String,
257 zone: Option<PivotZone>,
258 removable: bool,
259 marker: Option<&'static str>,
260 label_budget: f32,
261 window: &Window,
262 cx: &App,
263 ) -> gpui::AnyElement {
264 let s = state.read(cx);
265 let theme = s.theme.clone();
266 let ghost_label = label.clone();
267 let ghost_bg = theme.pivot_chip_bg;
268 let ghost_fg = theme.pivot_chip_fg;
269 let ghost_border = theme.grid_line;
270
271 let state_drop = state.clone();
272 let state_remove = state.clone();
273
274 let mut available = label_budget;
275 if let Some(marker) = marker {
276 available -= CHIP_GAP + measure_text(window, marker, MARKER_FONT_SIZE);
277 }
278 if removable {
279 available -= CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE);
280 }
281 let chopped = measure_text(window, &label, CHIP_FONT_SIZE) > available + 0.5;
282
283 let mut chip = div()
284 .id(id)
285 .px(px(8.0))
286 .py(px(3.0))
287 .rounded(px(4.0))
288 .bg(theme.pivot_chip_bg)
289 .border_1()
290 .border_color(theme.grid_line)
291 .text_color(theme.pivot_chip_fg)
292 .text_size(px(CHIP_FONT_SIZE))
293 .flex()
294 .items_center()
295 .gap(px(CHIP_GAP))
296 .cursor_pointer()
297 .on_drag(
298 DraggedField { field },
299 move |_drag, _offset, _window, cx| {
300 cx.new(|_| DragGhost {
301 label: ghost_label.clone(),
302 bg: ghost_bg,
303 fg: ghost_fg,
304 border: ghost_border,
305 })
306 },
307 )
308 .child(
309 div()
310 .flex_1()
311 .min_w(px(0.0))
312 .truncate()
313 .child(label.clone()),
314 );
315
316 if chopped {
317 let tip_label: SharedString = label.into();
318 let tip_bg = theme.menu_bg;
319 let tip_fg = theme.menu_fg;
320 let tip_border = theme.grid_line;
321 chip = chip.tooltip(move |_window, cx| {
322 cx.new(|_| ChipTooltip {
323 label: tip_label.clone(),
324 bg: tip_bg,
325 fg: tip_fg,
326 border: tip_border,
327 })
328 .into()
329 });
330 }
331
332 if let Some(marker) = marker {
333 chip = chip.child(
334 div()
335 .flex_none()
336 .text_color(theme.sort_indicator)
337 .text_size(px(MARKER_FONT_SIZE))
338 .child(marker),
339 );
340 }
341
342 if removable {
343 chip = chip.child(
344 div()
345 .flex_none()
346 .text_color(theme.muted_text)
347 .cursor_pointer()
348 .child("✕")
349 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
350 cx.stop_propagation();
351 state_remove.update(cx, |s, cx| {
352 s.config.remove_field(field);
353 s.recompute();
354 cx.notify();
355 });
356 }),
357 );
358 }
359
360 if let Some(zone) = zone {
363 chip = chip.on_drop::<DraggedField>(move |dragged, _window, cx| {
364 cx.stop_propagation();
365 let dragged_field = dragged.field;
366 state_drop.update(cx, |s, cx| {
367 let index = s.config.fields_in(zone).iter().position(|&f| f == field);
368 s.config.move_field(dragged_field, zone, index);
369 s.recompute();
370 cx.notify();
371 });
372 });
373 }
374
375 chip.into_any_element()
376 }
377
378 fn zone(
381 state: &Entity<PivotState>,
382 zone: PivotZone,
383 title: &'static str,
384 content_w: f32,
385 window: &Window,
386 cx: &App,
387 ) -> gpui::AnyElement {
388 let s = state.read(cx);
389 let theme = s.theme.clone();
390 let fields = s.config.fields_in(zone);
391 let columns = s.source_columns().to_vec();
392 let agg = s.config.aggregation;
393 let agg_menu_open = s.agg_menu_open;
394
395 let state_drop = state.clone();
396 let active_bg = theme.pivot_drop_zone_active_bg;
397 let label_budget = content_w - ZONE_CHROME - CHIP_CHROME;
398
399 let mut chips: Vec<gpui::AnyElement> = Vec::new();
400 for (i, &field) in fields.iter().enumerate() {
401 let name = columns
402 .get(field)
403 .map(|c| c.name.clone())
404 .unwrap_or_else(|| format!("column {field}"));
405 match zone {
406 PivotZone::Values => {
407 chips.push(Self::values_chip(
408 state,
409 field,
410 agg,
411 agg_menu_open,
412 name,
413 label_budget,
414 window,
415 cx,
416 ));
417 }
418 PivotZone::Filters => {
419 let filter_on = s.filter_active(field);
420 let state_open = state.clone();
421 let chip = div()
422 .id(("pivot-filter-chip", i))
423 .on_mouse_down(MouseButton::Left, {
424 let state_open = state_open.clone();
425 move |e: &MouseDownEvent, _w, cx| {
426 state_open.update(cx, |s, cx| {
427 s.open_filter_popover(field, e.position);
428 cx.notify();
429 });
430 }
431 })
432 .child(Self::chip(
433 state,
434 ("pivot-filter-chip-inner", i),
435 field,
436 name,
437 Some(zone),
438 true,
439 filter_on.then_some("●"),
440 label_budget,
441 window,
442 cx,
443 ));
444 chips.push(chip.into_any_element());
445 }
446 PivotZone::Rows | PivotZone::Columns => {
447 let id = match zone {
448 PivotZone::Rows => ("pivot-row-chip", i),
449 _ => ("pivot-col-chip", i),
450 };
451 chips.push(Self::chip(
452 state,
453 id,
454 field,
455 name,
456 Some(zone),
457 true,
458 None,
459 label_budget,
460 window,
461 cx,
462 ));
463 }
464 }
465 }
466
467 let hint = if chips.is_empty() {
468 Some(
469 div()
470 .text_color(theme.muted_text)
471 .text_size(px(11.0))
472 .child("Drag fields here"),
473 )
474 } else {
475 None
476 };
477
478 div()
479 .flex()
480 .flex_col()
481 .gap(px(4.0))
482 .child(
483 div()
484 .text_color(theme.muted_text)
485 .text_size(px(11.0))
486 .child(title),
487 )
488 .child(
489 div()
490 .min_h(px(40.0))
491 .p(px(6.0))
492 .rounded(px(4.0))
493 .bg(theme.pivot_drop_zone_bg)
494 .border_1()
495 .border_color(theme.grid_line)
496 .flex()
497 .flex_col()
498 .gap(px(4.0))
499 .drag_over::<DraggedField>(move |style, _, _, _| style.bg(active_bg))
500 .on_drop::<DraggedField>(move |dragged, _window, cx| {
501 let dragged_field = dragged.field;
502 state_drop.update(cx, |s, cx| {
503 s.config.move_field(dragged_field, zone, None);
504 s.recompute();
505 cx.notify();
506 });
507 })
508 .children(chips)
509 .children(hint),
510 )
511 .into_any_element()
512 }
513
514 #[allow(clippy::too_many_arguments)]
518 fn values_chip(
519 state: &Entity<PivotState>,
520 field: usize,
521 agg: AggregationFn,
522 menu_open: bool,
523 name: String,
524 label_budget: f32,
525 window: &Window,
526 cx: &App,
527 ) -> gpui::AnyElement {
528 let s = state.read(cx);
529 let theme = s.theme.clone();
530 let state_toggle = state.clone();
531 let state_remove = state.clone();
532
533 let mut rows: Vec<gpui::AnyElement> = Vec::new();
534 let caption = agg.caption(&name);
535 let ghost_label = caption.clone();
536 let ghost_bg = theme.pivot_chip_bg;
537 let ghost_fg = theme.pivot_chip_fg;
538 let ghost_border = theme.grid_line;
539
540 let arrow = if menu_open { "▲" } else { "▼" };
541 let available = label_budget
542 - (CHIP_GAP + measure_text(window, arrow, CHIP_FONT_SIZE))
543 - (CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE));
544 let chopped = measure_text(window, &caption, CHIP_FONT_SIZE) > available + 0.5;
545
546 let mut chip = div()
547 .id("pivot-values-chip")
548 .px(px(8.0))
549 .py(px(3.0))
550 .rounded(px(4.0))
551 .bg(theme.pivot_chip_bg)
552 .border_1()
553 .border_color(theme.grid_line)
554 .text_color(theme.pivot_chip_fg)
555 .text_size(px(CHIP_FONT_SIZE))
556 .flex()
557 .items_center()
558 .gap(px(CHIP_GAP))
559 .cursor_pointer()
560 .on_drag(
561 DraggedField { field },
562 move |_drag, _offset, _window, cx| {
563 cx.new(|_| DragGhost {
564 label: ghost_label.clone(),
565 bg: ghost_bg,
566 fg: ghost_fg,
567 border: ghost_border,
568 })
569 },
570 )
571 .child(
572 div()
573 .flex_1()
574 .min_w(px(0.0))
575 .truncate()
576 .child(caption.clone()),
577 )
578 .child(
579 div()
580 .flex_none()
581 .cursor_pointer()
582 .text_color(theme.sort_indicator)
583 .child(arrow)
584 .on_mouse_down(MouseButton::Left, {
585 let state_toggle = state_toggle.clone();
586 move |_e: &MouseDownEvent, _w, cx| {
587 cx.stop_propagation();
588 state_toggle.update(cx, |s, cx| {
589 s.agg_menu_open = !s.agg_menu_open;
590 cx.notify();
591 });
592 }
593 }),
594 )
595 .child(
596 div()
597 .flex_none()
598 .text_color(theme.muted_text)
599 .cursor_pointer()
600 .child("✕")
601 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
602 cx.stop_propagation();
603 state_remove.update(cx, |s, cx| {
604 s.config.remove_field(field);
605 s.recompute();
606 cx.notify();
607 });
608 }),
609 );
610
611 if chopped {
612 let tip_label: SharedString = caption.into();
613 let tip_bg = theme.menu_bg;
614 let tip_fg = theme.menu_fg;
615 let tip_border = theme.grid_line;
616 chip = chip.tooltip(move |_window, cx| {
617 cx.new(|_| ChipTooltip {
618 label: tip_label.clone(),
619 bg: tip_bg,
620 fg: tip_fg,
621 border: tip_border,
622 })
623 .into()
624 });
625 }
626
627 rows.push(chip.into_any_element());
628
629 if menu_open {
630 for func in AggregationFn::all() {
631 let selected = func == agg;
632 let state_pick = state.clone();
633 rows.push(
634 div()
635 .px(px(8.0))
636 .py(px(2.0))
637 .rounded(px(3.0))
638 .bg(if selected {
639 theme.menu_hover_bg
640 } else {
641 theme.menu_bg
642 })
643 .text_color(theme.menu_fg)
644 .text_size(px(12.0))
645 .cursor_pointer()
646 .child(func.label())
647 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
648 state_pick.update(cx, |s, cx| {
649 s.config.aggregation = func;
650 s.agg_menu_open = false;
651 s.recompute();
652 cx.notify();
653 });
654 })
655 .into_any_element(),
656 );
657 }
658 }
659
660 div()
661 .flex()
662 .flex_col()
663 .gap(px(2.0))
664 .children(rows)
665 .into_any_element()
666 }
667
668 fn option_row(
670 state: &Entity<PivotState>,
671 label: &'static str,
672 checked: bool,
673 apply: impl Fn(&mut PivotState) + 'static,
674 cx: &App,
675 ) -> gpui::AnyElement {
676 let theme = state.read(cx).theme.clone();
677 let state_toggle = state.clone();
678 let boxed = div()
679 .w(px(12.0))
680 .h(px(12.0))
681 .border_1()
682 .border_color(theme.grid_line)
683 .bg(theme.menu_bg)
684 .flex()
685 .items_center()
686 .justify_center()
687 .children(checked.then(|| div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator)));
688 div()
689 .flex()
690 .items_center()
691 .gap(px(6.0))
692 .cursor_pointer()
693 .text_size(px(12.0))
694 .text_color(theme.menu_fg)
695 .child(boxed)
696 .child(label)
697 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
698 state_toggle.update(cx, |s, cx| {
699 apply(s);
700 s.recompute();
701 cx.notify();
702 });
703 })
704 .into_any_element()
705 }
706
707 fn text_button(
709 state: &Entity<PivotState>,
710 label: &'static str,
711 apply: impl Fn(&mut PivotState, &mut App) + 'static,
712 cx: &App,
713 ) -> gpui::AnyElement {
714 let theme = state.read(cx).theme.clone();
715 let state_btn = state.clone();
716 div()
717 .px(px(6.0))
718 .py(px(2.0))
719 .rounded(px(3.0))
720 .border_1()
721 .border_color(theme.grid_line)
722 .bg(theme.pivot_drop_zone_bg)
723 .text_color(theme.menu_fg)
724 .text_size(px(11.0))
725 .cursor_pointer()
726 .child(label)
727 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
728 state_btn.update(cx, |s, cx| {
729 apply(s, cx);
730 cx.notify();
731 });
732 })
733 .into_any_element()
734 }
735
736 fn render_filter_popover(
738 state: &Entity<PivotState>,
739 cx: &App,
740 ) -> Option<impl IntoElement + use<>> {
741 let s = state.read(cx);
742 let popover = s.filter_popover()?.clone();
743 let theme = s.theme.clone();
744 let field_name = s
745 .source_columns()
746 .get(popover.field)
747 .map(|c| c.name.clone())
748 .unwrap_or_default();
749
750 let checkbox = |checked: bool| {
751 let mut b = div()
752 .w(px(12.0))
753 .h(px(12.0))
754 .border_1()
755 .border_color(theme.grid_line)
756 .bg(theme.menu_bg)
757 .flex()
758 .items_center()
759 .justify_center();
760 if checked {
761 b = b.child(div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator));
762 }
763 b
764 };
765
766 let state_all = state.clone();
767 let select_all = div()
768 .h(px(22.0))
769 .flex()
770 .items_center()
771 .gap(px(6.0))
772 .cursor_pointer()
773 .child(checkbox(popover.all_checked()))
774 .child("(Select All)")
775 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
776 state_all.update(cx, |s, cx| {
777 s.toggle_filter_popover_select_all();
778 cx.notify();
779 });
780 });
781
782 let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
783 for (i, row) in popover
784 .rows
785 .iter()
786 .enumerate()
787 .take(FILTER_POPOVER_MAX_ROWS)
788 {
789 let state_val = state.clone();
790 value_rows.push(
791 div()
792 .h(px(20.0))
793 .flex()
794 .items_center()
795 .gap(px(6.0))
796 .cursor_pointer()
797 .child(checkbox(row.checked))
798 .child(div().text_color(theme.menu_fg).child(row.label.clone()))
799 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
800 state_val.update(cx, |s, cx| {
801 s.toggle_filter_popover_value(i);
802 cx.notify();
803 });
804 })
805 .into_any_element(),
806 );
807 }
808 let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
809
810 let state_clear = state.clone();
811 let state_close = state.clone();
812 let clear_field = popover.field;
813 let buttons = div()
814 .flex()
815 .gap(px(6.0))
816 .child(
817 div()
818 .flex_1()
819 .h(px(24.0))
820 .flex()
821 .items_center()
822 .justify_center()
823 .border_1()
824 .border_color(theme.grid_line)
825 .bg(theme.menu_hover_bg)
826 .cursor_pointer()
827 .child("Clear")
828 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
829 state_clear.update(cx, |s, cx| {
830 s.clear_filter(clear_field);
831 cx.notify();
832 });
833 }),
834 )
835 .child(
836 div()
837 .flex_1()
838 .h(px(24.0))
839 .flex()
840 .items_center()
841 .justify_center()
842 .border_1()
843 .border_color(theme.grid_line)
844 .bg(theme.menu_hover_bg)
845 .cursor_pointer()
846 .child("Close")
847 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
848 state_close.update(cx, |s, cx| {
849 s.close_filter_popover();
850 cx.notify();
851 });
852 }),
853 );
854
855 let body = div()
856 .flex()
857 .flex_col()
858 .w(px(240.0))
859 .p(px(8.0))
860 .gap(px(6.0))
861 .bg(theme.menu_bg)
862 .border_1()
863 .border_color(theme.grid_line)
864 .text_color(theme.menu_fg)
865 .text_size(px(12.0))
866 .child(
867 div()
868 .text_color(theme.muted_text)
869 .text_size(px(11.0))
870 .child(format!("Filter: {field_name}")),
871 )
872 .child(select_all)
873 .child(
874 div()
875 .id("pivot-filter-values")
876 .flex()
877 .flex_col()
878 .max_h(px(200.0))
879 .overflow_y_scroll()
880 .children(value_rows)
881 .children(truncated.then(|| {
882 div()
883 .text_color(theme.muted_text)
884 .text_size(px(11.0))
885 .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
886 })),
887 )
888 .child(buttons);
889
890 let state_backdrop = state.clone();
891 let overlay = deferred(
892 anchored()
893 .anchor(Corner::TopLeft)
894 .position(point(popover.anchor.x, popover.anchor.y))
895 .child(div().occlude().child(body).on_mouse_down_out(
896 move |_e: &MouseDownEvent, _window, cx| {
897 state_backdrop.update(cx, |s, cx| {
898 if s.filter_popover().is_some() {
899 s.close_filter_popover();
900 cx.notify();
901 }
902 });
903 },
904 )),
905 )
906 .with_priority(POPOVER_PRIORITY);
907 Some(overlay)
908 }
909}
910
911impl Render for PivotSidebar {
912 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
913 let (theme, columns, config, sidebar_width, save_handler) = {
914 let s = self.state.read(cx);
915 (
916 s.theme.clone(),
917 s.source_columns().to_vec(),
918 s.config.clone(),
919 s.sidebar_width,
920 s.save_config_handler.clone(),
921 )
922 };
923 let content_w = sidebar_width - SIDEBAR_CONTENT_CHROME;
924
925 let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
927 for (i, col) in columns.iter().enumerate() {
928 let badge = match config.zone_of(i) {
929 Some(PivotZone::Rows) => Some("R"),
930 Some(PivotZone::Columns) => Some("C"),
931 Some(PivotZone::Values) => Some("V"),
932 Some(PivotZone::Filters) => Some("F"),
933 None => None,
934 };
935 field_chips.push(Self::chip(
936 &self.state,
937 ("pivot-source-field", i),
938 i,
939 col.name.clone(),
940 None,
941 false,
942 badge,
943 content_w - CHIP_CHROME,
944 window,
945 cx,
946 ));
947 }
948
949 let options = div()
950 .flex()
951 .flex_col()
952 .gap(px(4.0))
953 .child(
954 div()
955 .text_color(theme.muted_text)
956 .text_size(px(11.0))
957 .child("Totals"),
958 )
959 .child(Self::option_row(
960 &self.state,
961 "Row subtotals",
962 config.show_row_subtotals,
963 |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
964 cx,
965 ))
966 .child(Self::option_row(
967 &self.state,
968 "Column subtotal columns",
969 config.show_column_subtotals,
970 |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
971 cx,
972 ))
973 .child(Self::option_row(
974 &self.state,
975 "Grand total row",
976 config.show_row_grand_total,
977 |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
978 cx,
979 ))
980 .child(Self::option_row(
981 &self.state,
982 "Grand total column",
983 config.show_column_grand_total,
984 |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
985 cx,
986 ));
987
988 let tools = div()
989 .flex()
990 .flex_col()
991 .gap(px(4.0))
992 .child(
993 div()
994 .text_color(theme.muted_text)
995 .text_size(px(11.0))
996 .child("Groups"),
997 )
998 .child(
999 div()
1000 .flex()
1001 .gap(px(4.0))
1002 .child(Self::text_button(
1003 &self.state,
1004 "Expand rows",
1005 |s, _| s.expand_all_rows(),
1006 cx,
1007 ))
1008 .child(Self::text_button(
1009 &self.state,
1010 "Collapse rows",
1011 |s, _| s.collapse_all_rows(),
1012 cx,
1013 )),
1014 )
1015 .child(
1016 div()
1017 .flex()
1018 .gap(px(4.0))
1019 .child(Self::text_button(
1020 &self.state,
1021 "Expand cols",
1022 |s, _| s.expand_all_cols(),
1023 cx,
1024 ))
1025 .child(Self::text_button(
1026 &self.state,
1027 "Collapse cols",
1028 |s, _| s.collapse_all_cols(),
1029 cx,
1030 )),
1031 )
1032 .child(
1033 div()
1034 .text_color(theme.muted_text)
1035 .text_size(px(11.0))
1036 .child("Export"),
1037 )
1038 .child(Self::text_button(
1039 &self.state,
1040 "Copy pivot as CSV",
1041 |s, cx| {
1042 let csv = s.to_csv();
1043 if !csv.is_empty() {
1044 cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
1045 }
1046 },
1047 cx,
1048 ));
1049
1050 let fields = div()
1051 .flex()
1052 .flex_col()
1053 .gap(px(3.0))
1054 .child(
1055 div()
1056 .text_color(theme.muted_text)
1057 .text_size(px(11.0))
1058 .child("Drag a field into a layout zone"),
1059 )
1060 .child(
1061 div()
1062 .id("pivot-field-list")
1063 .flex()
1064 .flex_col()
1065 .gap(px(3.0))
1066 .max_h(px(220.0))
1067 .overflow_y_scroll()
1068 .children(field_chips),
1069 );
1070
1071 let save_button = save_handler.map(|handler| {
1075 let state_save = self.state.clone();
1076 let icon_color = theme.muted_text;
1077 let hover_bg = theme.pivot_drop_zone_active_bg;
1078 let tip_bg = theme.menu_bg;
1079 let tip_fg = theme.menu_fg;
1080 let tip_border = theme.grid_line;
1081 div()
1082 .id("pivot-save-config")
1083 .p(px(2.0))
1084 .rounded(px(3.0))
1085 .cursor_pointer()
1086 .hover(move |style| style.bg(hover_bg))
1087 .child(disk_icon(icon_color))
1088 .tooltip(move |_window, cx| {
1089 cx.new(|_| ChipTooltip {
1090 label: "Save configuration".into(),
1091 bg: tip_bg,
1092 fg: tip_fg,
1093 border: tip_border,
1094 })
1095 .into()
1096 })
1097 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1098 cx.stop_propagation();
1099 let config = state_save.read(cx).config.clone();
1100 handler(&config, cx);
1101 })
1102 .on_mouse_up(MouseButton::Left, |_e: &MouseUpEvent, _w, cx| {
1105 cx.stop_propagation();
1106 })
1107 .into_any_element()
1108 });
1109
1110 let layout = div()
1111 .flex()
1112 .flex_col()
1113 .gap(px(6.0))
1114 .child(Self::zone(
1115 &self.state,
1116 PivotZone::Filters,
1117 "Filters",
1118 content_w,
1119 window,
1120 cx,
1121 ))
1122 .child(Self::zone(
1123 &self.state,
1124 PivotZone::Columns,
1125 "Columns",
1126 content_w,
1127 window,
1128 cx,
1129 ))
1130 .child(Self::zone(
1131 &self.state,
1132 PivotZone::Rows,
1133 "Rows",
1134 content_w,
1135 window,
1136 cx,
1137 ))
1138 .child(Self::zone(
1139 &self.state,
1140 PivotZone::Values,
1141 "Values",
1142 content_w,
1143 window,
1144 cx,
1145 ));
1146
1147 let display = div()
1148 .flex()
1149 .flex_col()
1150 .gap(px(10.0))
1151 .child(options)
1152 .child(tools);
1153
1154 let sidebar = cx.entity().clone();
1155 let is_expanded = |id: &str| self.expanded_sections.iter().any(|section| section == id);
1156 let sections = div()
1157 .flex()
1158 .flex_col()
1159 .border_1()
1160 .border_color(theme.grid_line)
1161 .rounded_lg()
1162 .child(Self::section(
1163 &sidebar,
1164 &theme,
1165 "fields",
1166 "Fields",
1167 None,
1168 is_expanded("fields"),
1169 true,
1170 fields.into_any_element(),
1171 ))
1172 .child(Self::section(
1173 &sidebar,
1174 &theme,
1175 "layout",
1176 "Layout",
1177 save_button,
1178 is_expanded("layout"),
1179 false,
1180 layout.into_any_element(),
1181 ))
1182 .child(Self::section(
1183 &sidebar,
1184 &theme,
1185 "display",
1186 "Display and export",
1187 None,
1188 is_expanded("display"),
1189 false,
1190 display.into_any_element(),
1191 ));
1192
1193 div()
1194 .id("pivot-sidebar")
1195 .h_full()
1196 .flex()
1197 .flex_col()
1198 .gap(px(8.0))
1199 .p(px(8.0))
1200 .bg(theme.menu_bg)
1201 .text_color(theme.menu_fg)
1202 .text_size(px(12.0))
1203 .overflow_y_scroll()
1204 .child(
1205 div()
1206 .text_color(theme.header_fg)
1207 .text_size(px(13.0))
1208 .child("Pivot Controls"),
1209 )
1210 .child(sections)
1211 .children(Self::render_filter_popover(&self.state, cx))
1212 }
1213}