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;
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}
134
135impl Render for DragGhost {
136 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
137 div()
138 .px(px(8.0))
139 .h(px(CHIP_HEIGHT))
140 .flex()
141 .items_center()
142 .rounded(px(4.0))
143 .bg(self.bg)
144 .border_1()
145 .border_color(self.border)
146 .text_color(self.fg)
147 .text_size(px(12.0))
148 .child(self.label.clone())
149 }
150}
151
152pub struct PivotSidebar {
155 pub state: Entity<PivotState>,
157 expanded_sections: Vec<SharedString>,
158}
159
160impl PivotSidebar {
161 #[must_use]
163 pub fn new(state: Entity<PivotState>) -> Self {
164 Self {
165 state,
166 expanded_sections: vec!["fields".into(), "layout".into(), "display".into()],
167 }
168 }
169
170 fn set_section_expanded(&mut self, id: &SharedString, expanded: bool) {
171 if expanded {
172 if !self.expanded_sections.contains(id) {
173 self.expanded_sections.push(id.clone());
174 }
175 } else {
176 self.expanded_sections.retain(|section| section != id);
177 }
178 }
179
180 #[allow(clippy::too_many_arguments)]
186 fn section(
187 sidebar: &Entity<PivotSidebar>,
188 theme: &GridTheme,
189 id: &'static str,
190 title: &'static str,
191 header_extra: Option<gpui::AnyElement>,
192 expanded: bool,
193 first: bool,
194 content: gpui::AnyElement,
195 ) -> gpui::AnyElement {
196 let sidebar = sidebar.clone();
197 let section_id: SharedString = id.into();
198 let hover_bg = theme.menu_hover_bg;
199
200 let mut header = div()
201 .id(SharedString::from(format!("pivot-section-{id}")))
202 .flex()
203 .items_center()
204 .justify_between()
205 .px(px(16.0))
206 .py(px(12.0))
207 .bg(theme.header_bg)
208 .cursor_pointer()
209 .hover(move |style| style.bg(hover_bg))
210 .on_mouse_up(MouseButton::Left, move |_e: &MouseUpEvent, _w, cx| {
211 sidebar.update(cx, |this, cx| {
212 this.set_section_expanded(§ion_id, !expanded);
213 cx.notify();
214 });
215 })
216 .child(
217 div()
218 .flex()
219 .items_center()
220 .gap(px(8.0))
221 .child(
222 div()
223 .text_size(px(14.0))
224 .font_weight(FontWeight::MEDIUM)
225 .text_color(theme.header_fg)
226 .child(title),
227 )
228 .children(header_extra),
229 )
230 .child(
231 div()
232 .text_size(px(12.0))
233 .text_color(theme.muted_text)
234 .child(if expanded { "▼" } else { "▶" }),
235 );
236 if !first {
237 header = header.border_t_1().border_color(theme.grid_line);
238 }
239
240 let mut wrapper = div().flex().flex_col().child(header);
241 if expanded {
242 wrapper = wrapper.child(
243 div()
244 .px(px(16.0))
245 .py(px(12.0))
246 .bg(theme.menu_bg)
247 .border_t_1()
248 .border_color(theme.grid_line)
249 .child(content),
250 );
251 }
252 wrapper.into_any_element()
253 }
254
255 #[allow(clippy::too_many_arguments)]
260 fn chip(
261 state: &Entity<PivotState>,
262 id: (&'static str, usize),
263 field: usize,
264 label: String,
265 zone: Option<PivotZone>,
266 removable: bool,
267 marker: Option<&'static str>,
268 label_budget: f32,
269 window: &Window,
270 cx: &App,
271 ) -> gpui::AnyElement {
272 let s = state.read(cx);
273 let theme = s.theme.clone();
274 let ghost_label = label.clone();
275 let ghost_bg = theme.pivot_chip_bg;
276 let ghost_fg = theme.pivot_chip_fg;
277 let ghost_border = theme.grid_line;
278
279 let state_drop = state.clone();
280 let state_remove = state.clone();
281
282 let mut available = label_budget;
283 if let Some(marker) = marker {
284 available -= CHIP_GAP + measure_text(window, marker, MARKER_FONT_SIZE);
285 }
286 if removable {
287 available -= CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE);
288 }
289 let chopped = measure_text(window, &label, CHIP_FONT_SIZE) > available + 0.5;
290
291 let mut chip = div()
292 .id(id)
293 .px(px(8.0))
294 .h(px(CHIP_HEIGHT))
295 .flex_none()
296 .rounded(px(4.0))
297 .bg(theme.pivot_chip_bg)
298 .border_1()
299 .border_color(theme.grid_line)
300 .text_color(theme.pivot_chip_fg)
301 .text_size(px(CHIP_FONT_SIZE))
302 .flex()
303 .items_center()
304 .gap(px(CHIP_GAP))
305 .cursor_pointer()
306 .on_drag(
307 DraggedField { field },
308 move |_drag, _offset, _window, cx| {
309 cx.new(|_| DragGhost {
310 label: ghost_label.clone(),
311 bg: ghost_bg,
312 fg: ghost_fg,
313 border: ghost_border,
314 })
315 },
316 )
317 .child(
318 div()
319 .flex_1()
320 .min_w(px(0.0))
321 .truncate()
322 .child(label.clone()),
323 );
324
325 if chopped {
326 let tip_label: SharedString = label.into();
327 let tip_bg = theme.menu_bg;
328 let tip_fg = theme.menu_fg;
329 let tip_border = theme.grid_line;
330 chip = chip.tooltip(move |_window, cx| {
331 cx.new(|_| ChipTooltip {
332 label: tip_label.clone(),
333 bg: tip_bg,
334 fg: tip_fg,
335 border: tip_border,
336 })
337 .into()
338 });
339 }
340
341 if let Some(marker) = marker {
342 chip = chip.child(
343 div()
344 .flex_none()
345 .text_color(theme.sort_indicator)
346 .text_size(px(MARKER_FONT_SIZE))
347 .child(marker),
348 );
349 }
350
351 if removable {
352 chip = chip.child(
353 div()
354 .flex_none()
355 .text_color(theme.muted_text)
356 .cursor_pointer()
357 .child("✕")
358 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
359 cx.stop_propagation();
360 state_remove.update(cx, |s, cx| {
361 s.config.remove_field(field);
362 s.recompute();
363 cx.notify();
364 });
365 }),
366 );
367 }
368
369 if let Some(zone) = zone {
373 let state_dialog = state.clone();
374 chip = chip
375 .on_drop::<DraggedField>(move |dragged, _window, cx| {
376 cx.stop_propagation();
377 let dragged_field = dragged.field;
378 state_drop.update(cx, |s, cx| {
379 let index = s.config.fields_in(zone).iter().position(|&f| f == field);
380 s.config.move_field(dragged_field, zone, index);
381 s.recompute();
382 cx.notify();
383 });
384 })
385 .on_mouse_down(MouseButton::Left, move |e: &MouseDownEvent, _w, cx| {
386 if e.click_count == 2 {
387 cx.stop_propagation();
388 state_dialog.update(cx, |s, cx| {
389 s.close_filter_popover();
390 s.open_format_dialog(field, zone, e.position);
391 cx.notify();
392 });
393 }
394 });
395 }
396
397 chip.into_any_element()
398 }
399
400 fn zone(
403 state: &Entity<PivotState>,
404 zone: PivotZone,
405 title: &'static str,
406 content_w: f32,
407 window: &Window,
408 cx: &App,
409 ) -> gpui::AnyElement {
410 let s = state.read(cx);
411 let theme = s.theme.clone();
412 let fields = s.config.fields_in(zone);
413 let columns = s.source_columns().to_vec();
414 let agg = s.config.aggregation;
415 let agg_menu_open = s.agg_menu_open;
416
417 let state_drop = state.clone();
418 let active_bg = theme.pivot_drop_zone_active_bg;
419 let label_budget = content_w - ZONE_CHROME - CHIP_CHROME;
420
421 let mut chips: Vec<gpui::AnyElement> = Vec::new();
422 for (i, &field) in fields.iter().enumerate() {
423 let name = columns
424 .get(field)
425 .map(|c| c.name.clone())
426 .unwrap_or_else(|| format!("column {field}"));
427 match zone {
428 PivotZone::Values => {
429 chips.push(Self::values_chip(
430 state,
431 field,
432 agg,
433 agg_menu_open,
434 name,
435 label_budget,
436 window,
437 cx,
438 ));
439 }
440 PivotZone::Filters => {
441 let filter_on = s.filter_active(field);
442 let state_open = state.clone();
443 let chip = div()
444 .id(("pivot-filter-chip", i))
445 .on_mouse_down(MouseButton::Left, {
446 let state_open = state_open.clone();
447 move |e: &MouseDownEvent, _w, cx| {
448 if e.click_count != 1 {
455 return;
456 }
457 let anchor = point(e.position.x, e.position.y + px(20.0));
458 state_open.update(cx, |s, cx| {
459 s.open_filter_popover(field, anchor);
460 cx.notify();
461 });
462 }
463 })
464 .child(Self::chip(
465 state,
466 ("pivot-filter-chip-inner", i),
467 field,
468 name,
469 Some(zone),
470 true,
471 filter_on.then_some("🔽"),
472 label_budget,
473 window,
474 cx,
475 ));
476 chips.push(chip.into_any_element());
477 }
478 PivotZone::Rows | PivotZone::Columns => {
479 let id = match zone {
480 PivotZone::Rows => ("pivot-row-chip", i),
481 _ => ("pivot-col-chip", i),
482 };
483 chips.push(Self::chip(
484 state,
485 id,
486 field,
487 name,
488 Some(zone),
489 true,
490 None,
491 label_budget,
492 window,
493 cx,
494 ));
495 }
496 }
497 }
498
499 let hint = if chips.is_empty() {
500 Some(
501 div()
502 .text_color(theme.muted_text)
503 .text_size(px(11.0))
504 .child("Drag fields here"),
505 )
506 } else {
507 None
508 };
509
510 div()
511 .flex()
512 .flex_col()
513 .gap(px(4.0))
514 .child(
515 div()
516 .text_color(theme.muted_text)
517 .text_size(px(11.0))
518 .child(title),
519 )
520 .child(
521 div()
522 .min_h(px(40.0))
523 .p(px(6.0))
524 .rounded(px(4.0))
525 .bg(theme.pivot_drop_zone_bg)
526 .border_1()
527 .border_color(theme.grid_line)
528 .flex()
529 .flex_col()
530 .gap(px(4.0))
531 .drag_over::<DraggedField>(move |style, _, _, _| style.bg(active_bg))
532 .on_drop::<DraggedField>(move |dragged, _window, cx| {
533 let dragged_field = dragged.field;
534 state_drop.update(cx, |s, cx| {
535 s.config.move_field(dragged_field, zone, None);
536 s.recompute();
537 cx.notify();
538 });
539 })
540 .children(chips)
541 .children(hint),
542 )
543 .into_any_element()
544 }
545
546 #[allow(clippy::too_many_arguments)]
550 fn values_chip(
551 state: &Entity<PivotState>,
552 field: usize,
553 agg: AggregationFn,
554 menu_open: bool,
555 name: String,
556 label_budget: f32,
557 window: &Window,
558 cx: &App,
559 ) -> gpui::AnyElement {
560 let s = state.read(cx);
561 let theme = s.theme.clone();
562 let state_toggle = state.clone();
563 let state_remove = state.clone();
564
565 let mut rows: Vec<gpui::AnyElement> = Vec::new();
566 let caption = agg.caption(&name);
567 let ghost_label = caption.clone();
568 let ghost_bg = theme.pivot_chip_bg;
569 let ghost_fg = theme.pivot_chip_fg;
570 let ghost_border = theme.grid_line;
571
572 let arrow = if menu_open { "▲" } else { "▼" };
573 let available = label_budget
574 - (CHIP_GAP + measure_text(window, arrow, CHIP_FONT_SIZE))
575 - (CHIP_GAP + measure_text(window, "✕", CHIP_FONT_SIZE));
576 let chopped = measure_text(window, &caption, CHIP_FONT_SIZE) > available + 0.5;
577
578 let mut chip = div()
579 .id("pivot-values-chip")
580 .px(px(8.0))
581 .h(px(CHIP_HEIGHT))
582 .flex_none()
583 .rounded(px(4.0))
584 .bg(theme.pivot_chip_bg)
585 .border_1()
586 .border_color(theme.grid_line)
587 .text_color(theme.pivot_chip_fg)
588 .text_size(px(CHIP_FONT_SIZE))
589 .flex()
590 .items_center()
591 .gap(px(CHIP_GAP))
592 .cursor_pointer()
593 .on_drag(
594 DraggedField { field },
595 move |_drag, _offset, _window, cx| {
596 cx.new(|_| DragGhost {
597 label: ghost_label.clone(),
598 bg: ghost_bg,
599 fg: ghost_fg,
600 border: ghost_border,
601 })
602 },
603 )
604 .child(
605 div()
606 .flex_1()
607 .min_w(px(0.0))
608 .truncate()
609 .child(caption.clone()),
610 )
611 .child(
612 div()
613 .flex_none()
614 .cursor_pointer()
615 .text_color(theme.sort_indicator)
616 .child(arrow)
617 .on_mouse_down(MouseButton::Left, {
618 let state_toggle = state_toggle.clone();
619 move |_e: &MouseDownEvent, _w, cx| {
620 cx.stop_propagation();
621 state_toggle.update(cx, |s, cx| {
622 s.agg_menu_open = !s.agg_menu_open;
623 cx.notify();
624 });
625 }
626 }),
627 )
628 .child(
629 div()
630 .flex_none()
631 .text_color(theme.muted_text)
632 .cursor_pointer()
633 .child("✕")
634 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
635 cx.stop_propagation();
636 state_remove.update(cx, |s, cx| {
637 s.config.remove_field(field);
638 s.recompute();
639 cx.notify();
640 });
641 }),
642 );
643
644 if chopped {
645 let tip_label: SharedString = caption.into();
646 let tip_bg = theme.menu_bg;
647 let tip_fg = theme.menu_fg;
648 let tip_border = theme.grid_line;
649 chip = chip.tooltip(move |_window, cx| {
650 cx.new(|_| ChipTooltip {
651 label: tip_label.clone(),
652 bg: tip_bg,
653 fg: tip_fg,
654 border: tip_border,
655 })
656 .into()
657 });
658 }
659
660 let state_dialog = state.clone();
661 chip = chip.on_mouse_down(MouseButton::Left, move |e: &MouseDownEvent, _w, cx| {
662 if e.click_count == 2 {
663 cx.stop_propagation();
664 state_dialog.update(cx, |s, cx| {
665 s.open_format_dialog(field, PivotZone::Values, e.position);
666 cx.notify();
667 });
668 }
669 });
670
671 rows.push(chip.into_any_element());
672
673 if menu_open {
674 for func in AggregationFn::all() {
675 let selected = func == agg;
676 let state_pick = state.clone();
677 rows.push(
678 div()
679 .px(px(8.0))
680 .py(px(2.0))
681 .rounded(px(3.0))
682 .bg(if selected {
683 theme.menu_hover_bg
684 } else {
685 theme.menu_bg
686 })
687 .text_color(theme.menu_fg)
688 .text_size(px(12.0))
689 .cursor_pointer()
690 .child(func.label())
691 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
692 state_pick.update(cx, |s, cx| {
693 s.config.aggregation = func;
694 s.agg_menu_open = false;
695 s.recompute();
696 cx.notify();
697 });
698 })
699 .into_any_element(),
700 );
701 }
702 }
703
704 div()
705 .flex()
706 .flex_col()
707 .gap(px(2.0))
708 .children(rows)
709 .into_any_element()
710 }
711
712 fn option_row(
714 state: &Entity<PivotState>,
715 label: &'static str,
716 checked: bool,
717 apply: impl Fn(&mut PivotState) + 'static,
718 cx: &App,
719 ) -> gpui::AnyElement {
720 let theme = state.read(cx).theme.clone();
721 let state_toggle = state.clone();
722 let hover_bg = theme.menu_hover_bg;
723 let boxed = div()
727 .w(px(16.0))
728 .h(px(16.0))
729 .flex_none()
730 .rounded(px(3.0))
731 .border_1()
732 .border_color(if checked {
733 theme.sort_indicator
734 } else {
735 theme.grid_line
736 })
737 .bg(if checked {
738 theme.sort_indicator
739 } else {
740 theme.menu_bg
741 })
742 .flex()
743 .items_center()
744 .justify_center()
745 .text_size(px(12.0))
746 .font_weight(FontWeight::SEMIBOLD)
747 .text_color(theme.bg)
748 .children(checked.then_some("✓"));
749 div()
750 .id(SharedString::from(format!("pivot-option-{label}")))
751 .flex()
752 .items_center()
753 .gap(px(8.0))
754 .px(px(4.0))
755 .py(px(3.0))
756 .rounded(px(4.0))
757 .cursor_pointer()
758 .hover(move |style| style.bg(hover_bg))
759 .text_size(px(13.0))
760 .text_color(theme.menu_fg)
761 .child(boxed)
762 .child(label)
763 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
764 state_toggle.update(cx, |s, cx| {
765 apply(s);
766 s.recompute();
767 cx.notify();
768 });
769 })
770 .into_any_element()
771 }
772
773 fn text_button(
775 state: &Entity<PivotState>,
776 label: &'static str,
777 apply: impl Fn(&mut PivotState, &mut App) + 'static,
778 cx: &App,
779 ) -> gpui::AnyElement {
780 let theme = state.read(cx).theme.clone();
781 let state_btn = state.clone();
782 let hover_bg = theme.menu_hover_bg;
783 div()
784 .id(SharedString::from(format!("pivot-button-{label}")))
785 .px(px(10.0))
786 .py(px(4.0))
787 .rounded(px(4.0))
788 .border_1()
789 .border_color(theme.grid_line)
790 .bg(theme.pivot_drop_zone_bg)
791 .hover(move |style| style.bg(hover_bg))
792 .text_color(theme.menu_fg)
793 .text_size(px(12.0))
794 .cursor_pointer()
795 .child(label)
796 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
797 state_btn.update(cx, |s, cx| {
798 apply(s, cx);
799 cx.notify();
800 });
801 })
802 .into_any_element()
803 }
804
805 fn render_filter_popover(
807 state: &Entity<PivotState>,
808 cx: &App,
809 ) -> Option<impl IntoElement + use<>> {
810 let s = state.read(cx);
811 let popover = s.filter_popover()?.clone();
812 let theme = s.theme.clone();
813 let field_name = s
814 .source_columns()
815 .get(popover.field)
816 .map(|c| c.name.clone())
817 .unwrap_or_default();
818
819 let checkbox = |checked: bool| {
820 let mut b = div()
821 .w(px(12.0))
822 .h(px(12.0))
823 .border_1()
824 .border_color(theme.grid_line)
825 .bg(theme.menu_bg)
826 .flex()
827 .items_center()
828 .justify_center();
829 if checked {
830 b = b.child(div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator));
831 }
832 b
833 };
834
835 let state_all = state.clone();
836 let select_all = div()
837 .h(px(22.0))
838 .flex()
839 .items_center()
840 .gap(px(6.0))
841 .cursor_pointer()
842 .child(checkbox(popover.all_checked()))
843 .child("(Select All)")
844 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
845 state_all.update(cx, |s, cx| {
846 s.toggle_filter_popover_select_all();
847 cx.notify();
848 });
849 });
850
851 let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
852 for (i, row) in popover
853 .rows
854 .iter()
855 .enumerate()
856 .take(FILTER_POPOVER_MAX_ROWS)
857 {
858 let state_val = state.clone();
859 value_rows.push(
860 div()
861 .h(px(20.0))
862 .flex()
863 .items_center()
864 .gap(px(6.0))
865 .cursor_pointer()
866 .child(checkbox(row.checked))
867 .child(div().text_color(theme.menu_fg).child(row.label.clone()))
868 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
869 state_val.update(cx, |s, cx| {
870 s.toggle_filter_popover_value(i);
871 cx.notify();
872 });
873 })
874 .into_any_element(),
875 );
876 }
877 let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
878
879 let state_clear = state.clone();
880 let state_close = state.clone();
881 let clear_field = popover.field;
882 let buttons = div()
883 .flex()
884 .gap(px(6.0))
885 .child(
886 div()
887 .flex_1()
888 .h(px(24.0))
889 .flex()
890 .items_center()
891 .justify_center()
892 .border_1()
893 .border_color(theme.grid_line)
894 .bg(theme.menu_hover_bg)
895 .cursor_pointer()
896 .child("Clear")
897 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
898 state_clear.update(cx, |s, cx| {
899 s.clear_filter(clear_field);
900 cx.notify();
901 });
902 }),
903 )
904 .child(
905 div()
906 .flex_1()
907 .h(px(24.0))
908 .flex()
909 .items_center()
910 .justify_center()
911 .border_1()
912 .border_color(theme.grid_line)
913 .bg(theme.menu_hover_bg)
914 .cursor_pointer()
915 .child("Close")
916 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
917 state_close.update(cx, |s, cx| {
918 s.close_filter_popover();
919 cx.notify();
920 });
921 }),
922 );
923
924 let body = div()
925 .flex()
926 .flex_col()
927 .w(px(240.0))
928 .p(px(8.0))
929 .gap(px(6.0))
930 .bg(theme.menu_bg)
931 .border_1()
932 .border_color(theme.grid_line)
933 .text_color(theme.menu_fg)
934 .text_size(px(12.0))
935 .child(
936 div()
937 .text_color(theme.muted_text)
938 .text_size(px(11.0))
939 .child(format!("Filter: {field_name}")),
940 )
941 .child(select_all)
942 .child(
943 div()
944 .id("pivot-filter-values")
945 .flex()
946 .flex_col()
947 .max_h(px(200.0))
948 .overflow_y_scroll()
949 .occlude()
952 .children(value_rows)
953 .children(truncated.then(|| {
954 div()
955 .text_color(theme.muted_text)
956 .text_size(px(11.0))
957 .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
958 })),
959 )
960 .child(buttons);
961
962 let state_backdrop = state.clone();
963 let overlay = deferred(
964 anchored()
965 .anchor(Corner::TopLeft)
966 .position(point(popover.anchor.x, popover.anchor.y))
967 .child(div().occlude().child(body).on_mouse_down_out(
968 move |_e: &MouseDownEvent, _window, cx| {
969 state_backdrop.update(cx, |s, cx| {
970 if s.filter_popover().is_some() {
971 s.close_filter_popover();
972 cx.notify();
973 }
974 });
975 },
976 )),
977 )
978 .with_priority(POPOVER_PRIORITY);
979 Some(overlay)
980 }
981
982 #[allow(clippy::too_many_lines)]
985 fn render_format_dialog(
986 state: &Entity<PivotState>,
987 cx: &App,
988 ) -> Option<impl IntoElement + use<>> {
989 use crate::config::{NumberFormat, TextAlignment};
990
991 let s = state.read(cx);
992 let dialog = *s.format_dialog()?;
993 let fmt = s.format_dialog_format()?;
994 let theme = s.theme.clone();
995 let field_name = s
996 .source_columns()
997 .get(dialog.field)
998 .map(|c| c.name.clone())
999 .unwrap_or_default();
1000
1001 let c_bg = theme.menu_bg;
1002 let c_line = theme.grid_line;
1003 let c_fg = theme.menu_fg;
1004 let c_muted = theme.muted_text;
1005 let c_accent = theme.sort_indicator;
1006 let c_hover = theme.menu_hover_bg;
1007
1008 let checkbox = move |checked: bool| {
1009 let mut b = div()
1010 .w(px(12.0))
1011 .h(px(12.0))
1012 .border_1()
1013 .border_color(c_line)
1014 .bg(c_bg)
1015 .flex()
1016 .items_center()
1017 .justify_center();
1018 if checked {
1019 b = b.child(div().w(px(6.0)).h(px(6.0)).bg(c_accent));
1020 }
1021 b
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(div().occlude().child(body).on_mouse_down_out(
1236 move |_e: &MouseDownEvent, _window, cx| {
1237 state_backdrop.update(cx, |s, cx| {
1238 if s.format_dialog().is_some() {
1239 s.close_format_dialog();
1240 cx.notify();
1241 }
1242 });
1243 },
1244 )),
1245 )
1246 .with_priority(POPOVER_PRIORITY);
1247 Some(overlay)
1248 }
1249}
1250
1251impl Render for PivotSidebar {
1252 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1253 let (theme, columns, config, sidebar_width, save_handler) = {
1254 let s = self.state.read(cx);
1255 (
1256 s.theme.clone(),
1257 s.source_columns().to_vec(),
1258 s.config.clone(),
1259 s.sidebar_width,
1260 s.save_config_handler.clone(),
1261 )
1262 };
1263 let content_w = sidebar_width - SIDEBAR_CONTENT_CHROME;
1264
1265 let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
1267 for (i, col) in columns.iter().enumerate() {
1268 let badge = match config.zone_of(i) {
1269 Some(PivotZone::Rows) => Some("R"),
1270 Some(PivotZone::Columns) => Some("C"),
1271 Some(PivotZone::Values) => Some("V"),
1272 Some(PivotZone::Filters) => Some("F"),
1273 None => None,
1274 };
1275 field_chips.push(Self::chip(
1276 &self.state,
1277 ("pivot-source-field", i),
1278 i,
1279 col.name.clone(),
1280 None,
1281 false,
1282 badge,
1283 content_w - CHIP_CHROME,
1284 window,
1285 cx,
1286 ));
1287 }
1288
1289 let options = div()
1290 .flex()
1291 .flex_col()
1292 .gap(px(6.0))
1293 .child(
1294 div()
1295 .text_color(theme.muted_text)
1296 .text_size(px(12.0))
1297 .child("Layout"),
1298 )
1299 .child(Self::option_row(
1300 &self.state,
1301 "Flat rows (no hierarchy)",
1302 config.flat_rows,
1303 |s| s.config.flat_rows = !s.config.flat_rows,
1304 cx,
1305 ))
1306 .child(
1307 div()
1308 .text_color(theme.muted_text)
1309 .text_size(px(12.0))
1310 .child("Totals"),
1311 )
1312 .child(Self::option_row(
1313 &self.state,
1314 "Row subtotals",
1315 config.show_row_subtotals,
1316 |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
1317 cx,
1318 ))
1319 .child(Self::option_row(
1320 &self.state,
1321 "Column subtotal columns",
1322 config.show_column_subtotals,
1323 |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
1324 cx,
1325 ))
1326 .child(Self::option_row(
1327 &self.state,
1328 "Grand total row",
1329 config.show_row_grand_total,
1330 |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
1331 cx,
1332 ))
1333 .child(Self::option_row(
1334 &self.state,
1335 "Grand total column",
1336 config.show_column_grand_total,
1337 |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
1338 cx,
1339 ));
1340
1341 let tools = div()
1342 .flex()
1343 .flex_col()
1344 .gap(px(6.0))
1345 .child(
1346 div()
1347 .text_color(theme.muted_text)
1348 .text_size(px(12.0))
1349 .child("Groups"),
1350 )
1351 .child(
1352 div()
1353 .flex()
1354 .gap(px(4.0))
1355 .child(Self::text_button(
1356 &self.state,
1357 "Expand rows",
1358 |s, _| s.expand_all_rows(),
1359 cx,
1360 ))
1361 .child(Self::text_button(
1362 &self.state,
1363 "Collapse rows",
1364 |s, _| s.collapse_all_rows(),
1365 cx,
1366 )),
1367 )
1368 .child(
1369 div()
1370 .flex()
1371 .gap(px(4.0))
1372 .child(Self::text_button(
1373 &self.state,
1374 "Expand cols",
1375 |s, _| s.expand_all_cols(),
1376 cx,
1377 ))
1378 .child(Self::text_button(
1379 &self.state,
1380 "Collapse cols",
1381 |s, _| s.collapse_all_cols(),
1382 cx,
1383 )),
1384 )
1385 .child(
1386 div()
1387 .text_color(theme.muted_text)
1388 .text_size(px(12.0))
1389 .child("Export"),
1390 )
1391 .child(Self::text_button(
1392 &self.state,
1393 "Copy pivot as CSV",
1394 |s, cx| {
1395 let csv = s.to_csv();
1396 if !csv.is_empty() {
1397 cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
1398 }
1399 },
1400 cx,
1401 ));
1402
1403 let fields = div()
1404 .flex()
1405 .flex_col()
1406 .gap(px(8.0))
1407 .child(
1408 div()
1409 .text_color(theme.muted_text)
1410 .text_size(px(11.0))
1411 .child("Drag a field into a layout zone"),
1412 )
1413 .child(
1414 div()
1415 .id("pivot-field-list")
1416 .flex()
1417 .flex_col()
1418 .gap(px(4.0))
1419 .max_h(px(220.0))
1420 .overflow_y_scroll()
1421 .occlude()
1429 .children(field_chips),
1430 );
1431
1432 let save_button = save_handler.map(|handler| {
1436 let state_save = self.state.clone();
1437 let icon_color = theme.muted_text;
1438 let hover_bg = theme.pivot_drop_zone_active_bg;
1439 let tip_bg = theme.menu_bg;
1440 let tip_fg = theme.menu_fg;
1441 let tip_border = theme.grid_line;
1442 div()
1443 .id("pivot-save-config")
1444 .p(px(2.0))
1445 .rounded(px(3.0))
1446 .cursor_pointer()
1447 .hover(move |style| style.bg(hover_bg))
1448 .child(disk_icon(icon_color))
1449 .tooltip(move |_window, cx| {
1450 cx.new(|_| ChipTooltip {
1451 label: "Save configuration".into(),
1452 bg: tip_bg,
1453 fg: tip_fg,
1454 border: tip_border,
1455 })
1456 .into()
1457 })
1458 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1459 cx.stop_propagation();
1460 let config = state_save.read(cx).config.clone();
1461 handler(&config, cx);
1462 })
1463 .on_mouse_up(MouseButton::Left, |_e: &MouseUpEvent, _w, cx| {
1466 cx.stop_propagation();
1467 })
1468 .into_any_element()
1469 });
1470
1471 let layout = div()
1472 .flex()
1473 .flex_col()
1474 .gap(px(8.0))
1475 .child(Self::zone(
1476 &self.state,
1477 PivotZone::Filters,
1478 "Filters",
1479 content_w,
1480 window,
1481 cx,
1482 ))
1483 .child(Self::zone(
1484 &self.state,
1485 PivotZone::Columns,
1486 "Columns",
1487 content_w,
1488 window,
1489 cx,
1490 ))
1491 .child(Self::zone(
1492 &self.state,
1493 PivotZone::Rows,
1494 "Rows",
1495 content_w,
1496 window,
1497 cx,
1498 ))
1499 .child(Self::zone(
1500 &self.state,
1501 PivotZone::Values,
1502 "Values",
1503 content_w,
1504 window,
1505 cx,
1506 ));
1507
1508 let display = div()
1509 .flex()
1510 .flex_col()
1511 .gap(px(10.0))
1512 .child(options)
1513 .child(tools);
1514
1515 let sidebar = cx.entity().clone();
1516 let is_expanded = |id: &str| self.expanded_sections.iter().any(|section| section == id);
1517 let sections = div()
1518 .flex()
1519 .flex_col()
1520 .border_1()
1521 .border_color(theme.grid_line)
1522 .rounded_lg()
1523 .child(Self::section(
1524 &sidebar,
1525 &theme,
1526 "fields",
1527 "Fields",
1528 None,
1529 is_expanded("fields"),
1530 true,
1531 fields.into_any_element(),
1532 ))
1533 .child(Self::section(
1534 &sidebar,
1535 &theme,
1536 "layout",
1537 "Layout",
1538 save_button,
1539 is_expanded("layout"),
1540 false,
1541 layout.into_any_element(),
1542 ))
1543 .child(Self::section(
1544 &sidebar,
1545 &theme,
1546 "display",
1547 "Display and export",
1548 None,
1549 is_expanded("display"),
1550 false,
1551 display.into_any_element(),
1552 ));
1553
1554 div()
1555 .id("pivot-sidebar")
1556 .h_full()
1557 .flex()
1558 .flex_col()
1559 .gap(px(8.0))
1560 .p(px(8.0))
1561 .bg(theme.menu_bg)
1562 .text_color(theme.menu_fg)
1563 .text_size(px(12.0))
1564 .overflow_y_scroll()
1565 .child(
1566 div()
1567 .text_color(theme.header_fg)
1568 .text_size(px(13.0))
1569 .child("Pivot Controls"),
1570 )
1571 .child(sections)
1572 .children(Self::render_filter_popover(&self.state, cx))
1573 .children(Self::render_format_dialog(&self.state, cx))
1574 }
1575}