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