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::PivotState;
21use crate::pivot::widget::PivotGrid;
22
23use gpui::{
24 anchored, canvas, deferred, div, hsla, point, pulsating_between, px, relative, Animation,
25 AnimationExt, App, AppContext, Context, Corner, Div, Entity, FocusHandle, Focusable,
26 InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
27 MouseUpEvent, ParentElement, Render, ScrollWheelEvent, StatefulInteractiveElement, Styled,
28 Window,
29};
30use std::sync::Arc;
31
32const CONTEXT_MENU_PRIORITY: usize = 1_000_000;
39
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42pub enum GridTab {
43 #[default]
45 Grid,
46 Pivot,
48}
49
50pub(crate) struct PivotParts {
52 pub(crate) state: Entity<PivotState>,
53 grid: Entity<PivotGrid>,
54 sidebar: Entity<PivotSidebar>,
55}
56
57pub struct SqllyDataTable {
59 pub state: Entity<GridState>,
60 pub(crate) pivot: Option<PivotParts>,
63 active_tab: GridTab,
65 follow_system_appearance: bool,
69 appearance_subscription: Option<gpui::Subscription>,
73}
74
75impl SqllyDataTable {
76 #[must_use]
78 pub fn new(state: Entity<GridState>) -> Self {
79 Self {
80 state,
81 pivot: None,
82 active_tab: GridTab::Grid,
83 follow_system_appearance: true,
84 appearance_subscription: None,
85 }
86 }
87
88 #[must_use]
90 pub fn builder(data: GridData) -> SqllyDataTableBuilder {
91 SqllyDataTableBuilder {
92 data,
93 config: GridConfig::default(),
94 context_menu_provider: None,
95 theme: None,
96 debug_bar: false,
97 pivot: None,
98 pivot_context_menu_provider: None,
99 }
100 }
101
102 #[must_use]
106 pub fn pivot_state(&self) -> Option<&Entity<PivotState>> {
107 self.pivot.as_ref().map(|p| &p.state)
108 }
109
110 #[must_use]
112 pub fn active_tab(&self) -> GridTab {
113 self.active_tab
114 }
115
116 pub fn set_active_tab(&mut self, tab: GridTab, cx: &mut App) {
121 if tab == GridTab::Pivot {
122 if self.pivot.is_none() {
123 return;
124 }
125 self.sync_pivot_source(cx);
126 }
127 self.active_tab = tab;
128 }
129
130 pub fn enable_pivot(&mut self, config: PivotConfig, cx: &mut App) {
134 if let Some(parts) = &self.pivot {
135 parts.state.update(cx, |s, cx| {
136 s.config = config;
137 s.recompute();
138 cx.notify();
139 });
140 return;
141 }
142 self.pivot = Some(build_pivot_parts(&self.state, config, None, cx));
143 }
144
145 pub fn disable_pivot(&mut self) {
147 self.pivot = None;
148 self.active_tab = GridTab::Grid;
149 }
150
151 pub fn set_pivot_context_menu_provider(
156 &mut self,
157 provider: impl PivotContextMenuProvider + 'static,
158 cx: &mut App,
159 ) {
160 if let Some(parts) = &self.pivot {
161 parts.state.update(cx, |s, _cx| {
162 s.set_context_menu_provider(provider);
163 });
164 }
165 }
166
167 fn sync_pivot_source(&self, cx: &mut App) {
170 let Some(parts) = &self.pivot else {
171 return;
172 };
173 let (columns, rows) = {
174 let s = self.state.read(cx);
175 (s.data.columns.clone(), Arc::clone(&s.data_rows))
176 };
177 parts.state.update(cx, |ps, cx| {
178 if ps.source_differs(&rows) {
179 ps.set_source(columns, rows);
180 cx.notify();
181 }
182 });
183 }
184}
185
186fn build_pivot_parts(
188 grid_state: &Entity<GridState>,
189 config: PivotConfig,
190 menu_provider: Option<PivotContextMenuProviderHandle>,
191 cx: &mut App,
192) -> PivotParts {
193 let (columns, rows, formats, key_bindings, theme) = {
194 let s = grid_state.read(cx);
195 (
196 s.data.columns.clone(),
197 Arc::clone(&s.data_rows),
198 s.resolved_formats.clone(),
199 s.config.key_bindings.clone(),
200 s.theme.clone(),
201 )
202 };
203 let focus = cx.focus_handle();
204 let state = cx.new(|_| {
205 let mut ps = PivotState::new(columns, rows, formats, config, key_bindings, focus);
206 ps.theme = theme;
207 ps.context_menu_provider = menu_provider;
208 ps
209 });
210 let grid = cx.new(|_| PivotGrid::new(state.clone()));
211 let sidebar = cx.new(|_| PivotSidebar::new(state.clone()));
212 PivotParts {
213 state,
214 grid,
215 sidebar,
216 }
217}
218
219pub struct SqllyDataTableBuilder {
221 data: GridData,
222 config: GridConfig,
223 context_menu_provider: Option<ContextMenuProviderHandle>,
224 theme: Option<GridTheme>,
225 debug_bar: bool,
226 pivot: Option<PivotConfig>,
227 pivot_context_menu_provider: Option<PivotContextMenuProviderHandle>,
228}
229
230impl SqllyDataTableBuilder {
231 #[must_use]
233 pub fn config(mut self, config: GridConfig) -> Self {
234 self.config = config;
235 self
236 }
237
238 #[must_use]
241 pub fn theme(mut self, theme: GridTheme) -> Self {
242 self.theme = Some(theme);
243 self
244 }
245
246 #[must_use]
253 pub fn context_menu_provider(mut self, provider: impl ContextMenuProvider + 'static) -> Self {
254 self.context_menu_provider = Some(ContextMenuProviderHandle::new(provider));
255 self
256 }
257
258 #[must_use]
262 pub fn debug_bar(mut self, enabled: bool) -> Self {
263 self.debug_bar = enabled;
264 self
265 }
266
267 #[must_use]
273 pub fn pivot(mut self, config: PivotConfig) -> Self {
274 self.pivot = Some(config);
275 self
276 }
277
278 #[must_use]
284 pub fn pivot_context_menu_provider(
285 mut self,
286 provider: impl PivotContextMenuProvider + 'static,
287 ) -> Self {
288 self.pivot_context_menu_provider = Some(PivotContextMenuProviderHandle::new(provider));
289 self
290 }
291
292 pub fn build(self, cx: &mut App) -> SqllyDataTable {
294 let focus = cx.focus_handle();
295 let provider = self.context_menu_provider;
296 let theme_override = self.theme;
297 let debug_bar = self.debug_bar;
298 let pivot_config = self.pivot;
299 let follow_system_appearance = theme_override.is_none();
300 let state = cx.new(|cx| {
301 let mut s = GridState::new(self.data, self.config, focus.clone());
302 s.context_menu_provider = provider;
303 s.debug_bar_enabled = debug_bar;
304 s.self_weak = Some(cx.weak_entity());
305 if let Some(theme) = theme_override {
306 s.theme = theme;
307 }
308 s
309 });
310 let pivot_menu_provider = self.pivot_context_menu_provider;
311 let pivot = pivot_config.map(|cfg| build_pivot_parts(&state, cfg, pivot_menu_provider, cx));
312 SqllyDataTable {
313 state,
314 pivot,
315 active_tab: GridTab::Grid,
316 follow_system_appearance,
317 appearance_subscription: None,
318 }
319 }
320}
321
322impl Focusable for SqllyDataTable {
323 fn focus_handle(&self, cx: &App) -> FocusHandle {
324 self.state.read(cx).focus_handle.clone()
325 }
326}
327
328impl Render for SqllyDataTable {
329 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
330 if self.follow_system_appearance && self.appearance_subscription.is_none() {
335 let initial = GridTheme::for_appearance(window.appearance());
336 self.state.update(cx, |s, _cx| s.theme = initial);
337 let state_appearance = self.state.clone();
338 self.appearance_subscription =
339 Some(window.observe_window_appearance(move |window, cx| {
340 let theme = GridTheme::for_appearance(window.appearance());
341 state_appearance.update(cx, |s, cx| {
342 s.theme = theme;
343 cx.notify();
344 });
345 }));
346 }
347
348 if let Some(parts) = &self.pivot {
351 let grid_theme = self.state.read(cx).theme.clone();
352 parts.state.update(cx, |s, cx| {
353 if s.theme != grid_theme {
354 s.theme = grid_theme;
355 cx.notify();
356 }
357 });
358 }
359
360 if let Some(parts) = &self.pivot {
365 let drill = parts.state.update(cx, |s, _cx| s.take_pending_drill_down());
366 if let Some(filter_sets) = drill {
367 self.state.update(cx, |g, cx| {
368 if g.window.is_none() {
372 for filter in &mut g.filters {
373 *filter = ColumnFilter::default();
374 }
375 for (field, values) in filter_sets {
376 if let Some(slot) = g.filters.get_mut(field) {
377 *slot = ColumnFilter {
378 predicate: FilterPredicate::None,
379 values: Some(values),
380 };
381 }
382 }
383 g.recompute();
384 }
385 cx.notify();
386 });
387 self.active_tab = GridTab::Grid;
388 let focus = self.state.read(cx).focus_handle.clone();
389 window.focus(&focus);
390 cx.notify();
391 }
392 }
393
394 let grid_view = self.render_grid_view(cx);
395
396 let Some(parts) = &self.pivot else {
397 return div().size_full().child(grid_view);
398 };
399
400 let theme = self.state.read(cx).theme.clone();
401 let tab = |label: &'static str, this_tab: GridTab, active: bool, cx: &mut Context<Self>| {
402 div()
403 .px(px(14.0))
404 .py(px(5.0))
405 .text_size(px(13.0))
406 .cursor_pointer()
407 .bg(if active { theme.bg } else { theme.header_bg })
408 .text_color(if active {
409 theme.header_fg
410 } else {
411 theme.muted_text
412 })
413 .border_b_2()
414 .border_color(if active {
415 theme.sort_indicator
416 } else {
417 theme.header_bg
418 })
419 .child(label)
420 .on_mouse_down(
421 MouseButton::Left,
422 cx.listener(move |this, _e: &MouseDownEvent, window, cx| {
423 if this.active_tab == this_tab {
424 return;
425 }
426 this.set_active_tab(this_tab, cx);
427 match this_tab {
429 GridTab::Grid => {
430 let focus = this.state.read(cx).focus_handle.clone();
431 window.focus(&focus);
432 }
433 GridTab::Pivot => {
434 if let Some(p) = &this.pivot {
435 let focus = p.state.read(cx).focus_handle.clone();
436 window.focus(&focus);
437 }
438 }
439 }
440 cx.notify();
441 }),
442 )
443 };
444
445 let tab_bar = div()
446 .flex()
447 .flex_row()
448 .flex_none()
449 .bg(theme.header_bg)
450 .border_b_1()
451 .border_color(theme.grid_line)
452 .child(tab(
453 "Grid",
454 GridTab::Grid,
455 self.active_tab == GridTab::Grid,
456 cx,
457 ))
458 .child(tab(
459 "Pivot",
460 GridTab::Pivot,
461 self.active_tab == GridTab::Pivot,
462 cx,
463 ));
464
465 let content: gpui::AnyElement = match self.active_tab {
466 GridTab::Grid => grid_view.into_any_element(),
467 GridTab::Pivot => div()
468 .flex()
469 .flex_row()
470 .size_full()
471 .child(parts.sidebar.clone())
472 .child(div().flex_1().min_w_0().child(parts.grid.clone()))
473 .into_any_element(),
474 };
475
476 div()
477 .flex()
478 .flex_col()
479 .size_full()
480 .child(tab_bar)
481 .child(div().flex_1().min_h_0().child(content))
482 }
483}
484
485impl SqllyDataTable {
486 fn render_grid_view(&mut self, cx: &mut Context<Self>) -> Div {
489 let state_canvas = self.state.clone();
490 let state_status = self.state.clone();
491 let state_mouse = self.state.clone();
492 let state_move = self.state.clone();
493 let state_up = self.state.clone();
494 let state_scroll = self.state.clone();
495 let state_key = self.state.clone();
496 let state_right = self.state.clone();
497 let bg = self.state.read(cx).theme.bg;
498 let focus_handle = self.state.read(cx).focus_handle.clone();
499 let focus_left = focus_handle.clone();
500 let focus_right = focus_handle.clone();
501 let debug_bar = self.state.read(cx).debug_bar_enabled;
502 let status_h = self.state.read(cx).status_bar_height;
503
504 if let Some((action, col)) = self.state.read(cx).pending_action {
507 self.state.update(cx, |s, cx| {
508 s.execute_action(action, col, cx);
509 s.pending_action = None;
510 });
511 }
512
513 if let Some(pending) = self
515 .state
516 .read(cx)
517 .pending_custom_context_menu_action
518 .clone()
519 {
520 self.state.update(cx, |s, cx| {
521 s.pending_custom_context_menu_action = None;
522 s.execute_custom_context_menu_action(pending, cx);
523 });
524 }
525
526 if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
534 self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
535 let state_edge = self.state.clone();
536 cx.spawn(async move |_weak, cx| {
537 loop {
538 gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
539 let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
540 if let Ok(true) = res {
541 let _ = state_edge.update(cx, |_s, cx| cx.notify());
542 }
543 let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
544 if !matches!(dragging_res, Ok(true)) {
545 break;
546 }
547 }
548 let _ =
549 cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
550 })
551 .detach();
552 }
553
554 div()
555 .flex()
556 .flex_col()
557 .size_full()
558 .relative()
559 .track_focus(&focus_handle)
560 .bg(bg)
561 .child(
562 canvas(
563 move |bounds, window, cx| -> PaintData {
564 let viewport = window.viewport_size();
565 state_canvas.update(cx, |s, cx| {
566 let mut dirty = false;
567 if s.bounds != bounds {
568 s.bounds = bounds;
569 s.clamp_scroll_to_bounds();
570 dirty = true;
571 }
572 if s.window_viewport != viewport {
573 s.window_viewport = viewport;
574 }
575 if dirty {
576 cx.notify();
577 }
578 });
579 let s = state_canvas.read(cx);
580 PaintData::from_state(s)
581 },
582 move |bounds, data, window, cx| {
583 paint_grid(&data, window, cx, bounds);
584 },
585 )
586 .flex_1(),
587 )
588 .children(debug_bar.then(|| {
589 canvas(
590 move |_bounds, _window, cx| -> StatusBarData {
591 let s = state_status.read(cx);
592 StatusBarData::from_state(s)
593 },
594 move |bounds, data, window, cx| {
595 paint_status_bar(&data, window, cx, bounds);
596 },
597 )
598 .h(px(status_h))
599 }))
600 .children(render_context_menu_overlay(&self.state, cx))
601 .children(render_filter_panel_overlay(&self.state, cx))
602 .children(render_busy_overlay(&self.state, cx))
603 .on_mouse_down(
604 MouseButton::Left,
605 move |event: &MouseDownEvent, window, cx| {
606 window.focus(&focus_left);
607 state_mouse.update(cx, |s, cx| {
608 if s.busy.is_some() {
611 return;
612 }
613 let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
619 if s.context_menu.is_some() || s.filter_panel.is_some() {
620 s.context_menu = None;
621 s.filter_panel = None;
622 }
623 s.handle_mouse_down_with_modifiers(
624 rel,
625 event.modifiers.shift,
626 event.modifiers.platform,
627 );
628 cx.notify();
629 });
630 },
631 )
632 .on_mouse_down(
633 MouseButton::Right,
634 move |event: &MouseDownEvent, window, cx| {
635 window.focus(&focus_right);
636 state_right.update(cx, |s, cx| {
637 if s.busy.is_some() {
638 return;
639 }
640 let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
641 let hit = s.hit_test(pos);
642
643 if s.context_menu_provider.is_none() {
645 match hit {
646 HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
647 s.open_context_menu(col, pos);
648 }
649 _ => {
650 s.context_menu = None;
651 s.filter_panel = None;
652 }
653 }
654 cx.notify();
655 return;
656 }
657
658 let Some(target) = s.context_menu_target_from_hit(hit) else {
660 s.context_menu = None;
661 s.filter_panel = None;
662 cx.notify();
663 return;
664 };
665
666 let effective = s.effective_selection_for_context_target(&target);
667 if effective != s.selection {
668 s.selection = effective.clone();
669 }
670
671 let request = s.build_context_menu_request(target, &effective);
672 let col = request.target.column_index().unwrap_or(0);
673
674 let Some(provider) = s.context_menu_provider.clone() else {
675 return;
676 };
677 let public_items = provider.menu_items(&request);
678 let items = GridState::convert_context_menu_items(public_items);
679
680 if items.is_empty() {
681 s.context_menu = None;
682 } else {
683 s.context_menu =
684 Some(menu::ContextMenu::custom(col, pos, items, request));
685 }
686 s.filter_panel = None;
687 cx.notify();
688 });
689 },
690 )
691 .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
692 state_move.update(cx, |s, cx| {
693 let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
694 s.handle_mouse_move(rel, event.pressed_button);
695 cx.notify();
696 });
697 })
698 .on_mouse_up(
699 MouseButton::Left,
700 move |_event: &MouseUpEvent, _window, cx| {
701 state_up.update(cx, |s, cx| {
702 s.handle_mouse_up();
703 cx.notify();
704 });
705 },
706 )
707 .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
708 state_scroll.update(cx, |s, cx| {
709 let line_h = px(s.row_height);
710 let delta = event.delta.pixel_delta(line_h);
711 let scroll = s.scroll_handle.offset();
712 let (mx, my) = s.max_scroll();
713 let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
714 let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
715 s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
716 if s.drag_start.is_some() {
717 s.handle_scroll_drag();
718 }
719 cx.notify();
720 });
721 })
722 .on_key_down(move |event: &KeyDownEvent, _window, cx| {
723 let ks = &event.keystroke;
724 if ks.modifiers.platform && ks.key == "q" {
725 cx.quit();
726 return;
727 }
728 state_key.update(cx, |s, cx| {
729 let kb = &s.config.key_bindings;
730 if kb.select_all.matches(ks) {
731 s.select_all();
732 } else if kb.copy.matches(ks) {
733 s.copy_selection(false, cx);
734 } else if kb.copy_with_headers.matches(ks) {
735 s.copy_selection(true, cx);
736 } else if kb.page_up.matches(ks) {
737 s.page_up();
738 } else if kb.page_down.matches(ks) {
739 s.page_down();
740 } else {
741 s.handle_key(ks);
742 }
743 cx.notify();
744 });
745 })
746 }
747}
748
749fn render_context_menu_overlay(
761 state: &Entity<GridState>,
762 cx: &mut Context<SqllyDataTable>,
763) -> Option<impl IntoElement> {
764 let s = state.read(cx);
765 let menu = s.context_menu.clone()?;
766 let theme = s.theme.clone();
767 let cw = s.char_width;
768 let grid_ox = f32::from(s.bounds.origin.x);
769 let grid_oy = f32::from(s.bounds.origin.y);
770 let viewport = s.window_viewport;
771 let vw = f32::from(viewport.width);
772 let vh = f32::from(viewport.height);
773
774 let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
775 let abs_x = grid_ox + f32::from(resolved.x);
776 let abs_y = grid_oy + f32::from(resolved.y);
777 let menu_w = menu.width_for(cw);
778
779 let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
782 let mut selectable_idx = 0usize;
783 for item in &menu.items {
784 match item {
785 MenuItem::Separator => {
786 rows.push(
787 div()
788 .h(px(menu::MENU_ITEM_HEIGHT))
789 .flex()
790 .items_center()
791 .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
792 .into_any_element(),
793 );
794 }
795 MenuItem::Action(_) | MenuItem::Custom { .. } => {
796 let this_idx = selectable_idx;
797 selectable_idx += 1;
798 let label = item.label().unwrap_or("").to_owned();
799 let hovered = menu.hovered == Some(this_idx);
800
801 let action = match item {
805 MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
806 MenuItem::Custom { id, .. } => {
807 MenuDispatch::Custom(id.clone(), menu.request.clone())
808 }
809 MenuItem::Separator => unreachable!(),
810 };
811
812 let state_click = state.clone();
813 let state_hover = state.clone();
814 let mut row = div()
815 .h(px(menu::MENU_ITEM_HEIGHT))
816 .px(px(menu::MENU_PADDING_X))
817 .flex()
818 .items_center()
819 .text_color(theme.menu_fg)
820 .text_size(px(menu::MENU_FONT_SIZE))
821 .child(label)
822 .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
823 state_hover.update(cx, |s, cx| {
824 if let Some(m) = s.context_menu.as_mut() {
825 if m.hovered != Some(this_idx) {
826 m.hovered = Some(this_idx);
827 cx.notify();
828 }
829 }
830 });
831 })
832 .on_mouse_down(
833 MouseButton::Left,
834 move |_e: &MouseDownEvent, _window, cx| {
835 state_click.update(cx, |s, cx| {
836 match &action {
837 MenuDispatch::Builtin(a, col) => {
838 s.pending_action = Some((*a, *col));
839 }
840 MenuDispatch::Custom(id, request) => {
841 if let Some(request) = request {
842 s.pending_custom_context_menu_action =
843 Some(PendingCustomContextMenuAction {
844 id: id.clone(),
845 request: request.clone(),
846 });
847 }
848 }
849 }
850 s.context_menu = None;
851 cx.notify();
852 });
853 },
854 );
855 if hovered {
856 row = row.bg(theme.menu_hover_bg);
857 }
858 rows.push(row.into_any_element());
859 }
860 }
861 }
862
863 let menu_body = div()
864 .flex()
865 .flex_col()
866 .w(px(menu_w))
867 .py(px(menu::MENU_INNER_PAD))
868 .bg(theme.menu_bg)
869 .border_1()
870 .border_color(theme.grid_line)
871 .children(rows);
872
873 let state_backdrop = state.clone();
876 let overlay = deferred(anchored().position(point(px(abs_x), px(abs_y))).child(
877 div().occlude().child(menu_body).on_mouse_down_out(
878 move |_e: &MouseDownEvent, _window, cx| {
879 state_backdrop.update(cx, |s, cx| {
880 if s.context_menu.is_some() {
881 s.context_menu = None;
882 s.filter_panel = None;
883 cx.notify();
884 }
885 });
886 },
887 ),
888 ))
889 .with_priority(CONTEXT_MENU_PRIORITY);
890
891 Some(overlay)
892}
893
894const FILTER_PANEL_WIDTH: f32 = 300.0;
896const FILTER_PANEL_MAX_ROWS: usize = 200;
898
899#[allow(clippy::too_many_lines)]
904fn render_filter_panel_overlay(
905 state: &Entity<GridState>,
906 cx: &mut Context<SqllyDataTable>,
907) -> Option<impl IntoElement> {
908 let s = state.read(cx);
909 let panel = s.filter_panel.clone()?;
910 let theme = s.theme.clone();
911 let col = panel.col;
912 let current_sort = s.sort;
913 let filter_active = s.filters.get(col).is_some_and(|f| f.is_active());
914 let grid_ox = f32::from(s.bounds.origin.x);
915 let grid_oy = f32::from(s.bounds.origin.y);
916
917 let abs_x = grid_ox + f32::from(panel.anchor.x);
922 let abs_y = grid_oy + f32::from(panel.anchor.y);
923
924 let c_bg = theme.menu_bg;
926 let c_line = theme.grid_line;
927 let c_fg = theme.menu_fg;
928 let c_accent = theme.sort_indicator;
929 let c_hover = theme.menu_hover_bg;
930 let c_muted = theme.muted_text;
931
932 let checkbox = move |checked: bool| {
933 let mut b = div()
934 .w(px(14.0))
935 .h(px(14.0))
936 .border_1()
937 .border_color(c_line)
938 .bg(c_bg)
939 .flex()
940 .items_center()
941 .justify_center();
942 if checked {
943 b = b.child(div().w(px(8.0)).h(px(8.0)).bg(c_accent));
944 }
945 b
946 };
947
948 let (asc_active, desc_active) = match current_sort {
950 Some((c, SortDirection::Ascending)) if c == col => (true, false),
951 Some((c, SortDirection::Descending)) if c == col => (false, true),
952 _ => (false, false),
953 };
954 let st_asc = state.clone();
955 let st_desc = state.clone();
956 let sort_row = div()
957 .flex()
958 .gap(px(6.0))
959 .child(
960 div()
961 .flex_1()
962 .h(px(26.0))
963 .flex()
964 .items_center()
965 .justify_center()
966 .border_1()
967 .border_color(c_line)
968 .bg(if asc_active { c_accent } else { c_hover })
969 .cursor_pointer()
970 .child("Ascending")
971 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
972 st_asc.update(cx, |s, cx| {
973 s.set_panel_sort(SortDirection::Ascending);
974 cx.notify();
975 });
976 }),
977 )
978 .child(
979 div()
980 .flex_1()
981 .h(px(26.0))
982 .flex()
983 .items_center()
984 .justify_center()
985 .border_1()
986 .border_color(c_line)
987 .bg(if desc_active { c_accent } else { c_hover })
988 .cursor_pointer()
989 .child("Descending")
990 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
991 st_desc.update(cx, |s, cx| {
992 s.set_panel_sort(SortDirection::Descending);
993 cx.notify();
994 });
995 }),
996 );
997
998 let st_op_toggle = state.clone();
1000 let op_button = div()
1001 .h(px(26.0))
1002 .px(px(8.0))
1003 .flex()
1004 .items_center()
1005 .border_1()
1006 .border_color(c_line)
1007 .bg(c_bg)
1008 .cursor_pointer()
1009 .child(panel.current_op_label())
1010 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1011 st_op_toggle.update(cx, |s, cx| {
1012 s.toggle_filter_op_menu();
1013 cx.notify();
1014 });
1015 });
1016
1017 let op_menu = panel.op_menu_open.then(|| {
1018 let mut items: Vec<gpui::AnyElement> = Vec::new();
1019 for (i, label) in panel.op_labels().iter().enumerate() {
1020 let selected = i == panel.op_index;
1021 let st_pick = state.clone();
1022 items.push(
1023 div()
1024 .h(px(24.0))
1025 .px(px(8.0))
1026 .flex()
1027 .items_center()
1028 .bg(if selected { c_accent } else { c_bg })
1029 .cursor_pointer()
1030 .child(*label)
1031 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1032 st_pick.update(cx, |s, cx| {
1033 s.set_filter_operator(i);
1034 cx.notify();
1035 });
1036 })
1037 .into_any_element(),
1038 );
1039 }
1040 div()
1041 .flex()
1042 .flex_col()
1043 .border_1()
1044 .border_color(c_line)
1045 .bg(c_bg)
1046 .children(items)
1047 });
1048
1049 let operand_field = |value: &str, focused: bool, placeholder: &str, input: FilterInput| {
1051 let st_focus = state.clone();
1052 let (text, is_placeholder) = if value.is_empty() {
1053 (placeholder.to_owned(), true)
1054 } else {
1055 (value.to_owned(), false)
1056 };
1057 div()
1058 .h(px(26.0))
1059 .px(px(6.0))
1060 .flex()
1061 .items_center()
1062 .gap(px(2.0))
1063 .border_1()
1064 .border_color(if focused { c_accent } else { c_line })
1065 .bg(c_bg)
1066 .cursor_pointer()
1067 .child(
1068 div()
1069 .text_color(if is_placeholder { c_muted } else { c_fg })
1070 .child(text),
1071 )
1072 .children(focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1073 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1074 st_focus.update(cx, |s, cx| {
1075 s.set_filter_focus(input);
1076 cx.notify();
1077 });
1078 })
1079 };
1080
1081 let operand_placeholder = if panel.kind == crate::data::ColumnKind::Date {
1082 "YYYY-MM-DD"
1083 } else if crate::filter::uses_number_ops(panel.kind) {
1084 "value"
1085 } else if panel.op_index == 7 {
1086 "regex"
1088 } else {
1089 "value"
1090 };
1091 let operands = panel.needs_operand().then(|| {
1092 let mut row = div().flex().flex_col().gap(px(4.0)).child(operand_field(
1093 &panel.operand_a.value,
1094 panel.focus == FilterInput::OperandA,
1095 operand_placeholder,
1096 FilterInput::OperandA,
1097 ));
1098 if panel.needs_second_operand() {
1099 row = row
1100 .child(div().text_color(c_muted).text_size(px(11.0)).child("and"))
1101 .child(operand_field(
1102 &panel.operand_b.value,
1103 panel.focus == FilterInput::OperandB,
1104 operand_placeholder,
1105 FilterInput::OperandB,
1106 ));
1107 }
1108 row
1109 });
1110
1111 let st_search = state.clone();
1113 let search_focused = panel.focus == FilterInput::Search;
1114 let (search_text, search_is_ph) = if panel.search.value.is_empty() {
1115 ("Search".to_owned(), true)
1116 } else {
1117 (panel.search.value.clone(), false)
1118 };
1119 let search_box = div()
1120 .h(px(26.0))
1121 .px(px(6.0))
1122 .flex()
1123 .items_center()
1124 .gap(px(2.0))
1125 .border_1()
1126 .border_color(if search_focused { c_accent } else { c_line })
1127 .bg(c_bg)
1128 .cursor_pointer()
1129 .child(
1130 div()
1131 .text_color(if search_is_ph { c_muted } else { c_fg })
1132 .child(search_text),
1133 )
1134 .children(search_focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1135 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1136 st_search.update(cx, |s, cx| {
1137 s.set_filter_focus(FilterInput::Search);
1138 cx.notify();
1139 });
1140 });
1141
1142 let st_all = state.clone();
1144 let select_all_row = div()
1145 .h(px(24.0))
1146 .flex()
1147 .items_center()
1148 .gap(px(6.0))
1149 .cursor_pointer()
1150 .child(checkbox(panel.all_checked()))
1151 .child("(Select All)")
1152 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1153 st_all.update(cx, |s, cx| {
1154 s.toggle_filter_select_all();
1155 cx.notify();
1156 });
1157 });
1158
1159 let visible = panel.visible_indices();
1160 let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
1161 for &idx in visible.iter().take(FILTER_PANEL_MAX_ROWS) {
1162 let row = &panel.distinct[idx];
1163 let st_val = state.clone();
1164 value_rows.push(
1165 div()
1166 .h(px(22.0))
1167 .flex()
1168 .items_center()
1169 .gap(px(6.0))
1170 .cursor_pointer()
1171 .child(checkbox(row.checked))
1172 .child(div().text_color(c_fg).child(row.label.clone()))
1173 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1174 st_val.update(cx, |s, cx| {
1175 s.toggle_filter_value(idx);
1176 cx.notify();
1177 });
1178 })
1179 .into_any_element(),
1180 );
1181 }
1182 let truncated = visible.len() > FILTER_PANEL_MAX_ROWS;
1183 let value_list = div()
1184 .id("filter-value-list")
1185 .flex()
1186 .flex_col()
1187 .max_h(px(180.0))
1188 .overflow_y_scroll()
1189 .children(value_rows)
1190 .children(truncated.then(|| {
1191 div()
1192 .text_color(c_muted)
1193 .text_size(px(11.0))
1194 .child("Refine search to see more…")
1195 }));
1196
1197 let st_clear = state.clone();
1199 let st_close = state.clone();
1200 let clear_bg = if filter_active { c_hover } else { c_bg };
1201 let clear_fg = if filter_active { c_fg } else { c_muted };
1202 let clear_border = if filter_active { c_line } else { c_muted };
1203 let buttons_row = div()
1204 .flex()
1205 .gap(px(6.0))
1206 .child(
1207 div()
1208 .flex_1()
1209 .h(px(28.0))
1210 .flex()
1211 .items_center()
1212 .justify_center()
1213 .border_1()
1214 .border_color(clear_border)
1215 .bg(clear_bg)
1216 .text_color(clear_fg)
1217 .cursor_pointer()
1218 .child("Clear Filter")
1219 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1220 if !filter_active {
1221 return;
1222 }
1223 st_clear.update(cx, |s, cx| {
1224 s.clear_filter_panel();
1225 cx.notify();
1226 });
1227 }),
1228 )
1229 .child(
1230 div()
1231 .flex_1()
1232 .h(px(28.0))
1233 .flex()
1234 .items_center()
1235 .justify_center()
1236 .border_1()
1237 .border_color(c_line)
1238 .bg(c_hover)
1239 .cursor_pointer()
1240 .child("Close")
1241 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1242 st_close.update(cx, |s, cx| {
1243 s.filter_panel = None;
1244 cx.notify();
1245 });
1246 }),
1247 );
1248
1249 let panel_body = div()
1250 .flex()
1251 .flex_col()
1252 .w(px(FILTER_PANEL_WIDTH))
1253 .p(px(10.0))
1254 .gap(px(8.0))
1255 .bg(c_bg)
1256 .border_1()
1257 .border_color(c_line)
1258 .text_color(c_fg)
1259 .text_size(px(13.0))
1260 .child(div().text_color(c_muted).text_size(px(11.0)).child("Sort"))
1261 .child(sort_row)
1262 .child(
1263 div()
1264 .text_color(c_muted)
1265 .text_size(px(11.0))
1266 .child("Filter"),
1267 )
1268 .child(op_button)
1269 .children(op_menu)
1270 .children(operands)
1271 .child(search_box)
1272 .child(select_all_row)
1273 .child(value_list)
1274 .child(buttons_row);
1275
1276 let st_backdrop = state.clone();
1277 let overlay = deferred(
1278 anchored()
1279 .anchor(Corner::BottomLeft)
1280 .position(point(px(abs_x), px(abs_y)))
1281 .child(div().occlude().child(panel_body).on_mouse_down_out(
1282 move |_e: &MouseDownEvent, _window, cx| {
1283 st_backdrop.update(cx, |s, cx| {
1284 if s.filter_panel.is_some() {
1285 s.filter_panel = None;
1286 cx.notify();
1287 }
1288 });
1289 },
1290 )),
1291 )
1292 .with_priority(CONTEXT_MENU_PRIORITY);
1293
1294 Some(overlay)
1295}
1296
1297fn render_busy_overlay(
1303 state: &Entity<GridState>,
1304 cx: &mut Context<SqllyDataTable>,
1305) -> Option<impl IntoElement> {
1306 let s = state.read(cx);
1307 let busy = s.busy.clone()?;
1308 let theme = s.theme.clone();
1309 let track = theme.grid_line;
1310 let accent = theme.sort_indicator;
1311
1312 let bar: gpui::AnyElement = if let Some(p) = busy.progress {
1313 let p = p.clamp(0.0, 1.0);
1314 div()
1315 .h_full()
1316 .w(relative(p))
1317 .rounded(px(3.0))
1318 .bg(accent)
1319 .into_any_element()
1320 } else {
1321 div()
1322 .h_full()
1323 .w(relative(0.3))
1324 .rounded(px(3.0))
1325 .bg(accent)
1326 .with_animation(
1327 "busy-indeterminate",
1328 Animation::new(std::time::Duration::from_millis(900))
1329 .repeat()
1330 .with_easing(pulsating_between(0.15, 0.85)),
1331 |el, delta| el.w(relative(delta)),
1332 )
1333 .into_any_element()
1334 };
1335
1336 let card = div()
1337 .flex()
1338 .flex_col()
1339 .gap(px(10.0))
1340 .p(px(16.0))
1341 .min_w(px(220.0))
1342 .rounded(px(8.0))
1343 .bg(theme.menu_bg)
1344 .border_1()
1345 .border_color(theme.grid_line)
1346 .child(
1347 div()
1348 .text_color(theme.menu_fg)
1349 .text_size(px(14.0))
1350 .child(busy.label.clone()),
1351 )
1352 .child(
1353 div()
1354 .w_full()
1355 .h(px(6.0))
1356 .rounded(px(3.0))
1357 .bg(track)
1358 .child(bar),
1359 );
1360
1361 let overlay = div()
1362 .absolute()
1363 .top_0()
1364 .left_0()
1365 .size_full()
1366 .occlude()
1367 .flex()
1368 .items_center()
1369 .justify_center()
1370 .bg(hsla(0.0, 0.0, 0.0, 0.35))
1371 .child(card);
1372
1373 Some(overlay)
1374}
1375
1376enum MenuDispatch {
1379 Builtin(menu::MenuAction, usize),
1380 Custom(
1381 String,
1382 Option<crate::grid::context_menu::ContextMenuRequest>,
1383 ),
1384}