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