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