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