1use crate::config::GridConfig;
7use crate::data::GridData;
8use crate::filter::{ColumnFilter, FilterPredicate};
9use crate::grid::context_menu::{
10 ContextMenuProvider, ContextMenuProviderHandle, PendingCustomContextMenuAction,
11};
12use crate::grid::paint::{paint_grid, paint_status_bar, PaintData, StatusBarData};
13use crate::grid::state::state_inner;
14use crate::grid::state::{FilterInput, GridState, EDGE_SCROLL_TICK_MS};
15use crate::grid::theme::{GridTheme, GridThemePair};
16use crate::grid::{menu, HitResult, MenuItem, SortDirection};
17use crate::pivot::config::PivotConfig;
18use crate::pivot::context_menu::{PivotContextMenuProvider, PivotContextMenuProviderHandle};
19use crate::pivot::sidebar::PivotSidebar;
20use crate::pivot::state::{
21 PivotSaveConfigHandler, PivotState, DEFAULT_PIVOT_COLUMN_WIDTH, DEFAULT_PIVOT_ROW_HEIGHT,
22 DEFAULT_PIVOT_SIDEBAR_WIDTH,
23};
24use crate::pivot::widget::PivotGrid;
25
26use gpui::prelude::FluentBuilder;
27use gpui::{
28 anchored, canvas, deferred, div, point, pulsating_between, px, relative, Animation,
29 AnimationExt, App, AppContext, Context, Corner, Div, Entity, FocusHandle, Focusable,
30 InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
31 MouseUpEvent, ParentElement, Render, ScrollWheelEvent, StatefulInteractiveElement, Styled,
32 Window,
33};
34use gpui_component::resizable::{h_resizable, resizable_panel, ResizableState};
35use gpui_component::{Icon, IconName};
36use std::sync::Arc;
37
38const CONTEXT_MENU_PRIORITY: usize = 1_000_000;
45const MIN_PIVOT_SIDEBAR_WIDTH: f32 = 180.0;
46const MAX_PIVOT_SIDEBAR_WIDTH: f32 = 480.0;
47
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum GridTab {
51 #[default]
53 Grid,
54 Pivot,
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60pub enum PivotSidebarPosition {
61 #[default]
63 Left,
64 Right,
66}
67
68pub(crate) struct PivotParts {
70 pub(crate) state: Entity<PivotState>,
71 grid: Entity<PivotGrid>,
72 sidebar: Entity<PivotSidebar>,
73}
74
75pub struct SqllyDataTable {
77 pub state: Entity<GridState>,
78 pub(crate) pivot: Option<PivotParts>,
81 active_tab: GridTab,
83 pivot_locked: bool,
86 pivot_status: Option<String>,
87 pivot_sidebar_position: PivotSidebarPosition,
88 pivot_sidebar_collapsed: bool,
89 pivot_sidebar_width: f32,
90 pivot_sidebar_resize: Option<Entity<ResizableState>>,
95 pivot_sidebar_width_dirty: bool,
99 drill_filter: Option<String>,
105 follow_system_appearance: bool,
109 appearance_subscription: Option<gpui::Subscription>,
113}
114
115impl SqllyDataTable {
116 #[must_use]
118 pub fn new(state: Entity<GridState>) -> Self {
119 Self {
120 state,
121 pivot: None,
122 active_tab: GridTab::Grid,
123 pivot_locked: false,
124 pivot_status: None,
125 pivot_sidebar_position: PivotSidebarPosition::Left,
126 pivot_sidebar_collapsed: false,
127 pivot_sidebar_width: DEFAULT_PIVOT_SIDEBAR_WIDTH,
128 pivot_sidebar_resize: None,
129 pivot_sidebar_width_dirty: false,
130 drill_filter: None,
131 follow_system_appearance: true,
132 appearance_subscription: None,
133 }
134 }
135
136 #[must_use]
138 pub fn builder(data: GridData) -> SqllyDataTableBuilder {
139 SqllyDataTableBuilder {
140 data,
141 config: GridConfig::default(),
142 context_menu_provider: None,
143 theme: None,
144 theme_family: None,
145 debug_bar: false,
146 grouped_column: None,
147 pivot: None,
148 pivot_context_menu_provider: None,
149 pivot_save_config_handler: None,
150 pivot_sidebar_position: PivotSidebarPosition::Left,
151 pivot_sidebar_collapsed: false,
152 pivot_sidebar_width: DEFAULT_PIVOT_SIDEBAR_WIDTH,
153 pivot_row_height: DEFAULT_PIVOT_ROW_HEIGHT,
154 pivot_column_width: DEFAULT_PIVOT_COLUMN_WIDTH,
155 }
156 }
157
158 #[must_use]
162 pub fn pivot_state(&self) -> Option<&Entity<PivotState>> {
163 self.pivot.as_ref().map(|p| &p.state)
164 }
165
166 #[must_use]
168 pub fn active_tab(&self) -> GridTab {
169 self.active_tab
170 }
171
172 pub fn set_theme_family(
179 &mut self,
180 family: GridThemePair,
181 window: &Window,
182 cx: &mut Context<Self>,
183 ) {
184 self.follow_system_appearance = true;
185 let appearance = window.appearance();
186 self.state.update(cx, |s, cx| {
187 s.theme = family.for_appearance(appearance);
188 s.theme_family = family;
189 cx.notify();
190 });
191 cx.notify();
192 }
193
194 #[must_use]
196 pub fn pivot_locked(&self) -> bool {
197 self.pivot_locked
198 }
199
200 #[must_use]
202 pub fn pivot_sidebar_position(&self) -> PivotSidebarPosition {
203 self.pivot_sidebar_position
204 }
205
206 pub fn set_pivot_sidebar_position(&mut self, position: PivotSidebarPosition) {
208 if self.pivot_sidebar_position != position {
209 self.pivot_sidebar_resize = None;
212 }
213 self.pivot_sidebar_position = position;
214 }
215
216 #[must_use]
218 pub fn pivot_sidebar_collapsed(&self) -> bool {
219 self.pivot_sidebar_collapsed
220 }
221
222 pub fn set_pivot_sidebar_collapsed(&mut self, collapsed: bool) {
224 self.pivot_sidebar_collapsed = collapsed;
225 }
226
227 #[must_use]
229 pub fn pivot_sidebar_width(&self) -> f32 {
230 self.pivot_sidebar_width
231 }
232
233 pub fn set_pivot_sidebar_width(&mut self, width: f32) {
235 self.pivot_sidebar_width = width.clamp(MIN_PIVOT_SIDEBAR_WIDTH, MAX_PIVOT_SIDEBAR_WIDTH);
236 self.pivot_sidebar_width_dirty = true;
237 }
238
239 #[must_use]
243 pub fn drill_filter(&self) -> Option<&str> {
244 self.drill_filter.as_deref()
245 }
246
247 pub fn clear_drill_filter(&mut self, cx: &mut Context<Self>) {
250 if self.drill_filter.take().is_none() {
251 return;
252 }
253 self.state.update(cx, |g, cx| {
254 for filter in &mut g.filters {
255 *filter = ColumnFilter::default();
256 }
257 g.recompute();
258 cx.notify();
259 });
260 cx.notify();
261 }
262
263 pub fn set_pivot_locked(&mut self, locked: bool, status: Option<String>) {
268 self.pivot_locked = locked;
269 self.pivot_status = locked.then_some(status).flatten();
270 if locked {
271 self.active_tab = GridTab::Grid;
272 }
273 }
274
275 pub fn set_active_tab(&mut self, tab: GridTab, cx: &mut App) {
280 if tab == GridTab::Pivot {
281 if self.pivot.is_none() || self.pivot_locked {
282 return;
283 }
284 self.sync_pivot_source(cx);
285 }
286 self.active_tab = tab;
287 }
288
289 pub fn enable_pivot(&mut self, config: PivotConfig, cx: &mut App) {
293 if let Some(parts) = &self.pivot {
294 parts.state.update(cx, |s, cx| {
295 s.config = config;
296 s.recompute();
297 cx.notify();
298 });
299 return;
300 }
301 self.pivot = Some(build_pivot_parts(
302 &self.state,
303 config,
304 None,
305 None,
306 DEFAULT_PIVOT_ROW_HEIGHT,
307 DEFAULT_PIVOT_COLUMN_WIDTH,
308 cx,
309 ));
310 }
311
312 pub fn disable_pivot(&mut self) {
314 self.pivot = None;
315 self.active_tab = GridTab::Grid;
316 }
317
318 pub fn set_pivot_context_menu_provider(
323 &mut self,
324 provider: impl PivotContextMenuProvider + 'static,
325 cx: &mut App,
326 ) {
327 if let Some(parts) = &self.pivot {
328 parts.state.update(cx, |s, _cx| {
329 s.set_context_menu_provider(provider);
330 });
331 }
332 }
333
334 pub fn set_pivot_save_config(
341 &mut self,
342 handler: impl Fn(&PivotConfig, &mut App) + 'static,
343 cx: &mut App,
344 ) {
345 if let Some(parts) = &self.pivot {
346 parts.state.update(cx, |s, cx| {
347 s.on_save_config(handler);
348 cx.notify();
349 });
350 }
351 }
352
353 pub fn clear_pivot_save_config(&mut self, cx: &mut App) {
356 if let Some(parts) = &self.pivot {
357 parts.state.update(cx, |s, cx| {
358 s.clear_save_config_handler();
359 cx.notify();
360 });
361 }
362 }
363
364 fn sync_pivot_source(&self, cx: &mut App) {
367 let Some(parts) = &self.pivot else {
368 return;
369 };
370 let (columns, rows) = {
371 let s = self.state.read(cx);
372 (s.data.columns.clone(), Arc::clone(&s.data_rows))
373 };
374 parts.state.update(cx, |ps, cx| {
375 if ps.source_differs(&rows) {
376 ps.set_source(columns, rows);
377 cx.notify();
378 }
379 });
380 }
381}
382
383fn build_pivot_parts(
385 grid_state: &Entity<GridState>,
386 config: PivotConfig,
387 menu_provider: Option<PivotContextMenuProviderHandle>,
388 save_config_handler: Option<PivotSaveConfigHandler>,
389 row_height: f32,
390 column_width: f32,
391 cx: &mut App,
392) -> PivotParts {
393 let (columns, rows, formats, key_bindings, theme, animations) = {
394 let s = grid_state.read(cx);
395 (
396 s.data.columns.clone(),
397 Arc::clone(&s.data_rows),
398 s.resolved_formats.as_ref().clone(),
399 s.config.key_bindings.clone(),
400 s.theme.clone(),
401 s.config.animations,
402 )
403 };
404 let focus = cx.focus_handle();
405 let state = cx.new(|_| {
406 let mut ps = PivotState::new(columns, rows, formats, config, key_bindings, focus);
407 ps.theme = theme;
408 ps.animations = animations;
409 ps.context_menu_provider = menu_provider;
410 ps.save_config_handler = save_config_handler;
411 ps.set_row_height(row_height);
412 ps.set_column_width(column_width);
413 ps
414 });
415 let grid = cx.new(|_| PivotGrid::new(state.clone()));
416 let sidebar = cx.new(|_| PivotSidebar::new(state.clone()));
417 PivotParts {
418 state,
419 grid,
420 sidebar,
421 }
422}
423
424pub struct SqllyDataTableBuilder {
426 data: GridData,
427 config: GridConfig,
428 context_menu_provider: Option<ContextMenuProviderHandle>,
429 theme: Option<GridTheme>,
430 theme_family: Option<GridThemePair>,
431 debug_bar: bool,
432 grouped_column: Option<usize>,
433 pivot: Option<PivotConfig>,
434 pivot_context_menu_provider: Option<PivotContextMenuProviderHandle>,
435 pivot_save_config_handler: Option<PivotSaveConfigHandler>,
436 pivot_sidebar_position: PivotSidebarPosition,
437 pivot_sidebar_collapsed: bool,
438 pivot_sidebar_width: f32,
439 pivot_row_height: f32,
440 pivot_column_width: f32,
441}
442
443impl SqllyDataTableBuilder {
444 #[must_use]
446 pub fn config(mut self, config: GridConfig) -> Self {
447 self.config = config;
448 self
449 }
450
451 #[must_use]
454 pub fn theme(mut self, theme: GridTheme) -> Self {
455 self.theme = Some(theme);
456 self
457 }
458
459 #[must_use]
466 pub fn theme_family(mut self, family: GridThemePair) -> Self {
467 self.theme_family = Some(family);
468 self
469 }
470
471 #[must_use]
478 pub fn context_menu_provider(mut self, provider: impl ContextMenuProvider + 'static) -> Self {
479 self.context_menu_provider = Some(ContextMenuProviderHandle::new(provider));
480 self
481 }
482
483 #[must_use]
487 pub fn debug_bar(mut self, enabled: bool) -> Self {
488 self.debug_bar = enabled;
489 self
490 }
491
492 #[must_use]
495 pub fn group_by_column(mut self, column: usize) -> Self {
496 self.grouped_column = Some(column);
497 self
498 }
499
500 #[must_use]
506 pub fn pivot(mut self, config: PivotConfig) -> Self {
507 self.pivot = Some(config);
508 self
509 }
510
511 #[must_use]
513 pub fn pivot_sidebar_position(mut self, position: PivotSidebarPosition) -> Self {
514 self.pivot_sidebar_position = position;
515 self
516 }
517
518 #[must_use]
520 pub fn pivot_sidebar_collapsed(mut self, collapsed: bool) -> Self {
521 self.pivot_sidebar_collapsed = collapsed;
522 self
523 }
524
525 #[must_use]
527 pub fn pivot_sidebar_width(mut self, width: f32) -> Self {
528 self.pivot_sidebar_width = width.clamp(MIN_PIVOT_SIDEBAR_WIDTH, MAX_PIVOT_SIDEBAR_WIDTH);
529 self
530 }
531
532 #[must_use]
534 pub fn pivot_row_height(mut self, height: f32) -> Self {
535 if height.is_finite() {
536 self.pivot_row_height = height.max(crate::pivot::state::MIN_PIVOT_ROW_HEIGHT);
537 }
538 self
539 }
540
541 #[must_use]
543 pub fn pivot_column_width(mut self, width: f32) -> Self {
544 if width.is_finite() {
545 self.pivot_column_width = width.max(crate::pivot::state::MIN_PIVOT_COLUMN_WIDTH);
546 }
547 self
548 }
549
550 #[must_use]
556 pub fn pivot_context_menu_provider(
557 mut self,
558 provider: impl PivotContextMenuProvider + 'static,
559 ) -> Self {
560 self.pivot_context_menu_provider = Some(PivotContextMenuProviderHandle::new(provider));
561 self
562 }
563
564 #[must_use]
572 pub fn pivot_save_config(mut self, handler: impl Fn(&PivotConfig, &mut App) + 'static) -> Self {
573 self.pivot_save_config_handler = Some(std::rc::Rc::new(handler));
574 self
575 }
576
577 pub fn build(self, cx: &mut App) -> SqllyDataTable {
579 let focus = cx.focus_handle();
580 let provider = self.context_menu_provider;
581 let theme_override = self.theme;
582 let theme_family = self.theme_family;
583 let debug_bar = self.debug_bar;
584 let grouped_column = self.grouped_column;
585 let pivot_config = self.pivot;
586 let pivot_sidebar_position = self.pivot_sidebar_position;
587 let pivot_sidebar_collapsed = self.pivot_sidebar_collapsed;
588 let pivot_sidebar_width = self.pivot_sidebar_width;
589 let pivot_row_height = self.pivot_row_height;
590 let pivot_column_width = self.pivot_column_width;
591 let follow_system_appearance = theme_override.is_none();
592 let state = cx.new(|cx| {
593 let mut s = GridState::new(self.data, self.config, focus.clone());
594 s.context_menu_provider = provider;
595 s.debug_bar_enabled = debug_bar;
596 s.set_grouped_column(grouped_column);
597 s.self_weak = Some(cx.weak_entity());
598 if let Some(family) = theme_family {
599 s.theme = family.light.clone();
600 s.theme_family = family;
601 }
602 if let Some(theme) = theme_override {
603 s.theme = theme;
604 }
605 s
606 });
607 let pivot_menu_provider = self.pivot_context_menu_provider;
608 let pivot_save_config_handler = self.pivot_save_config_handler;
609 let pivot = pivot_config.map(|cfg| {
610 build_pivot_parts(
611 &state,
612 cfg,
613 pivot_menu_provider,
614 pivot_save_config_handler,
615 pivot_row_height,
616 pivot_column_width,
617 cx,
618 )
619 });
620 SqllyDataTable {
621 state,
622 pivot,
623 active_tab: GridTab::Grid,
624 pivot_locked: false,
625 pivot_status: None,
626 pivot_sidebar_position,
627 pivot_sidebar_collapsed,
628 pivot_sidebar_width,
629 pivot_sidebar_resize: None,
630 pivot_sidebar_width_dirty: false,
631 drill_filter: None,
632 follow_system_appearance,
633 appearance_subscription: None,
634 }
635 }
636}
637
638impl Focusable for SqllyDataTable {
639 fn focus_handle(&self, cx: &App) -> FocusHandle {
640 self.state.read(cx).focus_handle.clone()
641 }
642}
643
644impl Render for SqllyDataTable {
645 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
646 if self.follow_system_appearance {
658 let appearance = window.appearance();
659 let resolved = self.state.read(cx).theme_family.for_appearance(appearance);
660 if self.state.read(cx).theme != resolved {
661 self.state.update(cx, |s, cx| {
662 s.theme = resolved;
663 cx.notify();
664 });
665 }
666 if self.appearance_subscription.is_none() {
667 let state_appearance = self.state.clone();
668 self.appearance_subscription =
669 Some(window.observe_window_appearance(move |window, cx| {
670 let appearance = window.appearance();
671 state_appearance.update(cx, |s, cx| {
672 let resolved = s.theme_family.for_appearance(appearance);
673 if s.theme != resolved {
674 s.theme = resolved;
675 cx.notify();
676 }
677 });
678 }));
679 }
680 }
681
682 if let Some(parts) = &self.pivot {
687 let grid_theme = self.state.read(cx).theme.clone();
688 let sidebar_width = self.pivot_sidebar_width;
689 parts.state.update(cx, |s, cx| {
690 let mut dirty = false;
691 if s.theme != grid_theme {
692 s.theme = grid_theme;
693 dirty = true;
694 }
695 if (s.sidebar_width - sidebar_width).abs() > 0.5 {
696 s.sidebar_width = sidebar_width;
697 dirty = true;
698 }
699 if dirty {
700 cx.notify();
701 }
702 });
703 }
704
705 if let Some(parts) = &self.pivot {
710 let drill = parts.state.update(cx, |s, _cx| s.take_pending_drill_down());
711 if let Some(filter_sets) = drill {
712 let mut applied = false;
713 self.state.update(cx, |g, cx| {
714 if g.window.is_none() {
718 for filter in &mut g.filters {
719 *filter = ColumnFilter::default();
720 }
721 for (field, values) in &filter_sets {
722 if let Some(slot) = g.filters.get_mut(*field) {
723 *slot = ColumnFilter {
724 predicate: FilterPredicate::None,
725 values: Some(values.clone()),
726 };
727 }
728 }
729 g.recompute();
730 applied = true;
731 }
732 cx.notify();
733 });
734 if applied {
735 self.drill_filter =
736 Some(drill_filter_label(&self.state.read(cx).data, &filter_sets));
737 }
738 self.active_tab = GridTab::Grid;
739 let focus = self.state.read(cx).focus_handle.clone();
740 window.focus(&focus);
741 cx.notify();
742 }
743 }
744
745 let grid_view = self.render_grid_view(cx);
746
747 let Some(parts) = &self.pivot else {
748 return div().size_full().child(grid_view);
749 };
750
751 let theme = self.state.read(cx).theme.clone();
752 let tab = |label: String,
753 this_tab: GridTab,
754 active: bool,
755 locked: bool,
756 cx: &mut Context<Self>| {
757 div()
758 .px(px(14.0))
759 .py(px(5.0))
760 .text_size(px(13.0))
761 .when(!locked, |tab| tab.cursor_pointer())
762 .when(locked, |tab| tab.opacity(0.55))
763 .bg(if active { theme.bg } else { theme.header_bg })
764 .text_color(if active {
765 theme.header_fg
766 } else {
767 theme.muted_text
768 })
769 .border_b_2()
770 .border_color(if active {
771 theme.sort_indicator
772 } else {
773 theme.header_bg
774 })
775 .child(label)
776 .when(!locked, |tab| {
777 tab.on_mouse_down(
778 MouseButton::Left,
779 cx.listener(move |this, _e: &MouseDownEvent, window, cx| {
780 if this.active_tab == this_tab {
781 return;
782 }
783 this.set_active_tab(this_tab, cx);
784 match this_tab {
786 GridTab::Grid => {
787 let focus = this.state.read(cx).focus_handle.clone();
788 window.focus(&focus);
789 }
790 GridTab::Pivot => {
791 if let Some(p) = &this.pivot {
792 let focus = p.state.read(cx).focus_handle.clone();
793 window.focus(&focus);
794 }
795 }
796 }
797 cx.notify();
798 }),
799 )
800 })
801 };
802
803 let pivot_label = self
804 .pivot_status
805 .as_deref()
806 .map_or_else(|| "Pivot".to_string(), |status| format!("Pivot {status}"));
807
808 let tab_bar = div()
809 .flex()
810 .flex_row()
811 .flex_none()
812 .bg(theme.header_bg)
813 .border_b_1()
814 .border_color(theme.grid_line)
815 .child(tab(
816 "Grid".to_string(),
817 GridTab::Grid,
818 self.active_tab == GridTab::Grid,
819 false,
820 cx,
821 ))
822 .child(tab(
823 pivot_label,
824 GridTab::Pivot,
825 self.active_tab == GridTab::Pivot,
826 self.pivot_locked,
827 cx,
828 ));
829
830 let content: gpui::AnyElement = match self.active_tab {
831 GridTab::Grid => {
832 if let Some(label) = self.drill_filter.clone() {
836 let table_clear = cx.entity().clone();
837 let hover_bg = theme.menu_hover_bg;
838 let banner = div()
839 .flex()
840 .flex_none()
841 .items_center()
842 .justify_between()
843 .gap(px(8.0))
844 .px(px(10.0))
845 .py(px(4.0))
846 .bg(theme.filter_active_bg)
847 .border_b_1()
848 .border_color(theme.grid_line)
849 .text_size(px(12.0))
850 .text_color(theme.header_fg)
851 .child(
852 div()
853 .flex_1()
854 .min_w(px(0.0))
855 .truncate()
856 .child(format!("Filtered from pivot: {label}")),
857 )
858 .child(
859 div()
860 .id("clear-drill-filter")
861 .flex()
862 .flex_none()
863 .items_center()
864 .gap(px(4.0))
865 .px(px(6.0))
866 .py(px(2.0))
867 .rounded(px(4.0))
868 .cursor_pointer()
869 .hover(move |style| style.bg(hover_bg))
870 .child(Icon::new(IconName::CircleX))
871 .child("Clear filter")
872 .on_mouse_down(MouseButton::Left, move |_event, _window, cx| {
873 table_clear.update(cx, |table, cx| {
874 table.clear_drill_filter(cx);
875 });
876 }),
877 );
878 div()
879 .flex()
880 .flex_col()
881 .size_full()
882 .child(banner)
883 .child(div().flex_1().min_h_0().child(grid_view))
884 .into_any_element()
885 } else {
886 grid_view.into_any_element()
887 }
888 }
889 GridTab::Pivot => {
890 let position = self.pivot_sidebar_position;
891 let collapsed = self.pivot_sidebar_collapsed;
892
893 if collapsed {
894 let rail = pivot_collapsed_rail(&theme, position, cx.entity().clone());
897 let pivot_grid = div().flex_1().min_w_0().child(parts.grid.clone());
898 let pivot_view = div().flex().flex_row().size_full();
899 let pivot_view = match position {
900 PivotSidebarPosition::Left => pivot_view.child(rail).child(pivot_grid),
901 PivotSidebarPosition::Right => pivot_view.child(pivot_grid).child(rail),
902 };
903 pivot_view.into_any_element()
904 } else {
905 if self.pivot_sidebar_width_dirty {
913 self.pivot_sidebar_width_dirty = false;
914 self.pivot_sidebar_resize = None;
915 }
916 let resize_state = match self.pivot_sidebar_resize.clone() {
917 Some(state) => state,
918 None => {
919 let state = cx.new(|_| ResizableState::default());
920 self.pivot_sidebar_resize = Some(state.clone());
921 state
922 }
923 };
924 let sidebar_ix = match position {
925 PivotSidebarPosition::Left => 0,
926 PivotSidebarPosition::Right => 1,
927 };
928
929 let toggle_strip = pivot_toggle_strip(&theme, position, cx.entity().clone());
930 let sidebar_body = div()
931 .flex_1()
932 .min_w_0()
933 .h_full()
934 .child(parts.sidebar.clone());
935 let sidebar_content = div().flex().flex_row().size_full();
936 let sidebar_content = match position {
937 PivotSidebarPosition::Left => {
938 sidebar_content.child(sidebar_body).child(toggle_strip)
939 }
940 PivotSidebarPosition::Right => {
941 sidebar_content.child(toggle_strip).child(sidebar_body)
942 }
943 };
944 let sidebar_panel = resizable_panel()
945 .size(px(self.pivot_sidebar_width))
946 .size_range(px(MIN_PIVOT_SIDEBAR_WIDTH)..px(MAX_PIVOT_SIDEBAR_WIDTH))
947 .child(sidebar_content);
948 let grid_panel = resizable_panel()
949 .child(div().size_full().min_w_0().child(parts.grid.clone()));
950
951 let table_resize = cx.entity().clone();
952 let group = h_resizable("pivot-sidebar-split")
953 .with_state(&resize_state)
954 .on_resize(move |state, _window, cx| {
955 let Some(width) = state.read(cx).sizes().get(sidebar_ix).copied()
956 else {
957 return;
958 };
959 table_resize.update(cx, |table, cx| {
960 table.pivot_sidebar_width = f32::from(width)
964 .clamp(MIN_PIVOT_SIDEBAR_WIDTH, MAX_PIVOT_SIDEBAR_WIDTH);
965 cx.notify();
966 });
967 });
968 let group = match position {
969 PivotSidebarPosition::Left => group.child(sidebar_panel).child(grid_panel),
970 PivotSidebarPosition::Right => group.child(grid_panel).child(sidebar_panel),
971 };
972 group.into_any_element()
973 }
974 }
975 };
976
977 div()
978 .flex()
979 .flex_col()
980 .size_full()
981 .child(tab_bar)
982 .child(div().flex_1().min_h_0().child(content))
983 }
984}
985
986fn drill_filter_label(
990 data: &crate::data::GridData,
991 filter_sets: &[(usize, std::collections::HashSet<String>)],
992) -> String {
993 let mut parts = Vec::with_capacity(filter_sets.len());
994 for (field, values) in filter_sets {
995 let name = data.columns.get(*field).map_or("?", |c| c.name.as_str());
996 let mut sorted: Vec<&str> = values.iter().map(String::as_str).collect();
997 sorted.sort_unstable();
998 let shown = match sorted.len() {
999 0 => continue,
1000 1..=3 => sorted.join(" | "),
1001 n => format!("{} | … ({n} values)", sorted[..2].join(" | ")),
1002 };
1003 parts.push(format!("{name} = {shown}"));
1004 }
1005 parts.join(", ")
1006}
1007
1008fn pivot_collapsed_rail(
1013 theme: &GridTheme,
1014 position: PivotSidebarPosition,
1015 table: Entity<SqllyDataTable>,
1016) -> impl IntoElement {
1017 let icon_name = match position {
1019 PivotSidebarPosition::Left => IconName::PanelLeftOpen,
1020 PivotSidebarPosition::Right => IconName::PanelRightOpen,
1021 };
1022 let label_chars = "Pivot".chars().map({
1023 let fg = theme.muted_text;
1024 move |c| {
1025 div()
1026 .text_color(fg)
1027 .text_size(px(11.0))
1028 .child(c.to_string())
1029 }
1030 });
1031 let hover_bg = theme.menu_hover_bg;
1032 let arrow_glyph = move |fg: gpui::Hsla| {
1033 div()
1034 .text_color(fg)
1035 .text_size(px(13.0))
1036 .child(Icon::new(icon_name.clone()))
1037 };
1038
1039 div()
1040 .id("pivot-sidebar-rail")
1041 .w(px(24.0))
1042 .h_full()
1043 .flex_none()
1044 .flex()
1045 .flex_col()
1046 .items_center()
1047 .justify_center()
1048 .gap(px(4.0))
1049 .bg(theme.menu_bg)
1050 .border_x_1()
1051 .border_color(theme.grid_line)
1052 .cursor_pointer()
1053 .hover(move |style| style.bg(hover_bg))
1054 .child(arrow_glyph(theme.muted_text))
1055 .child(
1056 div()
1057 .flex()
1058 .flex_col()
1059 .items_center()
1060 .gap(px(2.0))
1061 .py_2()
1062 .children(label_chars),
1063 )
1064 .child(arrow_glyph(theme.muted_text))
1065 .on_mouse_down(MouseButton::Left, move |_event, _window, cx| {
1066 table.update(cx, |table, cx| {
1067 table.set_pivot_sidebar_collapsed(false);
1068 cx.notify();
1069 });
1070 })
1071}
1072
1073fn pivot_toggle_strip(
1078 theme: &GridTheme,
1079 position: PivotSidebarPosition,
1080 table: Entity<SqllyDataTable>,
1081) -> impl IntoElement {
1082 let icon_name = match position {
1084 PivotSidebarPosition::Left => IconName::PanelLeftClose,
1085 PivotSidebarPosition::Right => IconName::PanelRightClose,
1086 };
1087 let hover_bg = theme.menu_hover_bg;
1088
1089 div()
1090 .id("pivot-sidebar-toggle")
1091 .w(px(16.0))
1092 .h_full()
1093 .flex_none()
1094 .flex()
1095 .flex_col()
1096 .items_center()
1097 .justify_center()
1098 .bg(theme.header_bg)
1099 .border_x_1()
1100 .border_color(theme.grid_line)
1101 .cursor_pointer()
1102 .hover(move |style| style.bg(hover_bg))
1103 .child(
1104 div()
1105 .text_color(theme.muted_text)
1106 .text_size(px(13.0))
1107 .child(Icon::new(icon_name)),
1108 )
1109 .on_mouse_down(MouseButton::Left, move |_event, _window, cx| {
1110 table.update(cx, |table, cx| {
1111 table.set_pivot_sidebar_collapsed(true);
1112 cx.notify();
1113 });
1114 })
1115}
1116
1117impl SqllyDataTable {
1118 fn render_grid_view(&mut self, cx: &mut Context<Self>) -> Div {
1121 let state_canvas = self.state.clone();
1122 let state_status = self.state.clone();
1123 let state_mouse = self.state.clone();
1124 let state_move = self.state.clone();
1125 let state_up = self.state.clone();
1126 let state_scroll = self.state.clone();
1127 let state_key = self.state.clone();
1128 let state_right = self.state.clone();
1129 let bg = self.state.read(cx).theme.bg;
1130 let focus_handle = self.state.read(cx).focus_handle.clone();
1131 let focus_left = focus_handle.clone();
1132 let focus_right = focus_handle.clone();
1133 let debug_bar = self.state.read(cx).debug_bar_enabled;
1134 let status_h = self.state.read(cx).status_bar_height;
1135
1136 if let Some((action, col)) = self.state.read(cx).pending_action {
1139 self.state.update(cx, |s, cx| {
1140 s.execute_action(action, col, cx);
1141 s.pending_action = None;
1142 });
1143 }
1144
1145 if let Some(pending) = self
1147 .state
1148 .read(cx)
1149 .pending_custom_context_menu_action
1150 .clone()
1151 {
1152 self.state.update(cx, |s, cx| {
1153 s.pending_custom_context_menu_action = None;
1154 s.execute_custom_context_menu_action(pending, cx);
1155 });
1156 }
1157
1158 if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
1166 self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
1167 let state_edge = self.state.clone();
1168 cx.spawn(async move |_weak, cx| {
1169 loop {
1170 cx.background_executor()
1171 .timer(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS))
1172 .await;
1173 let scrolled = cx
1174 .update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()))
1175 .unwrap_or(false);
1176 if scrolled {
1177 let _ = state_edge.update(cx, |_s, cx| cx.notify());
1178 }
1179 let dragging = cx
1180 .update(|cx| state_edge.read(cx).is_dragging)
1181 .unwrap_or(false);
1182 if !dragging {
1183 break;
1184 }
1185 }
1186 let _ =
1187 cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
1188 })
1189 .detach();
1190 }
1191
1192 div()
1193 .flex()
1194 .flex_col()
1195 .size_full()
1196 .relative()
1197 .track_focus(&focus_handle)
1198 .bg(bg)
1199 .child(
1200 canvas(
1201 move |bounds, window, cx| -> PaintData {
1202 let viewport = window.viewport_size();
1203 state_canvas.update(cx, |s, cx| {
1204 let mut dirty = false;
1205 if s.bounds != bounds {
1206 s.bounds = bounds;
1207 s.clamp_scroll_to_bounds();
1208 dirty = true;
1209 }
1210 if s.window_viewport != viewport {
1211 s.window_viewport = viewport;
1212 }
1213 if dirty {
1214 cx.notify();
1215 }
1216 });
1217 let s = state_canvas.read(cx);
1218 let mut data = PaintData::from_state(s);
1219 data.focused = s.focus_handle.is_focused(window);
1220 data
1221 },
1222 move |bounds, data, window, cx| {
1223 paint_grid(&data, window, cx, bounds);
1224 },
1225 )
1226 .flex_1(),
1227 )
1228 .children(debug_bar.then(|| {
1229 canvas(
1230 move |_bounds, _window, cx| -> StatusBarData {
1231 let s = state_status.read(cx);
1232 StatusBarData::from_state(s)
1233 },
1234 move |bounds, data, window, cx| {
1235 paint_status_bar(&data, window, cx, bounds);
1236 },
1237 )
1238 .h(px(status_h))
1239 }))
1240 .children(render_context_menu_overlay(&self.state, cx))
1241 .children(render_filter_panel_overlay(&self.state, cx))
1242 .children(render_busy_overlay(&self.state, cx))
1243 .on_mouse_down(
1244 MouseButton::Left,
1245 move |event: &MouseDownEvent, window, cx| {
1246 window.focus(&focus_left);
1247 state_mouse.update(cx, |s, cx| {
1248 if s.busy.is_some() {
1251 return;
1252 }
1253 let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
1259 if s.context_menu.is_some() || s.filter_panel.is_some() {
1260 s.context_menu = None;
1261 s.filter_panel = None;
1262 }
1263 s.handle_mouse_down_with_modifiers(
1264 rel,
1265 event.modifiers.shift,
1266 event.modifiers.platform,
1267 );
1268 cx.notify();
1269 });
1270 },
1271 )
1272 .on_mouse_down(
1273 MouseButton::Right,
1274 move |event: &MouseDownEvent, window, cx| {
1275 window.focus(&focus_right);
1276 state_right.update(cx, |s, cx| {
1277 if s.busy.is_some() {
1278 return;
1279 }
1280 let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
1281 let hit = s.hit_test(pos);
1282
1283 if s.context_menu_provider.is_none() {
1285 match hit {
1286 HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
1287 s.open_context_menu(col, pos);
1288 }
1289 _ => {
1290 s.context_menu = None;
1291 s.filter_panel = None;
1292 }
1293 }
1294 cx.notify();
1295 return;
1296 }
1297
1298 let Some(target) = s.context_menu_target_from_hit(hit) else {
1300 s.context_menu = None;
1301 s.filter_panel = None;
1302 cx.notify();
1303 return;
1304 };
1305
1306 let effective = s.effective_selection_for_context_target(&target);
1307 if effective != s.selection {
1308 s.selection = effective.clone();
1309 }
1310
1311 let request = s.build_context_menu_request(target, &effective);
1312 let col = request.target.column_index().unwrap_or(0);
1313
1314 let Some(provider) = s.context_menu_provider.clone() else {
1315 return;
1316 };
1317 let public_items = provider.menu_items(&request);
1318 let items = GridState::convert_context_menu_items(public_items);
1319
1320 if items.is_empty() {
1321 s.context_menu = None;
1322 } else {
1323 s.context_menu =
1324 Some(menu::ContextMenu::custom(col, pos, items, request));
1325 }
1326 s.filter_panel = None;
1327 cx.notify();
1328 });
1329 },
1330 )
1331 .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
1332 state_move.update(cx, |s, cx| {
1333 let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
1334 s.handle_mouse_move(rel, event.pressed_button);
1335 cx.notify();
1336 });
1337 })
1338 .on_mouse_up(
1339 MouseButton::Left,
1340 move |_event: &MouseUpEvent, _window, cx| {
1341 state_up.update(cx, |s, cx| {
1342 s.handle_mouse_up();
1343 cx.notify();
1344 });
1345 },
1346 )
1347 .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
1348 state_scroll.update(cx, |s, cx| {
1349 let line_h = px(s.row_height);
1350 let delta = event.delta.pixel_delta(line_h);
1351 let scroll = s.scroll_handle.offset();
1352 let (mx, my) = s.max_scroll();
1353 let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
1354 let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
1355 s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
1356 if s.drag_start.is_some() {
1357 s.handle_scroll_drag();
1358 }
1359 cx.notify();
1360 });
1361 })
1362 .on_key_down(move |event: &KeyDownEvent, _window, cx| {
1363 let ks = &event.keystroke;
1364 if ks.modifiers.platform && ks.key == "q" {
1365 cx.quit();
1366 return;
1367 }
1368 state_key.update(cx, |s, cx| {
1369 let kb = &s.config.key_bindings;
1370 if kb.select_all.matches(ks) {
1371 s.select_all();
1372 } else if kb.copy.matches(ks) {
1373 s.copy_selection(false, cx);
1374 } else if kb.copy_with_headers.matches(ks) {
1375 s.copy_selection(true, cx);
1376 } else if kb.page_up.matches(ks) {
1377 s.page_up();
1378 } else if kb.page_down.matches(ks) {
1379 s.page_down();
1380 } else {
1381 s.handle_key(ks);
1382 }
1383 cx.notify();
1384 });
1385 })
1386 }
1387}
1388
1389fn render_context_menu_overlay(
1401 state: &Entity<GridState>,
1402 cx: &mut Context<SqllyDataTable>,
1403) -> Option<impl IntoElement> {
1404 let s = state.read(cx);
1405 let menu = s.context_menu.clone()?;
1406 let theme = s.theme.clone();
1407 let animations = s.config.animations;
1408 let cw = s.char_width;
1409 let grid_ox = f32::from(s.bounds.origin.x);
1410 let grid_oy = f32::from(s.bounds.origin.y);
1411 let viewport = s.window_viewport;
1412 let vw = f32::from(viewport.width);
1413 let vh = f32::from(viewport.height);
1414
1415 let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
1416 let abs_x = grid_ox + f32::from(resolved.x);
1417 let abs_y = grid_oy + f32::from(resolved.y);
1418 let menu_w = menu.width_for(cw);
1419
1420 let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
1423 let mut selectable_idx = 0usize;
1424 for item in &menu.items {
1425 match item {
1426 MenuItem::Separator => {
1427 rows.push(
1428 div()
1429 .h(px(menu::MENU_ITEM_HEIGHT))
1430 .flex()
1431 .items_center()
1432 .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
1433 .into_any_element(),
1434 );
1435 }
1436 MenuItem::Action(_) | MenuItem::Custom { .. } => {
1437 let this_idx = selectable_idx;
1438 selectable_idx += 1;
1439 let label = item.label().unwrap_or("").to_owned();
1440 let hovered = menu.hovered == Some(this_idx);
1441
1442 let action = match item {
1446 MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
1447 MenuItem::Custom { id, .. } => {
1448 MenuDispatch::Custom(id.clone(), menu.request.clone())
1449 }
1450 MenuItem::Separator => unreachable!(),
1451 };
1452
1453 let state_click = state.clone();
1454 let state_hover = state.clone();
1455 let mut row = div()
1456 .h(px(menu::MENU_ITEM_HEIGHT))
1457 .px(px(menu::MENU_PADDING_X))
1458 .flex()
1459 .items_center()
1460 .text_color(theme.menu_fg)
1461 .text_size(px(menu::MENU_FONT_SIZE))
1462 .child(label)
1463 .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
1464 state_hover.update(cx, |s, cx| {
1465 if let Some(m) = s.context_menu.as_mut() {
1466 if m.hovered != Some(this_idx) {
1467 m.hovered = Some(this_idx);
1468 cx.notify();
1469 }
1470 }
1471 });
1472 })
1473 .on_mouse_down(
1474 MouseButton::Left,
1475 move |_e: &MouseDownEvent, _window, cx| {
1476 state_click.update(cx, |s, cx| {
1477 match &action {
1478 MenuDispatch::Builtin(a, col) => {
1479 s.pending_action = Some((*a, *col));
1480 }
1481 MenuDispatch::Custom(id, request) => {
1482 if let Some(request) = request {
1483 s.pending_custom_context_menu_action =
1484 Some(PendingCustomContextMenuAction {
1485 id: id.clone(),
1486 request: request.clone(),
1487 });
1488 }
1489 }
1490 }
1491 s.context_menu = None;
1492 cx.notify();
1493 });
1494 },
1495 );
1496 if hovered {
1497 row = row.bg(theme.menu_hover_bg);
1498 }
1499 rows.push(row.into_any_element());
1500 }
1501 }
1502 }
1503
1504 let menu_body = div()
1505 .flex()
1506 .flex_col()
1507 .w(px(menu_w))
1508 .py(px(menu::MENU_INNER_PAD))
1509 .bg(theme.menu_bg)
1510 .border_1()
1511 .border_color(theme.grid_line)
1512 .children(rows);
1513
1514 let state_backdrop = state.clone();
1517 let overlay = deferred(
1518 anchored().position(point(px(abs_x), px(abs_y))).child(
1519 div()
1520 .occlude()
1521 .child(crate::grid::motion::pop_in(
1522 menu_body,
1523 "grid-context-menu",
1524 animations,
1525 ))
1526 .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
1527 state_backdrop.update(cx, |s, cx| {
1528 if s.context_menu.is_some() {
1529 s.context_menu = None;
1530 s.filter_panel = None;
1531 cx.notify();
1532 }
1533 });
1534 }),
1535 ),
1536 )
1537 .with_priority(CONTEXT_MENU_PRIORITY);
1538
1539 Some(overlay)
1540}
1541
1542const FILTER_PANEL_WIDTH: f32 = 300.0;
1544const FILTER_PANEL_MAX_ROWS: usize = 200;
1546
1547#[allow(clippy::too_many_lines)]
1552fn render_filter_panel_overlay(
1553 state: &Entity<GridState>,
1554 cx: &mut Context<SqllyDataTable>,
1555) -> Option<impl IntoElement> {
1556 let s = state.read(cx);
1557 let panel = s.filter_panel.clone()?;
1558 let theme = s.theme.clone();
1559 let animations = s.config.animations;
1560 let col = panel.col;
1561 let current_sort = s.sort;
1562 let filter_active = s.filters.get(col).is_some_and(|f| f.is_active());
1563 let grid_ox = f32::from(s.bounds.origin.x);
1564 let grid_oy = f32::from(s.bounds.origin.y);
1565
1566 let abs_x = grid_ox + f32::from(panel.anchor.x);
1571 let abs_y = grid_oy + f32::from(panel.anchor.y);
1572
1573 let c_bg = theme.menu_bg;
1575 let c_line = theme.grid_line;
1576 let c_fg = theme.menu_fg;
1577 let c_accent = theme.sort_indicator;
1578 let c_hover = theme.menu_hover_bg;
1579 let c_muted = theme.muted_text;
1580
1581 let checkbox = {
1582 let theme = theme.clone();
1583 move |checked: bool| crate::grid::checkbox(checked, &theme)
1584 };
1585
1586 let (asc_active, desc_active) = match current_sort {
1588 Some((c, SortDirection::Ascending)) if c == col => (true, false),
1589 Some((c, SortDirection::Descending)) if c == col => (false, true),
1590 _ => (false, false),
1591 };
1592 let st_asc = state.clone();
1593 let st_desc = state.clone();
1594 let sort_row = div()
1595 .flex()
1596 .gap(px(6.0))
1597 .child(
1598 div()
1599 .flex_1()
1600 .h(px(26.0))
1601 .flex()
1602 .items_center()
1603 .justify_center()
1604 .border_1()
1605 .border_color(c_line)
1606 .bg(if asc_active { c_accent } else { c_hover })
1607 .cursor_pointer()
1608 .child("Ascending")
1609 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1610 st_asc.update(cx, |s, cx| {
1611 s.set_panel_sort(SortDirection::Ascending);
1612 cx.notify();
1613 });
1614 }),
1615 )
1616 .child(
1617 div()
1618 .flex_1()
1619 .h(px(26.0))
1620 .flex()
1621 .items_center()
1622 .justify_center()
1623 .border_1()
1624 .border_color(c_line)
1625 .bg(if desc_active { c_accent } else { c_hover })
1626 .cursor_pointer()
1627 .child("Descending")
1628 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1629 st_desc.update(cx, |s, cx| {
1630 s.set_panel_sort(SortDirection::Descending);
1631 cx.notify();
1632 });
1633 }),
1634 );
1635
1636 let st_op_toggle = state.clone();
1638 let op_button = div()
1639 .h(px(26.0))
1640 .px(px(8.0))
1641 .flex()
1642 .items_center()
1643 .border_1()
1644 .border_color(c_line)
1645 .bg(c_bg)
1646 .cursor_pointer()
1647 .child(panel.current_op_label())
1648 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1649 st_op_toggle.update(cx, |s, cx| {
1650 s.toggle_filter_op_menu();
1651 cx.notify();
1652 });
1653 });
1654
1655 let op_menu = panel.op_menu_open.then(|| {
1656 let mut items: Vec<gpui::AnyElement> = Vec::new();
1657 for (i, label) in panel.op_labels().iter().enumerate() {
1658 let selected = i == panel.op_index;
1659 let st_pick = state.clone();
1660 items.push(
1661 div()
1662 .h(px(24.0))
1663 .px(px(8.0))
1664 .flex()
1665 .items_center()
1666 .bg(if selected { c_accent } else { c_bg })
1667 .cursor_pointer()
1668 .child(*label)
1669 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1670 st_pick.update(cx, |s, cx| {
1671 s.set_filter_operator(i);
1672 cx.notify();
1673 });
1674 })
1675 .into_any_element(),
1676 );
1677 }
1678 div()
1679 .flex()
1680 .flex_col()
1681 .border_1()
1682 .border_color(c_line)
1683 .bg(c_bg)
1684 .children(items)
1685 });
1686
1687 let operand_field = |value: &str, focused: bool, placeholder: &str, input: FilterInput| {
1689 let st_focus = state.clone();
1690 let (text, is_placeholder) = if value.is_empty() {
1691 (placeholder.to_owned(), true)
1692 } else {
1693 (value.to_owned(), false)
1694 };
1695 div()
1696 .h(px(26.0))
1697 .px(px(6.0))
1698 .flex()
1699 .items_center()
1700 .gap(px(2.0))
1701 .border_1()
1702 .border_color(if focused { c_accent } else { c_line })
1703 .bg(c_bg)
1704 .cursor_pointer()
1705 .child(
1706 div()
1707 .text_color(if is_placeholder { c_muted } else { c_fg })
1708 .child(text),
1709 )
1710 .children(focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1711 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1712 st_focus.update(cx, |s, cx| {
1713 s.set_filter_focus(input);
1714 cx.notify();
1715 });
1716 })
1717 };
1718
1719 let operand_placeholder = if panel.kind == crate::data::ColumnKind::Date {
1720 "YYYY-MM-DD"
1721 } else if crate::filter::uses_number_ops(panel.kind) {
1722 "value"
1723 } else if panel.op_index == 7 {
1724 "regex"
1726 } else {
1727 "value"
1728 };
1729 let operands = panel.needs_operand().then(|| {
1730 let mut row = div().flex().flex_col().gap(px(4.0)).child(operand_field(
1731 &panel.operand_a.value,
1732 panel.focus == FilterInput::OperandA,
1733 operand_placeholder,
1734 FilterInput::OperandA,
1735 ));
1736 if panel.needs_second_operand() {
1737 row = row
1738 .child(div().text_color(c_muted).text_size(px(11.0)).child("and"))
1739 .child(operand_field(
1740 &panel.operand_b.value,
1741 panel.focus == FilterInput::OperandB,
1742 operand_placeholder,
1743 FilterInput::OperandB,
1744 ));
1745 }
1746 row
1747 });
1748
1749 let st_search = state.clone();
1751 let search_focused = panel.focus == FilterInput::Search;
1752 let (search_text, search_is_ph) = if panel.search.value.is_empty() {
1753 ("Search".to_owned(), true)
1754 } else {
1755 (panel.search.value.clone(), false)
1756 };
1757 let search_box = div()
1758 .h(px(26.0))
1759 .px(px(6.0))
1760 .flex()
1761 .items_center()
1762 .gap(px(2.0))
1763 .border_1()
1764 .border_color(if search_focused { c_accent } else { c_line })
1765 .bg(c_bg)
1766 .cursor_pointer()
1767 .child(
1768 div()
1769 .text_color(if search_is_ph { c_muted } else { c_fg })
1770 .child(search_text),
1771 )
1772 .children(search_focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1773 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1774 st_search.update(cx, |s, cx| {
1775 s.set_filter_focus(FilterInput::Search);
1776 cx.notify();
1777 });
1778 });
1779
1780 let st_all = state.clone();
1782 let select_all_row = div()
1783 .id("filter-select-all")
1784 .h(px(24.0))
1785 .flex()
1786 .items_center()
1787 .gap(px(6.0))
1788 .px(px(4.0))
1789 .rounded(px(4.0))
1790 .cursor_pointer()
1791 .hover(move |style| style.bg(c_hover))
1792 .child(checkbox(panel.all_checked()))
1793 .child("(Select All)")
1794 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1795 st_all.update(cx, |s, cx| {
1796 s.toggle_filter_select_all();
1797 cx.notify();
1798 });
1799 });
1800
1801 let visible = panel.visible_indices();
1802 let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
1803 for &idx in visible.iter().take(FILTER_PANEL_MAX_ROWS) {
1804 let row = &panel.distinct[idx];
1805 let st_val = state.clone();
1806 value_rows.push(
1807 div()
1808 .id(("filter-value", idx))
1809 .h(px(22.0))
1810 .flex()
1811 .items_center()
1812 .gap(px(6.0))
1813 .px(px(4.0))
1814 .rounded(px(4.0))
1815 .cursor_pointer()
1816 .hover(move |style| style.bg(c_hover))
1817 .child(checkbox(row.checked))
1818 .child(div().text_color(c_fg).child(row.label.clone()))
1819 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1820 st_val.update(cx, |s, cx| {
1821 s.toggle_filter_value(idx);
1822 cx.notify();
1823 });
1824 })
1825 .into_any_element(),
1826 );
1827 }
1828 let truncated = visible.len() > FILTER_PANEL_MAX_ROWS;
1829 let value_list = div()
1830 .id("filter-value-list")
1831 .flex()
1832 .flex_col()
1833 .max_h(px(180.0))
1834 .overflow_y_scroll()
1835 .children(value_rows)
1836 .children(truncated.then(|| {
1837 div()
1838 .text_color(c_muted)
1839 .text_size(px(11.0))
1840 .child("Refine search to see more…")
1841 }));
1842
1843 let st_clear = state.clone();
1845 let st_close = state.clone();
1846 let clear_bg = if filter_active { c_hover } else { c_bg };
1847 let clear_fg = if filter_active { c_fg } else { c_muted };
1848 let clear_border = if filter_active { c_line } else { c_muted };
1849 let buttons_row = div()
1850 .flex()
1851 .gap(px(6.0))
1852 .child(
1853 div()
1854 .flex_1()
1855 .h(px(28.0))
1856 .flex()
1857 .items_center()
1858 .justify_center()
1859 .border_1()
1860 .border_color(clear_border)
1861 .bg(clear_bg)
1862 .text_color(clear_fg)
1863 .cursor_pointer()
1864 .child("Clear Filter")
1865 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1866 if !filter_active {
1867 return;
1868 }
1869 st_clear.update(cx, |s, cx| {
1870 s.clear_filter_panel();
1871 cx.notify();
1872 });
1873 }),
1874 )
1875 .child(
1876 div()
1877 .flex_1()
1878 .h(px(28.0))
1879 .flex()
1880 .items_center()
1881 .justify_center()
1882 .border_1()
1883 .border_color(c_line)
1884 .bg(c_hover)
1885 .cursor_pointer()
1886 .child("Close")
1887 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1888 st_close.update(cx, |s, cx| {
1889 s.filter_panel = None;
1890 cx.notify();
1891 });
1892 }),
1893 );
1894
1895 let panel_body = div()
1896 .flex()
1897 .flex_col()
1898 .w(px(FILTER_PANEL_WIDTH))
1899 .p(px(10.0))
1900 .gap(px(8.0))
1901 .bg(c_bg)
1902 .border_1()
1903 .border_color(c_line)
1904 .text_color(c_fg)
1905 .text_size(px(13.0))
1906 .child(div().text_color(c_muted).text_size(px(11.0)).child("Sort"))
1907 .child(sort_row)
1908 .child(
1909 div()
1910 .text_color(c_muted)
1911 .text_size(px(11.0))
1912 .child("Filter"),
1913 )
1914 .child(op_button)
1915 .children(op_menu)
1916 .children(operands)
1917 .child(search_box)
1918 .child(select_all_row)
1919 .child(value_list)
1920 .child(buttons_row);
1921
1922 let st_backdrop = state.clone();
1923 let overlay = deferred(
1924 anchored()
1925 .anchor(Corner::BottomLeft)
1926 .position(point(px(abs_x), px(abs_y)))
1927 .child(
1928 div()
1929 .occlude()
1930 .child(crate::grid::motion::pop_in(
1931 panel_body,
1932 "grid-filter-panel",
1933 animations,
1934 ))
1935 .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
1936 st_backdrop.update(cx, |s, cx| {
1937 if s.filter_panel.is_some() {
1938 s.filter_panel = None;
1939 cx.notify();
1940 }
1941 });
1942 }),
1943 ),
1944 )
1945 .with_priority(CONTEXT_MENU_PRIORITY);
1946
1947 Some(overlay)
1948}
1949
1950fn render_busy_overlay(
1956 state: &Entity<GridState>,
1957 cx: &mut Context<SqllyDataTable>,
1958) -> Option<impl IntoElement> {
1959 let s = state.read(cx);
1960 let busy = s.busy.clone()?;
1961 let theme = s.theme.clone();
1962 let animations = s.config.animations;
1963 let track = theme.grid_line;
1964 let accent = theme.sort_indicator;
1965
1966 let bar: gpui::AnyElement = if let Some(p) = busy.progress {
1967 let p = p.clamp(0.0, 1.0);
1968 div()
1969 .h_full()
1970 .w(relative(p))
1971 .rounded(px(3.0))
1972 .bg(accent)
1973 .into_any_element()
1974 } else {
1975 div()
1976 .h_full()
1977 .w(relative(0.3))
1978 .rounded(px(3.0))
1979 .bg(accent)
1980 .with_animation(
1981 "busy-indeterminate",
1982 Animation::new(std::time::Duration::from_millis(900))
1983 .repeat()
1984 .with_easing(pulsating_between(0.15, 0.85)),
1985 |el, delta| el.w(relative(delta)),
1986 )
1987 .into_any_element()
1988 };
1989
1990 let card = div()
1991 .flex()
1992 .flex_col()
1993 .gap(px(10.0))
1994 .p(px(16.0))
1995 .min_w(px(220.0))
1996 .rounded(px(8.0))
1997 .bg(theme.menu_bg)
1998 .border_1()
1999 .border_color(theme.grid_line)
2000 .child(
2001 div()
2002 .text_color(theme.menu_fg)
2003 .text_size(px(14.0))
2004 .child(busy.label.clone()),
2005 )
2006 .child(
2007 div()
2008 .w_full()
2009 .h(px(6.0))
2010 .rounded(px(3.0))
2011 .bg(track)
2012 .child(bar),
2013 );
2014
2015 let overlay = div()
2016 .absolute()
2017 .top_0()
2018 .left_0()
2019 .size_full()
2020 .occlude()
2021 .flex()
2022 .items_center()
2023 .justify_center()
2024 .bg(theme.overlay_scrim)
2025 .child(card);
2026
2027 Some(crate::grid::motion::fade_in(
2028 overlay,
2029 "grid-busy-overlay",
2030 crate::grid::motion::SCRIM_ENTER_MS,
2031 animations,
2032 ))
2033}
2034
2035enum MenuDispatch {
2038 Builtin(menu::MenuAction, usize),
2039 Custom(
2040 String,
2041 Option<crate::grid::context_menu::ContextMenuRequest>,
2042 ),
2043}