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};
20use gpui_component::{Icon, IconName};
21
22const POPOVER_PRIORITY: usize = 1_000_000;
24const FILTER_POPOVER_MAX_ROWS: usize = 200;
26
27const SIDEBAR_CONTENT_CHROME: f32 = 42.0;
30const ZONE_CHROME: f32 = 14.0;
32const CHIP_CHROME: f32 = 18.0;
34const CHIP_GAP: f32 = 6.0;
37
38const CHIP_HEIGHT: f32 = 24.0;
43const CHIP_FONT_SIZE: f32 = 12.0;
45const MARKER_FONT_SIZE: f32 = 11.0;
47
48fn measure_text(window: &Window, text: &str, size: f32) -> f32 {
50 if text.is_empty() {
51 return 0.0;
52 }
53 let run = gpui::TextRun {
54 len: text.len(),
55 color: gpui::Hsla::default(),
56 font: window.text_style().font(),
57 background_color: None,
58 underline: None,
59 strikethrough: None,
60 };
61 let line = window.text_system().shape_line(
62 SharedString::from(text.to_owned()),
63 px(size),
64 &[run],
65 None,
66 );
67 f32::from(line.width)
68}
69
70struct ChipTooltip {
72 label: SharedString,
73 bg: gpui::Hsla,
74 fg: gpui::Hsla,
75 border: gpui::Hsla,
76}
77
78impl Render for ChipTooltip {
79 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
80 div()
81 .px(px(8.0))
82 .py(px(4.0))
83 .rounded(px(4.0))
84 .bg(self.bg)
85 .border_1()
86 .border_color(self.border)
87 .text_color(self.fg)
88 .text_size(px(CHIP_FONT_SIZE))
89 .child(self.label.clone())
90 }
91}
92
93fn disk_icon(color: gpui::Hsla) -> gpui::Div {
95 div()
96 .w(px(13.0))
97 .h(px(13.0))
98 .rounded(px(2.0))
99 .border_1()
100 .border_color(color)
101 .relative()
102 .child(
103 div()
104 .absolute()
105 .top(px(0.0))
106 .left(px(3.0))
107 .w(px(5.0))
108 .h(px(4.0))
109 .border_1()
110 .border_color(color),
111 )
112 .child(
113 div()
114 .absolute()
115 .bottom(px(1.0))
116 .left(px(2.0))
117 .w(px(7.0))
118 .h(px(4.0))
119 .bg(color),
120 )
121}
122
123struct DraggedField {
125 field: usize,
126}
127
128struct DragGhost {
130 label: String,
131 bg: gpui::Hsla,
132 fg: gpui::Hsla,
133 border: gpui::Hsla,
134 animations: bool,
135}
136
137impl Render for DragGhost {
138 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
139 let chip = div()
140 .px(px(8.0))
141 .h(px(CHIP_HEIGHT))
142 .flex()
143 .items_center()
144 .rounded(px(4.0))
145 .bg(self.bg)
146 .border_1()
147 .border_color(self.border)
148 .text_color(self.fg)
149 .text_size(px(12.0))
150 .child(self.label.clone());
151 crate::grid::motion::fade_in(
154 chip,
155 "pivot-drag-ghost",
156 crate::grid::motion::GHOST_ENTER_MS,
157 self.animations,
158 )
159 }
160}
161
162pub struct PivotSidebar {
165 pub state: Entity<PivotState>,
167 expanded_sections: Vec<SharedString>,
168}
169
170impl PivotSidebar {
171 #[must_use]
173 pub fn new(state: Entity<PivotState>) -> Self {
174 Self {
175 state,
176 expanded_sections: vec!["fields".into(), "layout".into(), "display".into()],
177 }
178 }
179
180 fn set_section_expanded(&mut self, id: &SharedString, expanded: bool) {
181 if expanded {
182 if !self.expanded_sections.contains(id) {
183 self.expanded_sections.push(id.clone());
184 }
185 } else {
186 self.expanded_sections.retain(|section| section != id);
187 }
188 }
189
190 #[allow(clippy::too_many_arguments)]
196 fn section(
197 sidebar: &Entity<PivotSidebar>,
198 theme: &GridTheme,
199 id: &'static str,
200 title: &'static str,
201 header_extra: Option<gpui::AnyElement>,
202 expanded: bool,
203 first: bool,
204 animations: bool,
205 content: gpui::AnyElement,
206 ) -> gpui::AnyElement {
207 let sidebar = sidebar.clone();
208 let section_id: SharedString = id.into();
209 let hover_bg = theme.menu_hover_bg;
210
211 let mut header = div()
212 .id(SharedString::from(format!("pivot-section-{id}")))
213 .flex()
214 .items_center()
215 .justify_between()
216 .px(px(12.0))
217 .py(px(12.0))
218 .bg(theme.header_bg)
219 .cursor_pointer()
220 .hover(move |style| style.bg(hover_bg))
221 .on_mouse_up(MouseButton::Left, move |_e: &MouseUpEvent, _w, cx| {
222 sidebar.update(cx, |this, cx| {
223 this.set_section_expanded(§ion_id, !expanded);
224 cx.notify();
225 });
226 })
227 .child(
228 div()
229 .flex()
230 .items_center()
231 .gap(px(8.0))
232 .child(
233 div()
234 .text_size(px(14.0))
235 .font_weight(FontWeight::MEDIUM)
236 .text_color(theme.header_fg)
237 .child(title),
238 )
239 .children(header_extra),
240 )
241 .child(
242 div()
243 .text_size(px(12.0))
244 .text_color(theme.muted_text)
245 .child(Icon::new(if expanded {
246 IconName::ChevronDown
247 } else {
248 IconName::ChevronRight
249 })),
250 );
251 if !first {
252 header = header.border_t_1().border_color(theme.grid_line);
253 }
254
255 let mut wrapper = div().flex().flex_col().child(header);
256 if expanded {
257 let body = div()
258 .px(px(12.0))
259 .py(px(12.0))
260 .bg(theme.menu_bg)
261 .border_t_1()
262 .border_color(theme.grid_line)
263 .child(content);
264 wrapper = wrapper.child(crate::grid::motion::fade_in(
268 body,
269 SharedString::from(format!("pivot-section-body-{id}")),
270 crate::grid::motion::SECTION_ENTER_MS,
271 animations,
272 ));
273 }
274 wrapper.into_any_element()
275 }
276
277 #[allow(clippy::too_many_arguments)]
282 fn chip(
283 state: &Entity<PivotState>,
284 id: (&'static str, usize),
285 field: usize,
286 label: String,
287 zone: Option<PivotZone>,
288 removable: bool,
289 marker: Option<&'static str>,
290 label_budget: f32,
291 window: &Window,
292 cx: &App,
293 ) -> gpui::AnyElement {
294 let s = state.read(cx);
295 let theme = s.theme.clone();
296 let animations = s.animations;
297 let ghost_label = label.clone();
298 let ghost_bg = theme.pivot_chip_bg;
299 let ghost_fg = theme.pivot_chip_fg;
300 let ghost_border = theme.grid_line;
301
302 let (chip_bg, chip_fg) = if zone.is_some() {
308 (theme.pivot_chip_bg, theme.pivot_chip_fg)
309 } else {
310 (theme.header_bg, theme.menu_fg)
311 };
312
313 let state_drop = state.clone();
314 let state_remove = state.clone();
315
316 let mut available = label_budget;
317 if let Some(marker) = marker {
318 available -= CHIP_GAP + measure_text(window, marker, MARKER_FONT_SIZE);
319 }
320 if removable {
321 available -= CHIP_GAP + CHIP_FONT_SIZE;
324 }
325 let chopped = measure_text(window, &label, CHIP_FONT_SIZE) > available + 0.5;
326
327 let mut chip = div()
328 .id(id)
329 .px(px(8.0))
330 .h(px(CHIP_HEIGHT))
331 .flex_none()
332 .rounded(px(4.0))
333 .bg(chip_bg)
334 .border_1()
335 .border_color(theme.grid_line)
336 .text_color(chip_fg)
337 .text_size(px(CHIP_FONT_SIZE))
338 .flex()
339 .items_center()
340 .gap(px(CHIP_GAP))
341 .cursor_pointer()
342 .on_drag(
343 DraggedField { field },
344 move |_drag, _offset, _window, cx| {
345 cx.new(|_| DragGhost {
346 label: ghost_label.clone(),
347 bg: ghost_bg,
348 fg: ghost_fg,
349 border: ghost_border,
350 animations,
351 })
352 },
353 )
354 .child(
355 div()
356 .flex_1()
357 .min_w(px(0.0))
358 .truncate()
359 .child(label.clone()),
360 );
361
362 if chopped {
363 let tip_label: SharedString = label.into();
364 let tip_bg = theme.menu_bg;
365 let tip_fg = theme.menu_fg;
366 let tip_border = theme.grid_line;
367 chip = chip.tooltip(move |_window, cx| {
368 cx.new(|_| ChipTooltip {
369 label: tip_label.clone(),
370 bg: tip_bg,
371 fg: tip_fg,
372 border: tip_border,
373 })
374 .into()
375 });
376 }
377
378 if let Some(marker) = marker {
379 chip = chip.child(
380 div()
381 .flex_none()
382 .text_color(theme.sort_indicator)
383 .text_size(px(MARKER_FONT_SIZE))
384 .child(marker),
385 );
386 }
387
388 if removable {
389 chip = chip.child(
390 div()
391 .flex_none()
392 .text_color(theme.muted_text)
393 .cursor_pointer()
394 .child(Icon::new(IconName::Close))
395 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
396 cx.stop_propagation();
397 state_remove.update(cx, |s, cx| {
398 s.config.remove_field(field);
399 s.recompute();
400 cx.notify();
401 });
402 }),
403 );
404 }
405
406 if let Some(zone) = zone {
410 let state_dialog = state.clone();
411 chip = chip
412 .on_drop::<DraggedField>(move |dragged, _window, cx| {
413 cx.stop_propagation();
414 let dragged_field = dragged.field;
415 state_drop.update(cx, |s, cx| {
416 let index = s.config.fields_in(zone).iter().position(|&f| f == field);
417 s.config.move_field(dragged_field, zone, index);
418 s.recompute();
419 cx.notify();
420 });
421 })
422 .on_mouse_down(MouseButton::Left, move |e: &MouseDownEvent, _w, cx| {
423 if e.click_count == 2 {
424 cx.stop_propagation();
425 state_dialog.update(cx, |s, cx| {
426 s.close_filter_popover();
427 s.open_format_dialog(field, zone, e.position);
428 cx.notify();
429 });
430 }
431 });
432 }
433
434 chip.into_any_element()
435 }
436
437 fn zone(
440 state: &Entity<PivotState>,
441 zone: PivotZone,
442 title: &'static str,
443 content_w: f32,
444 window: &Window,
445 cx: &App,
446 ) -> gpui::AnyElement {
447 let s = state.read(cx);
448 let theme = s.theme.clone();
449 let fields = s.config.fields_in(zone);
450 let columns = s.source_columns().to_vec();
451 let agg = s.config.aggregation;
452 let agg_menu_open = s.agg_menu_open;
453
454 let state_drop = state.clone();
455 let active_bg = theme.pivot_drop_zone_active_bg;
456 let label_budget = content_w - ZONE_CHROME - CHIP_CHROME;
457
458 let mut chips: Vec<gpui::AnyElement> = Vec::new();
459 for (i, &field) in fields.iter().enumerate() {
460 let name = columns
461 .get(field)
462 .map(|c| c.name.clone())
463 .unwrap_or_else(|| format!("column {field}"));
464 match zone {
465 PivotZone::Values => {
466 chips.push(Self::values_chip(
467 state,
468 field,
469 agg,
470 agg_menu_open,
471 name,
472 label_budget,
473 window,
474 cx,
475 ));
476 }
477 PivotZone::Filters => {
478 let filter_on = s.filter_active(field);
479 let state_open = state.clone();
480 let chip = div()
481 .id(("pivot-filter-chip", i))
482 .on_mouse_down(MouseButton::Left, {
483 let state_open = state_open.clone();
484 move |e: &MouseDownEvent, _w, cx| {
485 if e.click_count != 1 {
492 return;
493 }
494 let anchor = point(e.position.x, e.position.y + px(20.0));
495 state_open.update(cx, |s, cx| {
496 s.open_filter_popover(field, anchor);
497 cx.notify();
498 });
499 }
500 })
501 .child(Self::chip(
502 state,
503 ("pivot-filter-chip-inner", i),
504 field,
505 name,
506 Some(zone),
507 true,
508 filter_on.then_some("🔽"),
509 label_budget,
510 window,
511 cx,
512 ));
513 chips.push(chip.into_any_element());
514 }
515 PivotZone::Rows | PivotZone::Columns => {
516 let id = match zone {
517 PivotZone::Rows => ("pivot-row-chip", i),
518 _ => ("pivot-col-chip", i),
519 };
520 chips.push(Self::chip(
521 state,
522 id,
523 field,
524 name,
525 Some(zone),
526 true,
527 None,
528 label_budget,
529 window,
530 cx,
531 ));
532 }
533 }
534 }
535
536 let hint = if chips.is_empty() {
537 Some(
538 div()
539 .text_color(theme.muted_text)
540 .text_size(px(11.0))
541 .child("Drag fields here"),
542 )
543 } else {
544 None
545 };
546
547 div()
548 .flex()
549 .flex_col()
550 .gap(px(4.0))
551 .child(
552 div()
553 .text_color(theme.muted_text)
554 .text_size(px(11.0))
555 .child(title),
556 )
557 .child(
558 div()
559 .min_h(px(40.0))
560 .p(px(6.0))
561 .rounded(px(4.0))
562 .bg(theme.pivot_drop_zone_bg)
563 .border_1()
564 .border_color(theme.grid_line)
565 .flex()
566 .flex_col()
567 .gap(px(4.0))
568 .drag_over::<DraggedField>(move |style, _, _, _| style.bg(active_bg))
569 .on_drop::<DraggedField>(move |dragged, _window, cx| {
570 let dragged_field = dragged.field;
571 state_drop.update(cx, |s, cx| {
572 s.config.move_field(dragged_field, zone, None);
573 s.recompute();
574 cx.notify();
575 });
576 })
577 .children(chips)
578 .children(hint),
579 )
580 .into_any_element()
581 }
582
583 #[allow(clippy::too_many_arguments)]
587 fn values_chip(
588 state: &Entity<PivotState>,
589 field: usize,
590 agg: AggregationFn,
591 menu_open: bool,
592 name: String,
593 label_budget: f32,
594 window: &Window,
595 cx: &App,
596 ) -> gpui::AnyElement {
597 let s = state.read(cx);
598 let theme = s.theme.clone();
599 let animations = s.animations;
600 let state_toggle = state.clone();
601 let state_remove = state.clone();
602
603 let mut rows: Vec<gpui::AnyElement> = Vec::new();
604 let caption = agg.caption(&name);
605 let ghost_label = caption.clone();
606 let ghost_bg = theme.pivot_chip_bg;
607 let ghost_fg = theme.pivot_chip_fg;
608 let ghost_border = theme.grid_line;
609
610 let arrow = Icon::new(if menu_open {
613 IconName::ChevronUp
614 } else {
615 IconName::ChevronDown
616 });
617 let available = label_budget - (CHIP_GAP + CHIP_FONT_SIZE) - (CHIP_GAP + CHIP_FONT_SIZE);
618 let chopped = measure_text(window, &caption, CHIP_FONT_SIZE) > available + 0.5;
619
620 let mut chip = div()
621 .id("pivot-values-chip")
622 .px(px(8.0))
623 .h(px(CHIP_HEIGHT))
624 .flex_none()
625 .rounded(px(4.0))
626 .bg(theme.pivot_chip_bg)
627 .border_1()
628 .border_color(theme.grid_line)
629 .text_color(theme.pivot_chip_fg)
630 .text_size(px(CHIP_FONT_SIZE))
631 .flex()
632 .items_center()
633 .gap(px(CHIP_GAP))
634 .cursor_pointer()
635 .on_drag(
636 DraggedField { field },
637 move |_drag, _offset, _window, cx| {
638 cx.new(|_| DragGhost {
639 label: ghost_label.clone(),
640 bg: ghost_bg,
641 fg: ghost_fg,
642 border: ghost_border,
643 animations,
644 })
645 },
646 )
647 .child(
648 div()
649 .flex_1()
650 .min_w(px(0.0))
651 .truncate()
652 .child(caption.clone()),
653 )
654 .child(
655 div()
656 .flex_none()
657 .cursor_pointer()
658 .text_color(theme.sort_indicator)
659 .child(arrow)
660 .on_mouse_down(MouseButton::Left, {
661 let state_toggle = state_toggle.clone();
662 move |_e: &MouseDownEvent, _w, cx| {
663 cx.stop_propagation();
664 state_toggle.update(cx, |s, cx| {
665 s.agg_menu_open = !s.agg_menu_open;
666 cx.notify();
667 });
668 }
669 }),
670 )
671 .child(
672 div()
673 .flex_none()
674 .text_color(theme.muted_text)
675 .cursor_pointer()
676 .child(Icon::new(IconName::Close))
677 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
678 cx.stop_propagation();
679 state_remove.update(cx, |s, cx| {
680 s.config.remove_field(field);
681 s.recompute();
682 cx.notify();
683 });
684 }),
685 );
686
687 if chopped {
688 let tip_label: SharedString = caption.into();
689 let tip_bg = theme.menu_bg;
690 let tip_fg = theme.menu_fg;
691 let tip_border = theme.grid_line;
692 chip = chip.tooltip(move |_window, cx| {
693 cx.new(|_| ChipTooltip {
694 label: tip_label.clone(),
695 bg: tip_bg,
696 fg: tip_fg,
697 border: tip_border,
698 })
699 .into()
700 });
701 }
702
703 let state_dialog = state.clone();
704 chip = chip.on_mouse_down(MouseButton::Left, move |e: &MouseDownEvent, _w, cx| {
705 if e.click_count == 2 {
706 cx.stop_propagation();
707 state_dialog.update(cx, |s, cx| {
708 s.open_format_dialog(field, PivotZone::Values, e.position);
709 cx.notify();
710 });
711 }
712 });
713
714 rows.push(chip.into_any_element());
715
716 if menu_open {
717 for func in AggregationFn::all() {
718 let selected = func == agg;
719 let state_pick = state.clone();
720 rows.push(
721 div()
722 .px(px(8.0))
723 .py(px(2.0))
724 .rounded(px(3.0))
725 .bg(if selected {
726 theme.menu_hover_bg
727 } else {
728 theme.menu_bg
729 })
730 .text_color(theme.menu_fg)
731 .text_size(px(12.0))
732 .cursor_pointer()
733 .child(func.label())
734 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
735 state_pick.update(cx, |s, cx| {
736 s.config.aggregation = func;
737 s.agg_menu_open = false;
738 s.recompute();
739 cx.notify();
740 });
741 })
742 .into_any_element(),
743 );
744 }
745 }
746
747 div()
748 .flex()
749 .flex_col()
750 .gap(px(2.0))
751 .children(rows)
752 .into_any_element()
753 }
754
755 fn option_row(
757 state: &Entity<PivotState>,
758 label: &'static str,
759 checked: bool,
760 apply: impl Fn(&mut PivotState) + 'static,
761 cx: &App,
762 ) -> gpui::AnyElement {
763 let theme = state.read(cx).theme.clone();
764 let state_toggle = state.clone();
765 let hover_bg = theme.menu_hover_bg;
766 let boxed = crate::grid::checkbox(checked, &theme);
767 div()
768 .id(SharedString::from(format!("pivot-option-{label}")))
769 .flex()
770 .items_center()
771 .gap(px(8.0))
772 .px(px(4.0))
773 .py(px(3.0))
774 .rounded(px(4.0))
775 .cursor_pointer()
776 .hover(move |style| style.bg(hover_bg))
777 .text_size(px(13.0))
778 .text_color(theme.menu_fg)
779 .child(boxed)
780 .child(label)
781 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
782 state_toggle.update(cx, |s, cx| {
783 apply(s);
784 s.recompute();
785 cx.notify();
786 });
787 })
788 .into_any_element()
789 }
790
791 fn text_button(
793 state: &Entity<PivotState>,
794 label: &'static str,
795 apply: impl Fn(&mut PivotState, &mut App) + 'static,
796 cx: &App,
797 ) -> gpui::AnyElement {
798 let theme = state.read(cx).theme.clone();
799 let state_btn = state.clone();
800 let hover_bg = theme.menu_hover_bg;
801 div()
802 .id(SharedString::from(format!("pivot-button-{label}")))
803 .px(px(10.0))
804 .py(px(4.0))
805 .rounded(px(4.0))
806 .border_1()
807 .border_color(theme.grid_line)
808 .bg(theme.pivot_drop_zone_bg)
809 .hover(move |style| style.bg(hover_bg))
810 .text_color(theme.menu_fg)
811 .text_size(px(12.0))
812 .cursor_pointer()
813 .child(label)
814 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
815 state_btn.update(cx, |s, cx| {
816 apply(s, cx);
817 cx.notify();
818 });
819 })
820 .into_any_element()
821 }
822
823 fn render_filter_popover(
825 state: &Entity<PivotState>,
826 cx: &App,
827 ) -> Option<impl IntoElement + use<>> {
828 let s = state.read(cx);
829 let popover = s.filter_popover()?.clone();
830 let theme = s.theme.clone();
831 let animations = s.animations;
832 let field_name = s
833 .source_columns()
834 .get(popover.field)
835 .map(|c| c.name.clone())
836 .unwrap_or_default();
837
838 let checkbox = |checked: bool| crate::grid::checkbox(checked, &theme);
839 let hover_bg = theme.menu_hover_bg;
840
841 let state_all = state.clone();
842 let select_all = div()
843 .id("pivot-filter-select-all")
844 .h(px(22.0))
845 .flex()
846 .items_center()
847 .gap(px(8.0))
848 .px(px(4.0))
849 .rounded(px(4.0))
850 .cursor_pointer()
851 .hover(move |style| style.bg(hover_bg))
852 .child(checkbox(popover.all_checked()))
853 .child("(Select All)")
854 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
855 state_all.update(cx, |s, cx| {
856 s.toggle_filter_popover_select_all();
857 cx.notify();
858 });
859 });
860
861 let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
862 for (i, row) in popover
863 .rows
864 .iter()
865 .enumerate()
866 .take(FILTER_POPOVER_MAX_ROWS)
867 {
868 let state_val = state.clone();
869 value_rows.push(
870 div()
871 .id(("pivot-filter-value", i))
872 .h(px(22.0))
873 .flex()
874 .items_center()
875 .gap(px(8.0))
876 .px(px(4.0))
877 .rounded(px(4.0))
878 .cursor_pointer()
879 .hover(move |style| style.bg(hover_bg))
880 .child(checkbox(row.checked))
881 .child(div().text_color(theme.menu_fg).child(row.label.clone()))
882 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
883 state_val.update(cx, |s, cx| {
884 s.toggle_filter_popover_value(i);
885 cx.notify();
886 });
887 })
888 .into_any_element(),
889 );
890 }
891 let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
892
893 let state_clear = state.clone();
894 let state_close = state.clone();
895 let clear_field = popover.field;
896 let buttons = div()
897 .flex()
898 .gap(px(6.0))
899 .child(
900 div()
901 .flex_1()
902 .h(px(24.0))
903 .flex()
904 .items_center()
905 .justify_center()
906 .border_1()
907 .border_color(theme.grid_line)
908 .bg(theme.menu_hover_bg)
909 .cursor_pointer()
910 .child("Clear")
911 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
912 state_clear.update(cx, |s, cx| {
913 s.clear_filter(clear_field);
914 cx.notify();
915 });
916 }),
917 )
918 .child(
919 div()
920 .flex_1()
921 .h(px(24.0))
922 .flex()
923 .items_center()
924 .justify_center()
925 .border_1()
926 .border_color(theme.grid_line)
927 .bg(theme.menu_hover_bg)
928 .cursor_pointer()
929 .child("Close")
930 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
931 state_close.update(cx, |s, cx| {
932 s.close_filter_popover();
933 cx.notify();
934 });
935 }),
936 );
937
938 let body = div()
939 .flex()
940 .flex_col()
941 .w(px(240.0))
942 .p(px(10.0))
943 .gap(px(8.0))
944 .bg(theme.menu_bg)
945 .border_1()
946 .border_color(theme.grid_line)
947 .text_color(theme.menu_fg)
948 .text_size(px(13.0))
949 .child(
950 div()
951 .text_color(theme.muted_text)
952 .text_size(px(11.0))
953 .child(format!("Filter: {field_name}")),
954 )
955 .child(select_all)
956 .child(
957 div()
958 .id("pivot-filter-values")
959 .flex()
960 .flex_col()
961 .max_h(px(200.0))
962 .overflow_y_scroll()
963 .occlude()
966 .children(value_rows)
967 .children(truncated.then(|| {
968 div()
969 .text_color(theme.muted_text)
970 .text_size(px(11.0))
971 .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
972 })),
973 )
974 .child(buttons);
975
976 let state_backdrop = state.clone();
977 let overlay = deferred(
978 anchored()
979 .anchor(Corner::TopLeft)
980 .position(point(popover.anchor.x, popover.anchor.y))
981 .child(
982 div()
983 .occlude()
984 .child(crate::grid::motion::pop_in(
985 body,
986 "pivot-filter-popover",
987 animations,
988 ))
989 .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
990 state_backdrop.update(cx, |s, cx| {
991 if s.filter_popover().is_some() {
992 s.close_filter_popover();
993 cx.notify();
994 }
995 });
996 }),
997 ),
998 )
999 .with_priority(POPOVER_PRIORITY);
1000 Some(overlay)
1001 }
1002
1003 #[allow(clippy::too_many_lines)]
1006 fn render_format_dialog(
1007 state: &Entity<PivotState>,
1008 cx: &App,
1009 ) -> Option<impl IntoElement + use<>> {
1010 use crate::config::{NumberFormat, TextAlignment};
1011
1012 let s = state.read(cx);
1013 let dialog = *s.format_dialog()?;
1014 let fmt = s.format_dialog_format()?;
1015 let theme = s.theme.clone();
1016 let animations = s.animations;
1017 let field_name = s
1018 .source_columns()
1019 .get(dialog.field)
1020 .map(|c| c.name.clone())
1021 .unwrap_or_default();
1022
1023 let c_bg = theme.menu_bg;
1024 let c_line = theme.grid_line;
1025 let c_fg = theme.menu_fg;
1026 let c_muted = theme.muted_text;
1027 let c_accent = theme.sort_indicator;
1028 let c_hover = theme.menu_hover_bg;
1029
1030 let checkbox = {
1031 let theme = theme.clone();
1032 move |checked: bool| crate::grid::checkbox(checked, &theme)
1033 };
1034
1035 let check_row = |label: &'static str, checked: bool, apply: fn(&mut NumberFormat)| {
1036 let st = state.clone();
1037 div()
1038 .h(px(22.0))
1039 .flex()
1040 .items_center()
1041 .gap(px(6.0))
1042 .cursor_pointer()
1043 .child(checkbox(checked))
1044 .child(label)
1045 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1046 st.update(cx, |s, cx| {
1047 s.update_format_dialog(apply);
1048 cx.notify();
1049 });
1050 })
1051 };
1052
1053 let neg_btn = |label: &'static str, parens: bool| {
1055 let st = state.clone();
1056 let selected = fmt.negative_parentheses == parens;
1057 div()
1058 .flex_1()
1059 .h(px(22.0))
1060 .flex()
1061 .items_center()
1062 .justify_center()
1063 .border_1()
1064 .border_color(c_line)
1065 .bg(if selected { c_accent } else { c_hover })
1066 .cursor_pointer()
1067 .child(label)
1068 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1069 st.update(cx, |s, cx| {
1070 s.update_format_dialog(|f| f.negative_parentheses = parens);
1071 cx.notify();
1072 });
1073 })
1074 };
1075
1076 let dec_btn = |label: &'static str, apply: fn(&mut NumberFormat)| {
1077 let st = state.clone();
1078 div()
1079 .w(px(20.0))
1080 .h(px(20.0))
1081 .flex()
1082 .items_center()
1083 .justify_center()
1084 .border_1()
1085 .border_color(c_line)
1086 .bg(c_hover)
1087 .cursor_pointer()
1088 .child(label)
1089 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1090 st.update(cx, |s, cx| {
1091 s.update_format_dialog(apply);
1092 cx.notify();
1093 });
1094 })
1095 };
1096
1097 let align_btn = |label: &'static str, value: TextAlignment| {
1098 let st = state.clone();
1099 let selected = fmt.alignment == value;
1100 div()
1101 .flex_1()
1102 .h(px(22.0))
1103 .flex()
1104 .items_center()
1105 .justify_center()
1106 .border_1()
1107 .border_color(c_line)
1108 .bg(if selected { c_accent } else { c_hover })
1109 .cursor_pointer()
1110 .child(label)
1111 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1112 st.update(cx, |s, cx| {
1113 s.update_format_dialog(move |f| f.alignment = value);
1114 cx.notify();
1115 });
1116 })
1117 };
1118
1119 let st_reset = state.clone();
1120 let st_close = state.clone();
1121 let buttons = div()
1122 .flex()
1123 .gap(px(6.0))
1124 .child(
1125 div()
1126 .flex_1()
1127 .h(px(24.0))
1128 .flex()
1129 .items_center()
1130 .justify_center()
1131 .border_1()
1132 .border_color(c_line)
1133 .bg(c_hover)
1134 .cursor_pointer()
1135 .child("Reset")
1136 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1137 st_reset.update(cx, |s, cx| {
1138 s.reset_format_dialog();
1139 cx.notify();
1140 });
1141 }),
1142 )
1143 .child(
1144 div()
1145 .flex_1()
1146 .h(px(24.0))
1147 .flex()
1148 .items_center()
1149 .justify_center()
1150 .border_1()
1151 .border_color(c_line)
1152 .bg(c_hover)
1153 .cursor_pointer()
1154 .child("Close")
1155 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1156 st_close.update(cx, |s, cx| {
1157 s.close_format_dialog();
1158 cx.notify();
1159 });
1160 }),
1161 );
1162
1163 let body = div()
1164 .flex()
1165 .flex_col()
1166 .w(px(230.0))
1167 .p(px(8.0))
1168 .gap(px(6.0))
1169 .bg(c_bg)
1170 .border_1()
1171 .border_color(c_line)
1172 .text_color(c_fg)
1173 .text_size(px(12.0))
1174 .child(
1175 div()
1176 .text_color(c_muted)
1177 .text_size(px(11.0))
1178 .child(format!("Format: {field_name}")),
1179 )
1180 .child(check_row(
1181 "Negative numbers in red",
1182 fmt.show_negative_red,
1183 |f| f.show_negative_red = !f.show_negative_red,
1184 ))
1185 .child(check_row(
1186 "Thousands separator",
1187 fmt.thousands_separator,
1188 |f| f.thousands_separator = !f.thousands_separator,
1189 ))
1190 .child(
1191 div()
1192 .text_color(c_muted)
1193 .text_size(px(11.0))
1194 .child("Negatives"),
1195 )
1196 .child(
1197 div()
1198 .flex()
1199 .gap(px(6.0))
1200 .child(neg_btn("-1,234", false))
1201 .child(neg_btn("(1,234)", true)),
1202 )
1203 .child(
1204 div()
1205 .h(px(22.0))
1206 .flex()
1207 .items_center()
1208 .gap(px(6.0))
1209 .child(
1210 div()
1211 .text_color(c_muted)
1212 .text_size(px(11.0))
1213 .child("Decimals"),
1214 )
1215 .child(dec_btn("-", |f| f.decimals = f.decimals.saturating_sub(1)))
1216 .child(
1217 div()
1218 .w(px(18.0))
1219 .flex()
1220 .justify_center()
1221 .child(fmt.decimals.to_string()),
1222 )
1223 .child(dec_btn("+", |f| f.decimals = (f.decimals + 1).min(8))),
1224 )
1225 .child(
1226 div()
1227 .text_color(c_muted)
1228 .text_size(px(11.0))
1229 .child("Alignment"),
1230 )
1231 .child(
1232 div()
1233 .flex()
1234 .gap(px(6.0))
1235 .child(align_btn("Left", TextAlignment::Left))
1236 .child(align_btn("Center", TextAlignment::Center))
1237 .child(align_btn("Right", TextAlignment::Right)),
1238 )
1239 .child(buttons);
1240
1241 let state_backdrop = state.clone();
1242 let overlay = deferred(
1243 anchored()
1244 .anchor(Corner::TopLeft)
1245 .position(point(dialog.anchor.x, dialog.anchor.y))
1246 .child(
1247 div()
1248 .occlude()
1249 .child(crate::grid::motion::pop_in(
1250 body,
1251 "pivot-format-dialog",
1252 animations,
1253 ))
1254 .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
1255 state_backdrop.update(cx, |s, cx| {
1256 if s.format_dialog().is_some() {
1257 s.close_format_dialog();
1258 cx.notify();
1259 }
1260 });
1261 }),
1262 ),
1263 )
1264 .with_priority(POPOVER_PRIORITY);
1265 Some(overlay)
1266 }
1267}
1268
1269impl Render for PivotSidebar {
1270 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1271 let (theme, columns, config, sidebar_width, save_handler, animations) = {
1272 let s = self.state.read(cx);
1273 (
1274 s.theme.clone(),
1275 s.source_columns().to_vec(),
1276 s.config.clone(),
1277 s.sidebar_width,
1278 s.save_config_handler.clone(),
1279 s.animations,
1280 )
1281 };
1282 let content_w = sidebar_width - SIDEBAR_CONTENT_CHROME;
1283
1284 let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
1286 for (i, col) in columns.iter().enumerate() {
1287 let badge = match config.zone_of(i) {
1288 Some(PivotZone::Rows) => Some("R"),
1289 Some(PivotZone::Columns) => Some("C"),
1290 Some(PivotZone::Values) => Some("V"),
1291 Some(PivotZone::Filters) => Some("F"),
1292 None => None,
1293 };
1294 field_chips.push(Self::chip(
1295 &self.state,
1296 ("pivot-source-field", i),
1297 i,
1298 col.name.clone(),
1299 None,
1300 false,
1301 badge,
1302 content_w - CHIP_CHROME,
1303 window,
1304 cx,
1305 ));
1306 }
1307
1308 let options = div()
1309 .flex()
1310 .flex_col()
1311 .gap(px(6.0))
1312 .child(
1313 div()
1314 .text_color(theme.muted_text)
1315 .text_size(px(11.0))
1316 .child("Layout"),
1317 )
1318 .child(Self::option_row(
1319 &self.state,
1320 "Flat rows (no hierarchy)",
1321 config.flat_rows,
1322 |s| s.config.flat_rows = !s.config.flat_rows,
1323 cx,
1324 ))
1325 .child(
1326 div()
1327 .text_color(theme.muted_text)
1328 .text_size(px(11.0))
1329 .child("Totals"),
1330 )
1331 .child(Self::option_row(
1332 &self.state,
1333 "Row subtotals",
1334 config.show_row_subtotals,
1335 |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
1336 cx,
1337 ))
1338 .child(Self::option_row(
1339 &self.state,
1340 "Column subtotal columns",
1341 config.show_column_subtotals,
1342 |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
1343 cx,
1344 ))
1345 .child(Self::option_row(
1346 &self.state,
1347 "Grand total row",
1348 config.show_row_grand_total,
1349 |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
1350 cx,
1351 ))
1352 .child(Self::option_row(
1353 &self.state,
1354 "Grand total column",
1355 config.show_column_grand_total,
1356 |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
1357 cx,
1358 ));
1359
1360 let tools = div()
1361 .flex()
1362 .flex_col()
1363 .gap(px(6.0))
1364 .child(
1365 div()
1366 .text_color(theme.muted_text)
1367 .text_size(px(11.0))
1368 .child("Groups"),
1369 )
1370 .child(
1371 div()
1372 .flex()
1373 .gap(px(4.0))
1374 .child(Self::text_button(
1375 &self.state,
1376 "Expand rows",
1377 |s, _| s.expand_all_rows(),
1378 cx,
1379 ))
1380 .child(Self::text_button(
1381 &self.state,
1382 "Collapse rows",
1383 |s, _| s.collapse_all_rows(),
1384 cx,
1385 )),
1386 )
1387 .child(
1388 div()
1389 .flex()
1390 .gap(px(4.0))
1391 .child(Self::text_button(
1392 &self.state,
1393 "Expand cols",
1394 |s, _| s.expand_all_cols(),
1395 cx,
1396 ))
1397 .child(Self::text_button(
1398 &self.state,
1399 "Collapse cols",
1400 |s, _| s.collapse_all_cols(),
1401 cx,
1402 )),
1403 )
1404 .child(
1405 div()
1406 .text_color(theme.muted_text)
1407 .text_size(px(11.0))
1408 .child("Export"),
1409 )
1410 .child(Self::text_button(
1411 &self.state,
1412 "Copy pivot as CSV",
1413 |s, cx| {
1414 let csv = s.to_csv();
1415 if !csv.is_empty() {
1416 cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
1417 }
1418 },
1419 cx,
1420 ));
1421
1422 let fields = div()
1423 .flex()
1424 .flex_col()
1425 .gap(px(8.0))
1426 .child(
1427 div()
1428 .text_color(theme.muted_text)
1429 .text_size(px(11.0))
1430 .child("Drag a field into a layout zone"),
1431 )
1432 .child(
1433 div()
1434 .id("pivot-field-list")
1435 .flex()
1436 .flex_col()
1437 .gap(px(4.0))
1438 .max_h(px(220.0))
1439 .overflow_y_scroll()
1440 .occlude()
1448 .children(field_chips),
1449 );
1450
1451 let save_button = save_handler.map(|handler| {
1455 let state_save = self.state.clone();
1456 let icon_color = theme.muted_text;
1457 let hover_bg = theme.pivot_drop_zone_active_bg;
1458 let tip_bg = theme.menu_bg;
1459 let tip_fg = theme.menu_fg;
1460 let tip_border = theme.grid_line;
1461 div()
1462 .id("pivot-save-config")
1463 .p(px(2.0))
1464 .rounded(px(3.0))
1465 .cursor_pointer()
1466 .hover(move |style| style.bg(hover_bg))
1467 .child(disk_icon(icon_color))
1468 .tooltip(move |_window, cx| {
1469 cx.new(|_| ChipTooltip {
1470 label: "Save configuration".into(),
1471 bg: tip_bg,
1472 fg: tip_fg,
1473 border: tip_border,
1474 })
1475 .into()
1476 })
1477 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1478 cx.stop_propagation();
1479 let config = state_save.read(cx).config.clone();
1480 handler(&config, cx);
1481 })
1482 .on_mouse_up(MouseButton::Left, |_e: &MouseUpEvent, _w, cx| {
1485 cx.stop_propagation();
1486 })
1487 .into_any_element()
1488 });
1489
1490 let layout = div()
1491 .flex()
1492 .flex_col()
1493 .gap(px(8.0))
1494 .child(Self::zone(
1495 &self.state,
1496 PivotZone::Filters,
1497 "Filters",
1498 content_w,
1499 window,
1500 cx,
1501 ))
1502 .child(Self::zone(
1503 &self.state,
1504 PivotZone::Columns,
1505 "Columns",
1506 content_w,
1507 window,
1508 cx,
1509 ))
1510 .child(Self::zone(
1511 &self.state,
1512 PivotZone::Rows,
1513 "Rows",
1514 content_w,
1515 window,
1516 cx,
1517 ))
1518 .child(Self::zone(
1519 &self.state,
1520 PivotZone::Values,
1521 "Values",
1522 content_w,
1523 window,
1524 cx,
1525 ));
1526
1527 let display = div()
1528 .flex()
1529 .flex_col()
1530 .gap(px(10.0))
1531 .child(options)
1532 .child(tools);
1533
1534 let sidebar = cx.entity().clone();
1535 let is_expanded = |id: &str| self.expanded_sections.iter().any(|section| section == id);
1536 let sections = div()
1537 .flex()
1538 .flex_col()
1539 .border_1()
1540 .border_color(theme.grid_line)
1541 .rounded_lg()
1542 .child(Self::section(
1543 &sidebar,
1544 &theme,
1545 "fields",
1546 "Fields",
1547 None,
1548 is_expanded("fields"),
1549 true,
1550 animations,
1551 fields.into_any_element(),
1552 ))
1553 .child(Self::section(
1554 &sidebar,
1555 &theme,
1556 "layout",
1557 "Layout",
1558 save_button,
1559 is_expanded("layout"),
1560 false,
1561 animations,
1562 layout.into_any_element(),
1563 ))
1564 .child(Self::section(
1565 &sidebar,
1566 &theme,
1567 "display",
1568 "Display and export",
1569 None,
1570 is_expanded("display"),
1571 false,
1572 animations,
1573 display.into_any_element(),
1574 ));
1575
1576 div()
1577 .id("pivot-sidebar")
1578 .h_full()
1579 .flex()
1580 .flex_col()
1581 .gap(px(8.0))
1582 .p(px(8.0))
1583 .bg(theme.menu_bg)
1584 .text_color(theme.menu_fg)
1585 .text_size(px(12.0))
1586 .overflow_y_scroll()
1587 .child(
1588 div()
1589 .text_color(theme.header_fg)
1590 .text_size(px(14.0))
1591 .font_weight(FontWeight::SEMIBOLD)
1592 .child("Pivot Controls"),
1593 )
1594 .child(sections)
1595 .children(Self::render_filter_popover(&self.state, cx))
1596 .children(Self::render_format_dialog(&self.state, cx))
1597 }
1598}