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