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 boxed = div()
723 .w(px(12.0))
724 .h(px(12.0))
725 .border_1()
726 .border_color(theme.grid_line)
727 .bg(theme.menu_bg)
728 .flex()
729 .items_center()
730 .justify_center()
731 .children(checked.then(|| div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator)));
732 div()
733 .flex()
734 .items_center()
735 .gap(px(6.0))
736 .cursor_pointer()
737 .text_size(px(12.0))
738 .text_color(theme.menu_fg)
739 .child(boxed)
740 .child(label)
741 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
742 state_toggle.update(cx, |s, cx| {
743 apply(s);
744 s.recompute();
745 cx.notify();
746 });
747 })
748 .into_any_element()
749 }
750
751 fn text_button(
753 state: &Entity<PivotState>,
754 label: &'static str,
755 apply: impl Fn(&mut PivotState, &mut App) + 'static,
756 cx: &App,
757 ) -> gpui::AnyElement {
758 let theme = state.read(cx).theme.clone();
759 let state_btn = state.clone();
760 div()
761 .px(px(6.0))
762 .py(px(2.0))
763 .rounded(px(3.0))
764 .border_1()
765 .border_color(theme.grid_line)
766 .bg(theme.pivot_drop_zone_bg)
767 .text_color(theme.menu_fg)
768 .text_size(px(11.0))
769 .cursor_pointer()
770 .child(label)
771 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
772 state_btn.update(cx, |s, cx| {
773 apply(s, cx);
774 cx.notify();
775 });
776 })
777 .into_any_element()
778 }
779
780 fn render_filter_popover(
782 state: &Entity<PivotState>,
783 cx: &App,
784 ) -> Option<impl IntoElement + use<>> {
785 let s = state.read(cx);
786 let popover = s.filter_popover()?.clone();
787 let theme = s.theme.clone();
788 let field_name = s
789 .source_columns()
790 .get(popover.field)
791 .map(|c| c.name.clone())
792 .unwrap_or_default();
793
794 let checkbox = |checked: bool| {
795 let mut b = div()
796 .w(px(12.0))
797 .h(px(12.0))
798 .border_1()
799 .border_color(theme.grid_line)
800 .bg(theme.menu_bg)
801 .flex()
802 .items_center()
803 .justify_center();
804 if checked {
805 b = b.child(div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator));
806 }
807 b
808 };
809
810 let state_all = state.clone();
811 let select_all = div()
812 .h(px(22.0))
813 .flex()
814 .items_center()
815 .gap(px(6.0))
816 .cursor_pointer()
817 .child(checkbox(popover.all_checked()))
818 .child("(Select All)")
819 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
820 state_all.update(cx, |s, cx| {
821 s.toggle_filter_popover_select_all();
822 cx.notify();
823 });
824 });
825
826 let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
827 for (i, row) in popover
828 .rows
829 .iter()
830 .enumerate()
831 .take(FILTER_POPOVER_MAX_ROWS)
832 {
833 let state_val = state.clone();
834 value_rows.push(
835 div()
836 .h(px(20.0))
837 .flex()
838 .items_center()
839 .gap(px(6.0))
840 .cursor_pointer()
841 .child(checkbox(row.checked))
842 .child(div().text_color(theme.menu_fg).child(row.label.clone()))
843 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
844 state_val.update(cx, |s, cx| {
845 s.toggle_filter_popover_value(i);
846 cx.notify();
847 });
848 })
849 .into_any_element(),
850 );
851 }
852 let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
853
854 let state_clear = state.clone();
855 let state_close = state.clone();
856 let clear_field = popover.field;
857 let buttons = div()
858 .flex()
859 .gap(px(6.0))
860 .child(
861 div()
862 .flex_1()
863 .h(px(24.0))
864 .flex()
865 .items_center()
866 .justify_center()
867 .border_1()
868 .border_color(theme.grid_line)
869 .bg(theme.menu_hover_bg)
870 .cursor_pointer()
871 .child("Clear")
872 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
873 state_clear.update(cx, |s, cx| {
874 s.clear_filter(clear_field);
875 cx.notify();
876 });
877 }),
878 )
879 .child(
880 div()
881 .flex_1()
882 .h(px(24.0))
883 .flex()
884 .items_center()
885 .justify_center()
886 .border_1()
887 .border_color(theme.grid_line)
888 .bg(theme.menu_hover_bg)
889 .cursor_pointer()
890 .child("Close")
891 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
892 state_close.update(cx, |s, cx| {
893 s.close_filter_popover();
894 cx.notify();
895 });
896 }),
897 );
898
899 let body = div()
900 .flex()
901 .flex_col()
902 .w(px(240.0))
903 .p(px(8.0))
904 .gap(px(6.0))
905 .bg(theme.menu_bg)
906 .border_1()
907 .border_color(theme.grid_line)
908 .text_color(theme.menu_fg)
909 .text_size(px(12.0))
910 .child(
911 div()
912 .text_color(theme.muted_text)
913 .text_size(px(11.0))
914 .child(format!("Filter: {field_name}")),
915 )
916 .child(select_all)
917 .child(
918 div()
919 .id("pivot-filter-values")
920 .flex()
921 .flex_col()
922 .max_h(px(200.0))
923 .overflow_y_scroll()
924 .children(value_rows)
925 .children(truncated.then(|| {
926 div()
927 .text_color(theme.muted_text)
928 .text_size(px(11.0))
929 .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
930 })),
931 )
932 .child(buttons);
933
934 let state_backdrop = state.clone();
935 let overlay = deferred(
936 anchored()
937 .anchor(Corner::TopLeft)
938 .position(point(popover.anchor.x, popover.anchor.y))
939 .child(div().occlude().child(body).on_mouse_down_out(
940 move |_e: &MouseDownEvent, _window, cx| {
941 state_backdrop.update(cx, |s, cx| {
942 if s.filter_popover().is_some() {
943 s.close_filter_popover();
944 cx.notify();
945 }
946 });
947 },
948 )),
949 )
950 .with_priority(POPOVER_PRIORITY);
951 Some(overlay)
952 }
953
954 #[allow(clippy::too_many_lines)]
957 fn render_format_dialog(
958 state: &Entity<PivotState>,
959 cx: &App,
960 ) -> Option<impl IntoElement + use<>> {
961 use crate::config::{NumberFormat, TextAlignment};
962
963 let s = state.read(cx);
964 let dialog = *s.format_dialog()?;
965 let fmt = s.format_dialog_format()?;
966 let theme = s.theme.clone();
967 let field_name = s
968 .source_columns()
969 .get(dialog.field)
970 .map(|c| c.name.clone())
971 .unwrap_or_default();
972
973 let c_bg = theme.menu_bg;
974 let c_line = theme.grid_line;
975 let c_fg = theme.menu_fg;
976 let c_muted = theme.muted_text;
977 let c_accent = theme.sort_indicator;
978 let c_hover = theme.menu_hover_bg;
979
980 let checkbox = move |checked: bool| {
981 let mut b = div()
982 .w(px(12.0))
983 .h(px(12.0))
984 .border_1()
985 .border_color(c_line)
986 .bg(c_bg)
987 .flex()
988 .items_center()
989 .justify_center();
990 if checked {
991 b = b.child(div().w(px(6.0)).h(px(6.0)).bg(c_accent));
992 }
993 b
994 };
995
996 let check_row = |label: &'static str, checked: bool, apply: fn(&mut NumberFormat)| {
997 let st = state.clone();
998 div()
999 .h(px(22.0))
1000 .flex()
1001 .items_center()
1002 .gap(px(6.0))
1003 .cursor_pointer()
1004 .child(checkbox(checked))
1005 .child(label)
1006 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1007 st.update(cx, |s, cx| {
1008 s.update_format_dialog(apply);
1009 cx.notify();
1010 });
1011 })
1012 };
1013
1014 let neg_btn = |label: &'static str, parens: bool| {
1016 let st = state.clone();
1017 let selected = fmt.negative_parentheses == parens;
1018 div()
1019 .flex_1()
1020 .h(px(22.0))
1021 .flex()
1022 .items_center()
1023 .justify_center()
1024 .border_1()
1025 .border_color(c_line)
1026 .bg(if selected { c_accent } else { c_hover })
1027 .cursor_pointer()
1028 .child(label)
1029 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1030 st.update(cx, |s, cx| {
1031 s.update_format_dialog(|f| f.negative_parentheses = parens);
1032 cx.notify();
1033 });
1034 })
1035 };
1036
1037 let dec_btn = |label: &'static str, apply: fn(&mut NumberFormat)| {
1038 let st = state.clone();
1039 div()
1040 .w(px(20.0))
1041 .h(px(20.0))
1042 .flex()
1043 .items_center()
1044 .justify_center()
1045 .border_1()
1046 .border_color(c_line)
1047 .bg(c_hover)
1048 .cursor_pointer()
1049 .child(label)
1050 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1051 st.update(cx, |s, cx| {
1052 s.update_format_dialog(apply);
1053 cx.notify();
1054 });
1055 })
1056 };
1057
1058 let align_btn = |label: &'static str, value: TextAlignment| {
1059 let st = state.clone();
1060 let selected = fmt.alignment == value;
1061 div()
1062 .flex_1()
1063 .h(px(22.0))
1064 .flex()
1065 .items_center()
1066 .justify_center()
1067 .border_1()
1068 .border_color(c_line)
1069 .bg(if selected { c_accent } else { c_hover })
1070 .cursor_pointer()
1071 .child(label)
1072 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1073 st.update(cx, |s, cx| {
1074 s.update_format_dialog(move |f| f.alignment = value);
1075 cx.notify();
1076 });
1077 })
1078 };
1079
1080 let st_reset = state.clone();
1081 let st_close = state.clone();
1082 let buttons = div()
1083 .flex()
1084 .gap(px(6.0))
1085 .child(
1086 div()
1087 .flex_1()
1088 .h(px(24.0))
1089 .flex()
1090 .items_center()
1091 .justify_center()
1092 .border_1()
1093 .border_color(c_line)
1094 .bg(c_hover)
1095 .cursor_pointer()
1096 .child("Reset")
1097 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1098 st_reset.update(cx, |s, cx| {
1099 s.reset_format_dialog();
1100 cx.notify();
1101 });
1102 }),
1103 )
1104 .child(
1105 div()
1106 .flex_1()
1107 .h(px(24.0))
1108 .flex()
1109 .items_center()
1110 .justify_center()
1111 .border_1()
1112 .border_color(c_line)
1113 .bg(c_hover)
1114 .cursor_pointer()
1115 .child("Close")
1116 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1117 st_close.update(cx, |s, cx| {
1118 s.close_format_dialog();
1119 cx.notify();
1120 });
1121 }),
1122 );
1123
1124 let body = div()
1125 .flex()
1126 .flex_col()
1127 .w(px(230.0))
1128 .p(px(8.0))
1129 .gap(px(6.0))
1130 .bg(c_bg)
1131 .border_1()
1132 .border_color(c_line)
1133 .text_color(c_fg)
1134 .text_size(px(12.0))
1135 .child(
1136 div()
1137 .text_color(c_muted)
1138 .text_size(px(11.0))
1139 .child(format!("Format: {field_name}")),
1140 )
1141 .child(check_row(
1142 "Negative numbers in red",
1143 fmt.show_negative_red,
1144 |f| f.show_negative_red = !f.show_negative_red,
1145 ))
1146 .child(check_row(
1147 "Thousands separator",
1148 fmt.thousands_separator,
1149 |f| f.thousands_separator = !f.thousands_separator,
1150 ))
1151 .child(
1152 div()
1153 .text_color(c_muted)
1154 .text_size(px(11.0))
1155 .child("Negatives"),
1156 )
1157 .child(
1158 div()
1159 .flex()
1160 .gap(px(6.0))
1161 .child(neg_btn("-1,234", false))
1162 .child(neg_btn("(1,234)", true)),
1163 )
1164 .child(
1165 div()
1166 .h(px(22.0))
1167 .flex()
1168 .items_center()
1169 .gap(px(6.0))
1170 .child(
1171 div()
1172 .text_color(c_muted)
1173 .text_size(px(11.0))
1174 .child("Decimals"),
1175 )
1176 .child(dec_btn("-", |f| f.decimals = f.decimals.saturating_sub(1)))
1177 .child(
1178 div()
1179 .w(px(18.0))
1180 .flex()
1181 .justify_center()
1182 .child(fmt.decimals.to_string()),
1183 )
1184 .child(dec_btn("+", |f| f.decimals = (f.decimals + 1).min(8))),
1185 )
1186 .child(
1187 div()
1188 .text_color(c_muted)
1189 .text_size(px(11.0))
1190 .child("Alignment"),
1191 )
1192 .child(
1193 div()
1194 .flex()
1195 .gap(px(6.0))
1196 .child(align_btn("Left", TextAlignment::Left))
1197 .child(align_btn("Center", TextAlignment::Center))
1198 .child(align_btn("Right", TextAlignment::Right)),
1199 )
1200 .child(buttons);
1201
1202 let state_backdrop = state.clone();
1203 let overlay = deferred(
1204 anchored()
1205 .anchor(Corner::TopLeft)
1206 .position(point(dialog.anchor.x, dialog.anchor.y))
1207 .child(div().occlude().child(body).on_mouse_down_out(
1208 move |_e: &MouseDownEvent, _window, cx| {
1209 state_backdrop.update(cx, |s, cx| {
1210 if s.format_dialog().is_some() {
1211 s.close_format_dialog();
1212 cx.notify();
1213 }
1214 });
1215 },
1216 )),
1217 )
1218 .with_priority(POPOVER_PRIORITY);
1219 Some(overlay)
1220 }
1221}
1222
1223impl Render for PivotSidebar {
1224 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1225 let (theme, columns, config, sidebar_width, save_handler) = {
1226 let s = self.state.read(cx);
1227 (
1228 s.theme.clone(),
1229 s.source_columns().to_vec(),
1230 s.config.clone(),
1231 s.sidebar_width,
1232 s.save_config_handler.clone(),
1233 )
1234 };
1235 let content_w = sidebar_width - SIDEBAR_CONTENT_CHROME;
1236
1237 let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
1239 for (i, col) in columns.iter().enumerate() {
1240 let badge = match config.zone_of(i) {
1241 Some(PivotZone::Rows) => Some("R"),
1242 Some(PivotZone::Columns) => Some("C"),
1243 Some(PivotZone::Values) => Some("V"),
1244 Some(PivotZone::Filters) => Some("F"),
1245 None => None,
1246 };
1247 field_chips.push(Self::chip(
1248 &self.state,
1249 ("pivot-source-field", i),
1250 i,
1251 col.name.clone(),
1252 None,
1253 false,
1254 badge,
1255 content_w - CHIP_CHROME,
1256 window,
1257 cx,
1258 ));
1259 }
1260
1261 let options = div()
1262 .flex()
1263 .flex_col()
1264 .gap(px(4.0))
1265 .child(
1266 div()
1267 .text_color(theme.muted_text)
1268 .text_size(px(11.0))
1269 .child("Totals"),
1270 )
1271 .child(Self::option_row(
1272 &self.state,
1273 "Row subtotals",
1274 config.show_row_subtotals,
1275 |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
1276 cx,
1277 ))
1278 .child(Self::option_row(
1279 &self.state,
1280 "Column subtotal columns",
1281 config.show_column_subtotals,
1282 |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
1283 cx,
1284 ))
1285 .child(Self::option_row(
1286 &self.state,
1287 "Grand total row",
1288 config.show_row_grand_total,
1289 |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
1290 cx,
1291 ))
1292 .child(Self::option_row(
1293 &self.state,
1294 "Grand total column",
1295 config.show_column_grand_total,
1296 |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
1297 cx,
1298 ));
1299
1300 let tools = div()
1301 .flex()
1302 .flex_col()
1303 .gap(px(4.0))
1304 .child(
1305 div()
1306 .text_color(theme.muted_text)
1307 .text_size(px(11.0))
1308 .child("Groups"),
1309 )
1310 .child(
1311 div()
1312 .flex()
1313 .gap(px(4.0))
1314 .child(Self::text_button(
1315 &self.state,
1316 "Expand rows",
1317 |s, _| s.expand_all_rows(),
1318 cx,
1319 ))
1320 .child(Self::text_button(
1321 &self.state,
1322 "Collapse rows",
1323 |s, _| s.collapse_all_rows(),
1324 cx,
1325 )),
1326 )
1327 .child(
1328 div()
1329 .flex()
1330 .gap(px(4.0))
1331 .child(Self::text_button(
1332 &self.state,
1333 "Expand cols",
1334 |s, _| s.expand_all_cols(),
1335 cx,
1336 ))
1337 .child(Self::text_button(
1338 &self.state,
1339 "Collapse cols",
1340 |s, _| s.collapse_all_cols(),
1341 cx,
1342 )),
1343 )
1344 .child(
1345 div()
1346 .text_color(theme.muted_text)
1347 .text_size(px(11.0))
1348 .child("Export"),
1349 )
1350 .child(Self::text_button(
1351 &self.state,
1352 "Copy pivot as CSV",
1353 |s, cx| {
1354 let csv = s.to_csv();
1355 if !csv.is_empty() {
1356 cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
1357 }
1358 },
1359 cx,
1360 ));
1361
1362 let fields = div()
1363 .flex()
1364 .flex_col()
1365 .gap(px(8.0))
1366 .child(
1367 div()
1368 .text_color(theme.muted_text)
1369 .text_size(px(11.0))
1370 .child("Drag a field into a layout zone"),
1371 )
1372 .child(
1373 div()
1374 .id("pivot-field-list")
1375 .flex()
1376 .flex_col()
1377 .gap(px(4.0))
1378 .max_h(px(220.0))
1379 .overflow_y_scroll()
1380 .children(field_chips),
1381 );
1382
1383 let save_button = save_handler.map(|handler| {
1387 let state_save = self.state.clone();
1388 let icon_color = theme.muted_text;
1389 let hover_bg = theme.pivot_drop_zone_active_bg;
1390 let tip_bg = theme.menu_bg;
1391 let tip_fg = theme.menu_fg;
1392 let tip_border = theme.grid_line;
1393 div()
1394 .id("pivot-save-config")
1395 .p(px(2.0))
1396 .rounded(px(3.0))
1397 .cursor_pointer()
1398 .hover(move |style| style.bg(hover_bg))
1399 .child(disk_icon(icon_color))
1400 .tooltip(move |_window, cx| {
1401 cx.new(|_| ChipTooltip {
1402 label: "Save configuration".into(),
1403 bg: tip_bg,
1404 fg: tip_fg,
1405 border: tip_border,
1406 })
1407 .into()
1408 })
1409 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1410 cx.stop_propagation();
1411 let config = state_save.read(cx).config.clone();
1412 handler(&config, cx);
1413 })
1414 .on_mouse_up(MouseButton::Left, |_e: &MouseUpEvent, _w, cx| {
1417 cx.stop_propagation();
1418 })
1419 .into_any_element()
1420 });
1421
1422 let layout = div()
1423 .flex()
1424 .flex_col()
1425 .gap(px(8.0))
1426 .child(Self::zone(
1427 &self.state,
1428 PivotZone::Filters,
1429 "Filters",
1430 content_w,
1431 window,
1432 cx,
1433 ))
1434 .child(Self::zone(
1435 &self.state,
1436 PivotZone::Columns,
1437 "Columns",
1438 content_w,
1439 window,
1440 cx,
1441 ))
1442 .child(Self::zone(
1443 &self.state,
1444 PivotZone::Rows,
1445 "Rows",
1446 content_w,
1447 window,
1448 cx,
1449 ))
1450 .child(Self::zone(
1451 &self.state,
1452 PivotZone::Values,
1453 "Values",
1454 content_w,
1455 window,
1456 cx,
1457 ));
1458
1459 let display = div()
1460 .flex()
1461 .flex_col()
1462 .gap(px(10.0))
1463 .child(options)
1464 .child(tools);
1465
1466 let sidebar = cx.entity().clone();
1467 let is_expanded = |id: &str| self.expanded_sections.iter().any(|section| section == id);
1468 let sections = div()
1469 .flex()
1470 .flex_col()
1471 .border_1()
1472 .border_color(theme.grid_line)
1473 .rounded_lg()
1474 .child(Self::section(
1475 &sidebar,
1476 &theme,
1477 "fields",
1478 "Fields",
1479 None,
1480 is_expanded("fields"),
1481 true,
1482 fields.into_any_element(),
1483 ))
1484 .child(Self::section(
1485 &sidebar,
1486 &theme,
1487 "layout",
1488 "Layout",
1489 save_button,
1490 is_expanded("layout"),
1491 false,
1492 layout.into_any_element(),
1493 ))
1494 .child(Self::section(
1495 &sidebar,
1496 &theme,
1497 "display",
1498 "Display and export",
1499 None,
1500 is_expanded("display"),
1501 false,
1502 display.into_any_element(),
1503 ));
1504
1505 div()
1506 .id("pivot-sidebar")
1507 .h_full()
1508 .flex()
1509 .flex_col()
1510 .gap(px(8.0))
1511 .p(px(8.0))
1512 .bg(theme.menu_bg)
1513 .text_color(theme.menu_fg)
1514 .text_size(px(12.0))
1515 .overflow_y_scroll()
1516 .child(
1517 div()
1518 .text_color(theme.header_fg)
1519 .text_size(px(13.0))
1520 .child("Pivot Controls"),
1521 )
1522 .child(sections)
1523 .children(Self::render_filter_popover(&self.state, cx))
1524 .children(Self::render_format_dialog(&self.state, cx))
1525 }
1526}