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