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_ui_kit::pane_divider::{CollapseDirection, PaneDivider, PaneDividerTheme};
35use std::sync::Arc;
36
37const CONTEXT_MENU_PRIORITY: usize = 1_000_000;
44const MIN_PIVOT_SIDEBAR_WIDTH: f32 = 180.0;
45const MAX_PIVOT_SIDEBAR_WIDTH: f32 = 480.0;
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum GridTab {
50 #[default]
52 Grid,
53 Pivot,
55}
56
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum PivotSidebarPosition {
60 #[default]
62 Left,
63 Right,
65}
66
67#[derive(Clone, Copy)]
68struct PivotSidebarDrag {
69 start_x: f32,
70 start_width: f32,
71}
72
73pub(crate) struct PivotParts {
75 pub(crate) state: Entity<PivotState>,
76 grid: Entity<PivotGrid>,
77 sidebar: Entity<PivotSidebar>,
78}
79
80pub struct SqllyDataTable {
82 pub state: Entity<GridState>,
83 pub(crate) pivot: Option<PivotParts>,
86 active_tab: GridTab,
88 pivot_locked: bool,
91 pivot_status: Option<String>,
92 pivot_sidebar_position: PivotSidebarPosition,
93 pivot_sidebar_collapsed: bool,
94 pivot_sidebar_width: f32,
95 pivot_sidebar_drag: Option<PivotSidebarDrag>,
96 follow_system_appearance: bool,
100 appearance_subscription: Option<gpui::Subscription>,
104}
105
106impl SqllyDataTable {
107 #[must_use]
109 pub fn new(state: Entity<GridState>) -> Self {
110 Self {
111 state,
112 pivot: None,
113 active_tab: GridTab::Grid,
114 pivot_locked: false,
115 pivot_status: None,
116 pivot_sidebar_position: PivotSidebarPosition::Left,
117 pivot_sidebar_collapsed: false,
118 pivot_sidebar_width: DEFAULT_PIVOT_SIDEBAR_WIDTH,
119 pivot_sidebar_drag: None,
120 follow_system_appearance: true,
121 appearance_subscription: None,
122 }
123 }
124
125 #[must_use]
127 pub fn builder(data: GridData) -> SqllyDataTableBuilder {
128 SqllyDataTableBuilder {
129 data,
130 config: GridConfig::default(),
131 context_menu_provider: None,
132 theme: None,
133 theme_family: None,
134 debug_bar: false,
135 grouped_column: None,
136 pivot: None,
137 pivot_context_menu_provider: None,
138 pivot_save_config_handler: None,
139 pivot_sidebar_position: PivotSidebarPosition::Left,
140 pivot_sidebar_collapsed: false,
141 pivot_sidebar_width: DEFAULT_PIVOT_SIDEBAR_WIDTH,
142 pivot_row_height: DEFAULT_PIVOT_ROW_HEIGHT,
143 pivot_column_width: DEFAULT_PIVOT_COLUMN_WIDTH,
144 }
145 }
146
147 #[must_use]
151 pub fn pivot_state(&self) -> Option<&Entity<PivotState>> {
152 self.pivot.as_ref().map(|p| &p.state)
153 }
154
155 #[must_use]
157 pub fn active_tab(&self) -> GridTab {
158 self.active_tab
159 }
160
161 pub fn set_theme_family(
168 &mut self,
169 family: GridThemePair,
170 window: &Window,
171 cx: &mut Context<Self>,
172 ) {
173 self.follow_system_appearance = true;
174 let appearance = window.appearance();
175 self.state.update(cx, |s, cx| {
176 s.theme = family.for_appearance(appearance);
177 s.theme_family = family;
178 cx.notify();
179 });
180 cx.notify();
181 }
182
183 #[must_use]
185 pub fn pivot_locked(&self) -> bool {
186 self.pivot_locked
187 }
188
189 #[must_use]
191 pub fn pivot_sidebar_position(&self) -> PivotSidebarPosition {
192 self.pivot_sidebar_position
193 }
194
195 pub fn set_pivot_sidebar_position(&mut self, position: PivotSidebarPosition) {
197 self.pivot_sidebar_position = position;
198 self.pivot_sidebar_drag = None;
199 }
200
201 #[must_use]
203 pub fn pivot_sidebar_collapsed(&self) -> bool {
204 self.pivot_sidebar_collapsed
205 }
206
207 pub fn set_pivot_sidebar_collapsed(&mut self, collapsed: bool) {
209 self.pivot_sidebar_collapsed = collapsed;
210 self.pivot_sidebar_drag = None;
211 }
212
213 #[must_use]
215 pub fn pivot_sidebar_width(&self) -> f32 {
216 self.pivot_sidebar_width
217 }
218
219 pub fn set_pivot_sidebar_width(&mut self, width: f32) {
221 self.pivot_sidebar_width = width.clamp(MIN_PIVOT_SIDEBAR_WIDTH, MAX_PIVOT_SIDEBAR_WIDTH);
222 }
223
224 pub fn set_pivot_locked(&mut self, locked: bool, status: Option<String>) {
229 self.pivot_locked = locked;
230 self.pivot_status = locked.then_some(status).flatten();
231 if locked {
232 self.active_tab = GridTab::Grid;
233 }
234 }
235
236 pub fn set_active_tab(&mut self, tab: GridTab, cx: &mut App) {
241 if tab == GridTab::Pivot {
242 if self.pivot.is_none() || self.pivot_locked {
243 return;
244 }
245 self.sync_pivot_source(cx);
246 }
247 self.active_tab = tab;
248 }
249
250 pub fn enable_pivot(&mut self, config: PivotConfig, cx: &mut App) {
254 if let Some(parts) = &self.pivot {
255 parts.state.update(cx, |s, cx| {
256 s.config = config;
257 s.recompute();
258 cx.notify();
259 });
260 return;
261 }
262 self.pivot = Some(build_pivot_parts(
263 &self.state,
264 config,
265 None,
266 None,
267 DEFAULT_PIVOT_ROW_HEIGHT,
268 DEFAULT_PIVOT_COLUMN_WIDTH,
269 cx,
270 ));
271 }
272
273 pub fn disable_pivot(&mut self) {
275 self.pivot = None;
276 self.active_tab = GridTab::Grid;
277 }
278
279 pub fn set_pivot_context_menu_provider(
284 &mut self,
285 provider: impl PivotContextMenuProvider + 'static,
286 cx: &mut App,
287 ) {
288 if let Some(parts) = &self.pivot {
289 parts.state.update(cx, |s, _cx| {
290 s.set_context_menu_provider(provider);
291 });
292 }
293 }
294
295 pub fn set_pivot_save_config(
302 &mut self,
303 handler: impl Fn(&PivotConfig, &mut App) + 'static,
304 cx: &mut App,
305 ) {
306 if let Some(parts) = &self.pivot {
307 parts.state.update(cx, |s, cx| {
308 s.on_save_config(handler);
309 cx.notify();
310 });
311 }
312 }
313
314 pub fn clear_pivot_save_config(&mut self, cx: &mut App) {
317 if let Some(parts) = &self.pivot {
318 parts.state.update(cx, |s, cx| {
319 s.clear_save_config_handler();
320 cx.notify();
321 });
322 }
323 }
324
325 fn sync_pivot_source(&self, cx: &mut App) {
328 let Some(parts) = &self.pivot else {
329 return;
330 };
331 let (columns, rows) = {
332 let s = self.state.read(cx);
333 (s.data.columns.clone(), Arc::clone(&s.data_rows))
334 };
335 parts.state.update(cx, |ps, cx| {
336 if ps.source_differs(&rows) {
337 ps.set_source(columns, rows);
338 cx.notify();
339 }
340 });
341 }
342}
343
344fn build_pivot_parts(
346 grid_state: &Entity<GridState>,
347 config: PivotConfig,
348 menu_provider: Option<PivotContextMenuProviderHandle>,
349 save_config_handler: Option<PivotSaveConfigHandler>,
350 row_height: f32,
351 column_width: f32,
352 cx: &mut App,
353) -> PivotParts {
354 let (columns, rows, formats, key_bindings, theme, animations) = {
355 let s = grid_state.read(cx);
356 (
357 s.data.columns.clone(),
358 Arc::clone(&s.data_rows),
359 s.resolved_formats.as_ref().clone(),
360 s.config.key_bindings.clone(),
361 s.theme.clone(),
362 s.config.animations,
363 )
364 };
365 let focus = cx.focus_handle();
366 let state = cx.new(|_| {
367 let mut ps = PivotState::new(columns, rows, formats, config, key_bindings, focus);
368 ps.theme = theme;
369 ps.animations = animations;
370 ps.context_menu_provider = menu_provider;
371 ps.save_config_handler = save_config_handler;
372 ps.set_row_height(row_height);
373 ps.set_column_width(column_width);
374 ps
375 });
376 let grid = cx.new(|_| PivotGrid::new(state.clone()));
377 let sidebar = cx.new(|_| PivotSidebar::new(state.clone()));
378 PivotParts {
379 state,
380 grid,
381 sidebar,
382 }
383}
384
385pub struct SqllyDataTableBuilder {
387 data: GridData,
388 config: GridConfig,
389 context_menu_provider: Option<ContextMenuProviderHandle>,
390 theme: Option<GridTheme>,
391 theme_family: Option<GridThemePair>,
392 debug_bar: bool,
393 grouped_column: Option<usize>,
394 pivot: Option<PivotConfig>,
395 pivot_context_menu_provider: Option<PivotContextMenuProviderHandle>,
396 pivot_save_config_handler: Option<PivotSaveConfigHandler>,
397 pivot_sidebar_position: PivotSidebarPosition,
398 pivot_sidebar_collapsed: bool,
399 pivot_sidebar_width: f32,
400 pivot_row_height: f32,
401 pivot_column_width: f32,
402}
403
404impl SqllyDataTableBuilder {
405 #[must_use]
407 pub fn config(mut self, config: GridConfig) -> Self {
408 self.config = config;
409 self
410 }
411
412 #[must_use]
415 pub fn theme(mut self, theme: GridTheme) -> Self {
416 self.theme = Some(theme);
417 self
418 }
419
420 #[must_use]
427 pub fn theme_family(mut self, family: GridThemePair) -> Self {
428 self.theme_family = Some(family);
429 self
430 }
431
432 #[must_use]
439 pub fn context_menu_provider(mut self, provider: impl ContextMenuProvider + 'static) -> Self {
440 self.context_menu_provider = Some(ContextMenuProviderHandle::new(provider));
441 self
442 }
443
444 #[must_use]
448 pub fn debug_bar(mut self, enabled: bool) -> Self {
449 self.debug_bar = enabled;
450 self
451 }
452
453 #[must_use]
456 pub fn group_by_column(mut self, column: usize) -> Self {
457 self.grouped_column = Some(column);
458 self
459 }
460
461 #[must_use]
467 pub fn pivot(mut self, config: PivotConfig) -> Self {
468 self.pivot = Some(config);
469 self
470 }
471
472 #[must_use]
474 pub fn pivot_sidebar_position(mut self, position: PivotSidebarPosition) -> Self {
475 self.pivot_sidebar_position = position;
476 self
477 }
478
479 #[must_use]
481 pub fn pivot_sidebar_collapsed(mut self, collapsed: bool) -> Self {
482 self.pivot_sidebar_collapsed = collapsed;
483 self
484 }
485
486 #[must_use]
488 pub fn pivot_sidebar_width(mut self, width: f32) -> Self {
489 self.pivot_sidebar_width = width.clamp(MIN_PIVOT_SIDEBAR_WIDTH, MAX_PIVOT_SIDEBAR_WIDTH);
490 self
491 }
492
493 #[must_use]
495 pub fn pivot_row_height(mut self, height: f32) -> Self {
496 if height.is_finite() {
497 self.pivot_row_height = height.max(crate::pivot::state::MIN_PIVOT_ROW_HEIGHT);
498 }
499 self
500 }
501
502 #[must_use]
504 pub fn pivot_column_width(mut self, width: f32) -> Self {
505 if width.is_finite() {
506 self.pivot_column_width = width.max(crate::pivot::state::MIN_PIVOT_COLUMN_WIDTH);
507 }
508 self
509 }
510
511 #[must_use]
517 pub fn pivot_context_menu_provider(
518 mut self,
519 provider: impl PivotContextMenuProvider + 'static,
520 ) -> Self {
521 self.pivot_context_menu_provider = Some(PivotContextMenuProviderHandle::new(provider));
522 self
523 }
524
525 #[must_use]
533 pub fn pivot_save_config(mut self, handler: impl Fn(&PivotConfig, &mut App) + 'static) -> Self {
534 self.pivot_save_config_handler = Some(std::rc::Rc::new(handler));
535 self
536 }
537
538 pub fn build(self, cx: &mut App) -> SqllyDataTable {
540 let focus = cx.focus_handle();
541 let provider = self.context_menu_provider;
542 let theme_override = self.theme;
543 let theme_family = self.theme_family;
544 let debug_bar = self.debug_bar;
545 let grouped_column = self.grouped_column;
546 let pivot_config = self.pivot;
547 let pivot_sidebar_position = self.pivot_sidebar_position;
548 let pivot_sidebar_collapsed = self.pivot_sidebar_collapsed;
549 let pivot_sidebar_width = self.pivot_sidebar_width;
550 let pivot_row_height = self.pivot_row_height;
551 let pivot_column_width = self.pivot_column_width;
552 let follow_system_appearance = theme_override.is_none();
553 let state = cx.new(|cx| {
554 let mut s = GridState::new(self.data, self.config, focus.clone());
555 s.context_menu_provider = provider;
556 s.debug_bar_enabled = debug_bar;
557 s.set_grouped_column(grouped_column);
558 s.self_weak = Some(cx.weak_entity());
559 if let Some(family) = theme_family {
560 s.theme = family.light.clone();
561 s.theme_family = family;
562 }
563 if let Some(theme) = theme_override {
564 s.theme = theme;
565 }
566 s
567 });
568 let pivot_menu_provider = self.pivot_context_menu_provider;
569 let pivot_save_config_handler = self.pivot_save_config_handler;
570 let pivot = pivot_config.map(|cfg| {
571 build_pivot_parts(
572 &state,
573 cfg,
574 pivot_menu_provider,
575 pivot_save_config_handler,
576 pivot_row_height,
577 pivot_column_width,
578 cx,
579 )
580 });
581 SqllyDataTable {
582 state,
583 pivot,
584 active_tab: GridTab::Grid,
585 pivot_locked: false,
586 pivot_status: None,
587 pivot_sidebar_position,
588 pivot_sidebar_collapsed,
589 pivot_sidebar_width,
590 pivot_sidebar_drag: None,
591 follow_system_appearance,
592 appearance_subscription: None,
593 }
594 }
595}
596
597impl Focusable for SqllyDataTable {
598 fn focus_handle(&self, cx: &App) -> FocusHandle {
599 self.state.read(cx).focus_handle.clone()
600 }
601}
602
603impl Render for SqllyDataTable {
604 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
605 if self.follow_system_appearance {
617 let appearance = window.appearance();
618 let resolved = self.state.read(cx).theme_family.for_appearance(appearance);
619 if self.state.read(cx).theme != resolved {
620 self.state.update(cx, |s, cx| {
621 s.theme = resolved;
622 cx.notify();
623 });
624 }
625 if self.appearance_subscription.is_none() {
626 let state_appearance = self.state.clone();
627 self.appearance_subscription =
628 Some(window.observe_window_appearance(move |window, cx| {
629 let appearance = window.appearance();
630 state_appearance.update(cx, |s, cx| {
631 let resolved = s.theme_family.for_appearance(appearance);
632 if s.theme != resolved {
633 s.theme = resolved;
634 cx.notify();
635 }
636 });
637 }));
638 }
639 }
640
641 if let Some(parts) = &self.pivot {
646 let grid_theme = self.state.read(cx).theme.clone();
647 let sidebar_width = self.pivot_sidebar_width;
648 parts.state.update(cx, |s, cx| {
649 let mut dirty = false;
650 if s.theme != grid_theme {
651 s.theme = grid_theme;
652 dirty = true;
653 }
654 if (s.sidebar_width - sidebar_width).abs() > 0.5 {
655 s.sidebar_width = sidebar_width;
656 dirty = true;
657 }
658 if dirty {
659 cx.notify();
660 }
661 });
662 }
663
664 if let Some(parts) = &self.pivot {
669 let drill = parts.state.update(cx, |s, _cx| s.take_pending_drill_down());
670 if let Some(filter_sets) = drill {
671 self.state.update(cx, |g, cx| {
672 if g.window.is_none() {
676 for filter in &mut g.filters {
677 *filter = ColumnFilter::default();
678 }
679 for (field, values) in filter_sets {
680 if let Some(slot) = g.filters.get_mut(field) {
681 *slot = ColumnFilter {
682 predicate: FilterPredicate::None,
683 values: Some(values),
684 };
685 }
686 }
687 g.recompute();
688 }
689 cx.notify();
690 });
691 self.active_tab = GridTab::Grid;
692 let focus = self.state.read(cx).focus_handle.clone();
693 window.focus(&focus);
694 cx.notify();
695 }
696 }
697
698 let grid_view = self.render_grid_view(cx);
699
700 let Some(parts) = &self.pivot else {
701 return div().size_full().child(grid_view);
702 };
703
704 let theme = self.state.read(cx).theme.clone();
705 let tab = |label: String,
706 this_tab: GridTab,
707 active: bool,
708 locked: bool,
709 cx: &mut Context<Self>| {
710 div()
711 .px(px(14.0))
712 .py(px(5.0))
713 .text_size(px(13.0))
714 .when(!locked, |tab| tab.cursor_pointer())
715 .when(locked, |tab| tab.opacity(0.55))
716 .bg(if active { theme.bg } else { theme.header_bg })
717 .text_color(if active {
718 theme.header_fg
719 } else {
720 theme.muted_text
721 })
722 .border_b_2()
723 .border_color(if active {
724 theme.sort_indicator
725 } else {
726 theme.header_bg
727 })
728 .child(label)
729 .when(!locked, |tab| {
730 tab.on_mouse_down(
731 MouseButton::Left,
732 cx.listener(move |this, _e: &MouseDownEvent, window, cx| {
733 if this.active_tab == this_tab {
734 return;
735 }
736 this.set_active_tab(this_tab, cx);
737 match this_tab {
739 GridTab::Grid => {
740 let focus = this.state.read(cx).focus_handle.clone();
741 window.focus(&focus);
742 }
743 GridTab::Pivot => {
744 if let Some(p) = &this.pivot {
745 let focus = p.state.read(cx).focus_handle.clone();
746 window.focus(&focus);
747 }
748 }
749 }
750 cx.notify();
751 }),
752 )
753 })
754 };
755
756 let pivot_label = self
757 .pivot_status
758 .as_deref()
759 .map_or_else(|| "Pivot".to_string(), |status| format!("Pivot {status}"));
760
761 let tab_bar = div()
762 .flex()
763 .flex_row()
764 .flex_none()
765 .bg(theme.header_bg)
766 .border_b_1()
767 .border_color(theme.grid_line)
768 .child(tab(
769 "Grid".to_string(),
770 GridTab::Grid,
771 self.active_tab == GridTab::Grid,
772 false,
773 cx,
774 ))
775 .child(tab(
776 pivot_label,
777 GridTab::Pivot,
778 self.active_tab == GridTab::Pivot,
779 self.pivot_locked,
780 cx,
781 ));
782
783 let content: gpui::AnyElement = match self.active_tab {
784 GridTab::Grid => grid_view.into_any_element(),
785 GridTab::Pivot => {
786 let position = self.pivot_sidebar_position;
787 let collapsed = self.pivot_sidebar_collapsed;
788 let panel = div()
789 .w(px(self.pivot_sidebar_width))
790 .h_full()
791 .flex_none()
792 .child(parts.sidebar.clone());
793 let pivot_grid = div().flex_1().min_w_0().child(parts.grid.clone());
794 let direction = match position {
795 PivotSidebarPosition::Left => CollapseDirection::Left,
796 PivotSidebarPosition::Right => CollapseDirection::Right,
797 };
798 let divider_theme = PaneDividerTheme {
799 background: theme.header_bg.into(),
800 background_hover: theme.menu_hover_bg.into(),
801 background_collapsed: theme.menu_bg.into(),
802 foreground: theme.muted_text.into(),
803 foreground_hover: theme.header_fg.into(),
804 border: theme.grid_line.into(),
805 };
806 let table_toggle = cx.entity().clone();
807 let table_drag = cx.entity().clone();
808 let divider = PaneDivider::vertical("pivot-sidebar-divider", direction)
809 .label("Pivot")
810 .collapsed(collapsed)
811 .theme(divider_theme)
812 .on_toggle(move |collapsed, _window, cx| {
813 table_toggle.update(cx, |table, cx| {
814 table.set_pivot_sidebar_collapsed(collapsed);
815 cx.notify();
816 });
817 })
818 .on_drag_start(move |start_x, _window, cx| {
819 table_drag.update(cx, |table, cx| {
820 table.pivot_sidebar_drag = Some(PivotSidebarDrag {
821 start_x,
822 start_width: table.pivot_sidebar_width,
823 });
824 cx.notify();
825 });
826 });
827
828 let mut pivot_view = div().flex().flex_row().size_full();
829 pivot_view = match position {
830 PivotSidebarPosition::Left => pivot_view
831 .children((!collapsed).then_some(panel))
832 .child(divider)
833 .child(pivot_grid),
834 PivotSidebarPosition::Right => pivot_view
835 .child(pivot_grid)
836 .child(divider)
837 .children((!collapsed).then_some(panel)),
838 };
839
840 pivot_view.into_any_element()
841 }
842 };
843
844 let mut root = div()
845 .flex()
846 .flex_col()
847 .size_full()
848 .child(tab_bar)
849 .child(div().flex_1().min_h_0().child(content));
850
851 if let Some(drag) = self.pivot_sidebar_drag {
852 let position = self.pivot_sidebar_position;
853 let table_move = cx.entity().clone();
854 let table_up = cx.entity().clone();
855 let table_up_out = cx.entity().clone();
856 root = root
857 .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
858 let current_x: f32 = event.position.x.into();
859 let delta = match position {
860 PivotSidebarPosition::Left => current_x - drag.start_x,
861 PivotSidebarPosition::Right => drag.start_x - current_x,
862 };
863 table_move.update(cx, |table, cx| {
864 table.set_pivot_sidebar_width(drag.start_width + delta);
865 cx.notify();
866 });
867 })
868 .on_mouse_up(MouseButton::Left, move |_event, _window, cx| {
869 table_up.update(cx, |table, cx| {
870 table.pivot_sidebar_drag = None;
871 cx.notify();
872 });
873 })
874 .on_mouse_up_out(MouseButton::Left, move |_event, _window, cx| {
875 table_up_out.update(cx, |table, cx| {
876 table.pivot_sidebar_drag = None;
877 cx.notify();
878 });
879 });
880 }
881
882 root
883 }
884}
885
886impl SqllyDataTable {
887 fn render_grid_view(&mut self, cx: &mut Context<Self>) -> Div {
890 let state_canvas = self.state.clone();
891 let state_status = self.state.clone();
892 let state_mouse = self.state.clone();
893 let state_move = self.state.clone();
894 let state_up = self.state.clone();
895 let state_scroll = self.state.clone();
896 let state_key = self.state.clone();
897 let state_right = self.state.clone();
898 let bg = self.state.read(cx).theme.bg;
899 let focus_handle = self.state.read(cx).focus_handle.clone();
900 let focus_left = focus_handle.clone();
901 let focus_right = focus_handle.clone();
902 let debug_bar = self.state.read(cx).debug_bar_enabled;
903 let status_h = self.state.read(cx).status_bar_height;
904
905 if let Some((action, col)) = self.state.read(cx).pending_action {
908 self.state.update(cx, |s, cx| {
909 s.execute_action(action, col, cx);
910 s.pending_action = None;
911 });
912 }
913
914 if let Some(pending) = self
916 .state
917 .read(cx)
918 .pending_custom_context_menu_action
919 .clone()
920 {
921 self.state.update(cx, |s, cx| {
922 s.pending_custom_context_menu_action = None;
923 s.execute_custom_context_menu_action(pending, cx);
924 });
925 }
926
927 if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
935 self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
936 let state_edge = self.state.clone();
937 cx.spawn(async move |_weak, cx| {
938 loop {
939 gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
940 let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
941 if let Ok(true) = res {
942 let _ = state_edge.update(cx, |_s, cx| cx.notify());
943 }
944 let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
945 if !matches!(dragging_res, Ok(true)) {
946 break;
947 }
948 }
949 let _ =
950 cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
951 })
952 .detach();
953 }
954
955 div()
956 .flex()
957 .flex_col()
958 .size_full()
959 .relative()
960 .track_focus(&focus_handle)
961 .bg(bg)
962 .child(
963 canvas(
964 move |bounds, window, cx| -> PaintData {
965 let viewport = window.viewport_size();
966 state_canvas.update(cx, |s, cx| {
967 let mut dirty = false;
968 if s.bounds != bounds {
969 s.bounds = bounds;
970 s.clamp_scroll_to_bounds();
971 dirty = true;
972 }
973 if s.window_viewport != viewport {
974 s.window_viewport = viewport;
975 }
976 if dirty {
977 cx.notify();
978 }
979 });
980 let s = state_canvas.read(cx);
981 let mut data = PaintData::from_state(s);
982 data.focused = s.focus_handle.is_focused(window);
983 data
984 },
985 move |bounds, data, window, cx| {
986 paint_grid(&data, window, cx, bounds);
987 },
988 )
989 .flex_1(),
990 )
991 .children(debug_bar.then(|| {
992 canvas(
993 move |_bounds, _window, cx| -> StatusBarData {
994 let s = state_status.read(cx);
995 StatusBarData::from_state(s)
996 },
997 move |bounds, data, window, cx| {
998 paint_status_bar(&data, window, cx, bounds);
999 },
1000 )
1001 .h(px(status_h))
1002 }))
1003 .children(render_context_menu_overlay(&self.state, cx))
1004 .children(render_filter_panel_overlay(&self.state, cx))
1005 .children(render_busy_overlay(&self.state, cx))
1006 .on_mouse_down(
1007 MouseButton::Left,
1008 move |event: &MouseDownEvent, window, cx| {
1009 window.focus(&focus_left);
1010 state_mouse.update(cx, |s, cx| {
1011 if s.busy.is_some() {
1014 return;
1015 }
1016 let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
1022 if s.context_menu.is_some() || s.filter_panel.is_some() {
1023 s.context_menu = None;
1024 s.filter_panel = None;
1025 }
1026 s.handle_mouse_down_with_modifiers(
1027 rel,
1028 event.modifiers.shift,
1029 event.modifiers.platform,
1030 );
1031 cx.notify();
1032 });
1033 },
1034 )
1035 .on_mouse_down(
1036 MouseButton::Right,
1037 move |event: &MouseDownEvent, window, cx| {
1038 window.focus(&focus_right);
1039 state_right.update(cx, |s, cx| {
1040 if s.busy.is_some() {
1041 return;
1042 }
1043 let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
1044 let hit = s.hit_test(pos);
1045
1046 if s.context_menu_provider.is_none() {
1048 match hit {
1049 HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
1050 s.open_context_menu(col, pos);
1051 }
1052 _ => {
1053 s.context_menu = None;
1054 s.filter_panel = None;
1055 }
1056 }
1057 cx.notify();
1058 return;
1059 }
1060
1061 let Some(target) = s.context_menu_target_from_hit(hit) else {
1063 s.context_menu = None;
1064 s.filter_panel = None;
1065 cx.notify();
1066 return;
1067 };
1068
1069 let effective = s.effective_selection_for_context_target(&target);
1070 if effective != s.selection {
1071 s.selection = effective.clone();
1072 }
1073
1074 let request = s.build_context_menu_request(target, &effective);
1075 let col = request.target.column_index().unwrap_or(0);
1076
1077 let Some(provider) = s.context_menu_provider.clone() else {
1078 return;
1079 };
1080 let public_items = provider.menu_items(&request);
1081 let items = GridState::convert_context_menu_items(public_items);
1082
1083 if items.is_empty() {
1084 s.context_menu = None;
1085 } else {
1086 s.context_menu =
1087 Some(menu::ContextMenu::custom(col, pos, items, request));
1088 }
1089 s.filter_panel = None;
1090 cx.notify();
1091 });
1092 },
1093 )
1094 .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
1095 state_move.update(cx, |s, cx| {
1096 let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
1097 s.handle_mouse_move(rel, event.pressed_button);
1098 cx.notify();
1099 });
1100 })
1101 .on_mouse_up(
1102 MouseButton::Left,
1103 move |_event: &MouseUpEvent, _window, cx| {
1104 state_up.update(cx, |s, cx| {
1105 s.handle_mouse_up();
1106 cx.notify();
1107 });
1108 },
1109 )
1110 .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
1111 state_scroll.update(cx, |s, cx| {
1112 let line_h = px(s.row_height);
1113 let delta = event.delta.pixel_delta(line_h);
1114 let scroll = s.scroll_handle.offset();
1115 let (mx, my) = s.max_scroll();
1116 let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
1117 let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
1118 s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
1119 if s.drag_start.is_some() {
1120 s.handle_scroll_drag();
1121 }
1122 cx.notify();
1123 });
1124 })
1125 .on_key_down(move |event: &KeyDownEvent, _window, cx| {
1126 let ks = &event.keystroke;
1127 if ks.modifiers.platform && ks.key == "q" {
1128 cx.quit();
1129 return;
1130 }
1131 state_key.update(cx, |s, cx| {
1132 let kb = &s.config.key_bindings;
1133 if kb.select_all.matches(ks) {
1134 s.select_all();
1135 } else if kb.copy.matches(ks) {
1136 s.copy_selection(false, cx);
1137 } else if kb.copy_with_headers.matches(ks) {
1138 s.copy_selection(true, cx);
1139 } else if kb.page_up.matches(ks) {
1140 s.page_up();
1141 } else if kb.page_down.matches(ks) {
1142 s.page_down();
1143 } else {
1144 s.handle_key(ks);
1145 }
1146 cx.notify();
1147 });
1148 })
1149 }
1150}
1151
1152fn render_context_menu_overlay(
1164 state: &Entity<GridState>,
1165 cx: &mut Context<SqllyDataTable>,
1166) -> Option<impl IntoElement> {
1167 let s = state.read(cx);
1168 let menu = s.context_menu.clone()?;
1169 let theme = s.theme.clone();
1170 let animations = s.config.animations;
1171 let cw = s.char_width;
1172 let grid_ox = f32::from(s.bounds.origin.x);
1173 let grid_oy = f32::from(s.bounds.origin.y);
1174 let viewport = s.window_viewport;
1175 let vw = f32::from(viewport.width);
1176 let vh = f32::from(viewport.height);
1177
1178 let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
1179 let abs_x = grid_ox + f32::from(resolved.x);
1180 let abs_y = grid_oy + f32::from(resolved.y);
1181 let menu_w = menu.width_for(cw);
1182
1183 let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
1186 let mut selectable_idx = 0usize;
1187 for item in &menu.items {
1188 match item {
1189 MenuItem::Separator => {
1190 rows.push(
1191 div()
1192 .h(px(menu::MENU_ITEM_HEIGHT))
1193 .flex()
1194 .items_center()
1195 .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
1196 .into_any_element(),
1197 );
1198 }
1199 MenuItem::Action(_) | MenuItem::Custom { .. } => {
1200 let this_idx = selectable_idx;
1201 selectable_idx += 1;
1202 let label = item.label().unwrap_or("").to_owned();
1203 let hovered = menu.hovered == Some(this_idx);
1204
1205 let action = match item {
1209 MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
1210 MenuItem::Custom { id, .. } => {
1211 MenuDispatch::Custom(id.clone(), menu.request.clone())
1212 }
1213 MenuItem::Separator => unreachable!(),
1214 };
1215
1216 let state_click = state.clone();
1217 let state_hover = state.clone();
1218 let mut row = div()
1219 .h(px(menu::MENU_ITEM_HEIGHT))
1220 .px(px(menu::MENU_PADDING_X))
1221 .flex()
1222 .items_center()
1223 .text_color(theme.menu_fg)
1224 .text_size(px(menu::MENU_FONT_SIZE))
1225 .child(label)
1226 .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
1227 state_hover.update(cx, |s, cx| {
1228 if let Some(m) = s.context_menu.as_mut() {
1229 if m.hovered != Some(this_idx) {
1230 m.hovered = Some(this_idx);
1231 cx.notify();
1232 }
1233 }
1234 });
1235 })
1236 .on_mouse_down(
1237 MouseButton::Left,
1238 move |_e: &MouseDownEvent, _window, cx| {
1239 state_click.update(cx, |s, cx| {
1240 match &action {
1241 MenuDispatch::Builtin(a, col) => {
1242 s.pending_action = Some((*a, *col));
1243 }
1244 MenuDispatch::Custom(id, request) => {
1245 if let Some(request) = request {
1246 s.pending_custom_context_menu_action =
1247 Some(PendingCustomContextMenuAction {
1248 id: id.clone(),
1249 request: request.clone(),
1250 });
1251 }
1252 }
1253 }
1254 s.context_menu = None;
1255 cx.notify();
1256 });
1257 },
1258 );
1259 if hovered {
1260 row = row.bg(theme.menu_hover_bg);
1261 }
1262 rows.push(row.into_any_element());
1263 }
1264 }
1265 }
1266
1267 let menu_body = div()
1268 .flex()
1269 .flex_col()
1270 .w(px(menu_w))
1271 .py(px(menu::MENU_INNER_PAD))
1272 .bg(theme.menu_bg)
1273 .border_1()
1274 .border_color(theme.grid_line)
1275 .children(rows);
1276
1277 let state_backdrop = state.clone();
1280 let overlay = deferred(
1281 anchored().position(point(px(abs_x), px(abs_y))).child(
1282 div()
1283 .occlude()
1284 .child(crate::grid::motion::pop_in(
1285 menu_body,
1286 "grid-context-menu",
1287 animations,
1288 ))
1289 .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
1290 state_backdrop.update(cx, |s, cx| {
1291 if s.context_menu.is_some() {
1292 s.context_menu = None;
1293 s.filter_panel = None;
1294 cx.notify();
1295 }
1296 });
1297 }),
1298 ),
1299 )
1300 .with_priority(CONTEXT_MENU_PRIORITY);
1301
1302 Some(overlay)
1303}
1304
1305const FILTER_PANEL_WIDTH: f32 = 300.0;
1307const FILTER_PANEL_MAX_ROWS: usize = 200;
1309
1310#[allow(clippy::too_many_lines)]
1315fn render_filter_panel_overlay(
1316 state: &Entity<GridState>,
1317 cx: &mut Context<SqllyDataTable>,
1318) -> Option<impl IntoElement> {
1319 let s = state.read(cx);
1320 let panel = s.filter_panel.clone()?;
1321 let theme = s.theme.clone();
1322 let animations = s.config.animations;
1323 let col = panel.col;
1324 let current_sort = s.sort;
1325 let filter_active = s.filters.get(col).is_some_and(|f| f.is_active());
1326 let grid_ox = f32::from(s.bounds.origin.x);
1327 let grid_oy = f32::from(s.bounds.origin.y);
1328
1329 let abs_x = grid_ox + f32::from(panel.anchor.x);
1334 let abs_y = grid_oy + f32::from(panel.anchor.y);
1335
1336 let c_bg = theme.menu_bg;
1338 let c_line = theme.grid_line;
1339 let c_fg = theme.menu_fg;
1340 let c_accent = theme.sort_indicator;
1341 let c_hover = theme.menu_hover_bg;
1342 let c_muted = theme.muted_text;
1343
1344 let checkbox = {
1345 let theme = theme.clone();
1346 move |checked: bool| crate::grid::checkbox(checked, &theme)
1347 };
1348
1349 let (asc_active, desc_active) = match current_sort {
1351 Some((c, SortDirection::Ascending)) if c == col => (true, false),
1352 Some((c, SortDirection::Descending)) if c == col => (false, true),
1353 _ => (false, false),
1354 };
1355 let st_asc = state.clone();
1356 let st_desc = state.clone();
1357 let sort_row = div()
1358 .flex()
1359 .gap(px(6.0))
1360 .child(
1361 div()
1362 .flex_1()
1363 .h(px(26.0))
1364 .flex()
1365 .items_center()
1366 .justify_center()
1367 .border_1()
1368 .border_color(c_line)
1369 .bg(if asc_active { c_accent } else { c_hover })
1370 .cursor_pointer()
1371 .child("Ascending")
1372 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1373 st_asc.update(cx, |s, cx| {
1374 s.set_panel_sort(SortDirection::Ascending);
1375 cx.notify();
1376 });
1377 }),
1378 )
1379 .child(
1380 div()
1381 .flex_1()
1382 .h(px(26.0))
1383 .flex()
1384 .items_center()
1385 .justify_center()
1386 .border_1()
1387 .border_color(c_line)
1388 .bg(if desc_active { c_accent } else { c_hover })
1389 .cursor_pointer()
1390 .child("Descending")
1391 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1392 st_desc.update(cx, |s, cx| {
1393 s.set_panel_sort(SortDirection::Descending);
1394 cx.notify();
1395 });
1396 }),
1397 );
1398
1399 let st_op_toggle = state.clone();
1401 let op_button = div()
1402 .h(px(26.0))
1403 .px(px(8.0))
1404 .flex()
1405 .items_center()
1406 .border_1()
1407 .border_color(c_line)
1408 .bg(c_bg)
1409 .cursor_pointer()
1410 .child(panel.current_op_label())
1411 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1412 st_op_toggle.update(cx, |s, cx| {
1413 s.toggle_filter_op_menu();
1414 cx.notify();
1415 });
1416 });
1417
1418 let op_menu = panel.op_menu_open.then(|| {
1419 let mut items: Vec<gpui::AnyElement> = Vec::new();
1420 for (i, label) in panel.op_labels().iter().enumerate() {
1421 let selected = i == panel.op_index;
1422 let st_pick = state.clone();
1423 items.push(
1424 div()
1425 .h(px(24.0))
1426 .px(px(8.0))
1427 .flex()
1428 .items_center()
1429 .bg(if selected { c_accent } else { c_bg })
1430 .cursor_pointer()
1431 .child(*label)
1432 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1433 st_pick.update(cx, |s, cx| {
1434 s.set_filter_operator(i);
1435 cx.notify();
1436 });
1437 })
1438 .into_any_element(),
1439 );
1440 }
1441 div()
1442 .flex()
1443 .flex_col()
1444 .border_1()
1445 .border_color(c_line)
1446 .bg(c_bg)
1447 .children(items)
1448 });
1449
1450 let operand_field = |value: &str, focused: bool, placeholder: &str, input: FilterInput| {
1452 let st_focus = state.clone();
1453 let (text, is_placeholder) = if value.is_empty() {
1454 (placeholder.to_owned(), true)
1455 } else {
1456 (value.to_owned(), false)
1457 };
1458 div()
1459 .h(px(26.0))
1460 .px(px(6.0))
1461 .flex()
1462 .items_center()
1463 .gap(px(2.0))
1464 .border_1()
1465 .border_color(if focused { c_accent } else { c_line })
1466 .bg(c_bg)
1467 .cursor_pointer()
1468 .child(
1469 div()
1470 .text_color(if is_placeholder { c_muted } else { c_fg })
1471 .child(text),
1472 )
1473 .children(focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1474 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1475 st_focus.update(cx, |s, cx| {
1476 s.set_filter_focus(input);
1477 cx.notify();
1478 });
1479 })
1480 };
1481
1482 let operand_placeholder = if panel.kind == crate::data::ColumnKind::Date {
1483 "YYYY-MM-DD"
1484 } else if crate::filter::uses_number_ops(panel.kind) {
1485 "value"
1486 } else if panel.op_index == 7 {
1487 "regex"
1489 } else {
1490 "value"
1491 };
1492 let operands = panel.needs_operand().then(|| {
1493 let mut row = div().flex().flex_col().gap(px(4.0)).child(operand_field(
1494 &panel.operand_a.value,
1495 panel.focus == FilterInput::OperandA,
1496 operand_placeholder,
1497 FilterInput::OperandA,
1498 ));
1499 if panel.needs_second_operand() {
1500 row = row
1501 .child(div().text_color(c_muted).text_size(px(11.0)).child("and"))
1502 .child(operand_field(
1503 &panel.operand_b.value,
1504 panel.focus == FilterInput::OperandB,
1505 operand_placeholder,
1506 FilterInput::OperandB,
1507 ));
1508 }
1509 row
1510 });
1511
1512 let st_search = state.clone();
1514 let search_focused = panel.focus == FilterInput::Search;
1515 let (search_text, search_is_ph) = if panel.search.value.is_empty() {
1516 ("Search".to_owned(), true)
1517 } else {
1518 (panel.search.value.clone(), false)
1519 };
1520 let search_box = div()
1521 .h(px(26.0))
1522 .px(px(6.0))
1523 .flex()
1524 .items_center()
1525 .gap(px(2.0))
1526 .border_1()
1527 .border_color(if search_focused { c_accent } else { c_line })
1528 .bg(c_bg)
1529 .cursor_pointer()
1530 .child(
1531 div()
1532 .text_color(if search_is_ph { c_muted } else { c_fg })
1533 .child(search_text),
1534 )
1535 .children(search_focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1536 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1537 st_search.update(cx, |s, cx| {
1538 s.set_filter_focus(FilterInput::Search);
1539 cx.notify();
1540 });
1541 });
1542
1543 let st_all = state.clone();
1545 let select_all_row = div()
1546 .id("filter-select-all")
1547 .h(px(24.0))
1548 .flex()
1549 .items_center()
1550 .gap(px(6.0))
1551 .px(px(4.0))
1552 .rounded(px(4.0))
1553 .cursor_pointer()
1554 .hover(move |style| style.bg(c_hover))
1555 .child(checkbox(panel.all_checked()))
1556 .child("(Select All)")
1557 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1558 st_all.update(cx, |s, cx| {
1559 s.toggle_filter_select_all();
1560 cx.notify();
1561 });
1562 });
1563
1564 let visible = panel.visible_indices();
1565 let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
1566 for &idx in visible.iter().take(FILTER_PANEL_MAX_ROWS) {
1567 let row = &panel.distinct[idx];
1568 let st_val = state.clone();
1569 value_rows.push(
1570 div()
1571 .id(("filter-value", idx))
1572 .h(px(22.0))
1573 .flex()
1574 .items_center()
1575 .gap(px(6.0))
1576 .px(px(4.0))
1577 .rounded(px(4.0))
1578 .cursor_pointer()
1579 .hover(move |style| style.bg(c_hover))
1580 .child(checkbox(row.checked))
1581 .child(div().text_color(c_fg).child(row.label.clone()))
1582 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1583 st_val.update(cx, |s, cx| {
1584 s.toggle_filter_value(idx);
1585 cx.notify();
1586 });
1587 })
1588 .into_any_element(),
1589 );
1590 }
1591 let truncated = visible.len() > FILTER_PANEL_MAX_ROWS;
1592 let value_list = div()
1593 .id("filter-value-list")
1594 .flex()
1595 .flex_col()
1596 .max_h(px(180.0))
1597 .overflow_y_scroll()
1598 .children(value_rows)
1599 .children(truncated.then(|| {
1600 div()
1601 .text_color(c_muted)
1602 .text_size(px(11.0))
1603 .child("Refine search to see more…")
1604 }));
1605
1606 let st_clear = state.clone();
1608 let st_close = state.clone();
1609 let clear_bg = if filter_active { c_hover } else { c_bg };
1610 let clear_fg = if filter_active { c_fg } else { c_muted };
1611 let clear_border = if filter_active { c_line } else { c_muted };
1612 let buttons_row = div()
1613 .flex()
1614 .gap(px(6.0))
1615 .child(
1616 div()
1617 .flex_1()
1618 .h(px(28.0))
1619 .flex()
1620 .items_center()
1621 .justify_center()
1622 .border_1()
1623 .border_color(clear_border)
1624 .bg(clear_bg)
1625 .text_color(clear_fg)
1626 .cursor_pointer()
1627 .child("Clear Filter")
1628 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1629 if !filter_active {
1630 return;
1631 }
1632 st_clear.update(cx, |s, cx| {
1633 s.clear_filter_panel();
1634 cx.notify();
1635 });
1636 }),
1637 )
1638 .child(
1639 div()
1640 .flex_1()
1641 .h(px(28.0))
1642 .flex()
1643 .items_center()
1644 .justify_center()
1645 .border_1()
1646 .border_color(c_line)
1647 .bg(c_hover)
1648 .cursor_pointer()
1649 .child("Close")
1650 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1651 st_close.update(cx, |s, cx| {
1652 s.filter_panel = None;
1653 cx.notify();
1654 });
1655 }),
1656 );
1657
1658 let panel_body = div()
1659 .flex()
1660 .flex_col()
1661 .w(px(FILTER_PANEL_WIDTH))
1662 .p(px(10.0))
1663 .gap(px(8.0))
1664 .bg(c_bg)
1665 .border_1()
1666 .border_color(c_line)
1667 .text_color(c_fg)
1668 .text_size(px(13.0))
1669 .child(div().text_color(c_muted).text_size(px(11.0)).child("Sort"))
1670 .child(sort_row)
1671 .child(
1672 div()
1673 .text_color(c_muted)
1674 .text_size(px(11.0))
1675 .child("Filter"),
1676 )
1677 .child(op_button)
1678 .children(op_menu)
1679 .children(operands)
1680 .child(search_box)
1681 .child(select_all_row)
1682 .child(value_list)
1683 .child(buttons_row);
1684
1685 let st_backdrop = state.clone();
1686 let overlay = deferred(
1687 anchored()
1688 .anchor(Corner::BottomLeft)
1689 .position(point(px(abs_x), px(abs_y)))
1690 .child(
1691 div()
1692 .occlude()
1693 .child(crate::grid::motion::pop_in(
1694 panel_body,
1695 "grid-filter-panel",
1696 animations,
1697 ))
1698 .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
1699 st_backdrop.update(cx, |s, cx| {
1700 if s.filter_panel.is_some() {
1701 s.filter_panel = None;
1702 cx.notify();
1703 }
1704 });
1705 }),
1706 ),
1707 )
1708 .with_priority(CONTEXT_MENU_PRIORITY);
1709
1710 Some(overlay)
1711}
1712
1713fn render_busy_overlay(
1719 state: &Entity<GridState>,
1720 cx: &mut Context<SqllyDataTable>,
1721) -> Option<impl IntoElement> {
1722 let s = state.read(cx);
1723 let busy = s.busy.clone()?;
1724 let theme = s.theme.clone();
1725 let animations = s.config.animations;
1726 let track = theme.grid_line;
1727 let accent = theme.sort_indicator;
1728
1729 let bar: gpui::AnyElement = if let Some(p) = busy.progress {
1730 let p = p.clamp(0.0, 1.0);
1731 div()
1732 .h_full()
1733 .w(relative(p))
1734 .rounded(px(3.0))
1735 .bg(accent)
1736 .into_any_element()
1737 } else {
1738 div()
1739 .h_full()
1740 .w(relative(0.3))
1741 .rounded(px(3.0))
1742 .bg(accent)
1743 .with_animation(
1744 "busy-indeterminate",
1745 Animation::new(std::time::Duration::from_millis(900))
1746 .repeat()
1747 .with_easing(pulsating_between(0.15, 0.85)),
1748 |el, delta| el.w(relative(delta)),
1749 )
1750 .into_any_element()
1751 };
1752
1753 let card = div()
1754 .flex()
1755 .flex_col()
1756 .gap(px(10.0))
1757 .p(px(16.0))
1758 .min_w(px(220.0))
1759 .rounded(px(8.0))
1760 .bg(theme.menu_bg)
1761 .border_1()
1762 .border_color(theme.grid_line)
1763 .child(
1764 div()
1765 .text_color(theme.menu_fg)
1766 .text_size(px(14.0))
1767 .child(busy.label.clone()),
1768 )
1769 .child(
1770 div()
1771 .w_full()
1772 .h(px(6.0))
1773 .rounded(px(3.0))
1774 .bg(track)
1775 .child(bar),
1776 );
1777
1778 let overlay = div()
1779 .absolute()
1780 .top_0()
1781 .left_0()
1782 .size_full()
1783 .occlude()
1784 .flex()
1785 .items_center()
1786 .justify_center()
1787 .bg(theme.overlay_scrim)
1788 .child(card);
1789
1790 Some(crate::grid::motion::fade_in(
1791 overlay,
1792 "grid-busy-overlay",
1793 crate::grid::motion::SCRIM_ENTER_MS,
1794 animations,
1795 ))
1796}
1797
1798enum MenuDispatch {
1801 Builtin(menu::MenuAction, usize),
1802 Custom(
1803 String,
1804 Option<crate::grid::context_menu::ContextMenuRequest>,
1805 ),
1806}