Skip to main content

sqlly_datatable/grid/
widget.rs

1//! The `SqllyDataTable` GPUI widget and its builder. Owns one
2//! `Entity<GridState>` and wires GPUI's mouse / keyboard / scroll events to
3//! its methods. A bunch of `state.clone()` clones exist because each closure
4//! needs its own owned reference to the GPUI entity handle.
5
6use 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
33/// Draw order for the context-menu overlay. Deliberately far above any
34/// ordinary application UI so the menu — and, crucially, its event hitbox —
35/// sits on top of everything, even content painted outside the grid widget's
36/// own layout bounds (e.g. a host header above the grid). Deferred draws
37/// register their hitbox in a later pass, so this also fixes hover/click
38/// routing for menu items that visually overflow the grid area.
39const CONTEXT_MENU_PRIORITY: usize = 1_000_000;
40
41/// Which view of the data is active when the pivot tab is enabled.
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43pub enum GridTab {
44    /// The flat data grid.
45    #[default]
46    Grid,
47    /// The pivot view (sidebar + pivot grid).
48    Pivot,
49}
50
51/// The entities backing the pivot tab. Created when pivoting is enabled.
52pub(crate) struct PivotParts {
53    pub(crate) state: Entity<PivotState>,
54    grid: Entity<PivotGrid>,
55    sidebar: Entity<PivotSidebar>,
56}
57
58/// Top-level GPUI widget.
59pub struct SqllyDataTable {
60    pub state: Entity<GridState>,
61    /// Present when the pivot tab is enabled (via
62    /// [`SqllyDataTableBuilder::pivot`] or [`SqllyDataTable::enable_pivot`]).
63    pub(crate) pivot: Option<PivotParts>,
64    /// The active tab. Only meaningful while the pivot tab is enabled.
65    active_tab: GridTab,
66    /// Prevents opening the pivot while a host is still loading its source
67    /// rows. The tab remains visible and may display `pivot_status`.
68    pivot_locked: bool,
69    pivot_status: Option<String>,
70    /// When `true`, the grid swaps between the built-in light/dark
71    /// [`GridTheme`] palettes to follow the OS window appearance. Disabled
72    /// automatically when the caller supplies an explicit theme override.
73    follow_system_appearance: bool,
74    /// Retained appearance-observer subscription. Registered lazily on the
75    /// first render (that is where a `Window` is available); dropping it would
76    /// unregister the observer, so it is stored for the widget's lifetime.
77    appearance_subscription: Option<gpui::Subscription>,
78}
79
80impl SqllyDataTable {
81    /// Wrap an existing `Entity<GridState>`.
82    #[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    /// Construct from `GridData` using the default [`GridConfig`].
96    #[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    /// The pivot state entity, when the pivot tab is enabled. Read it for
110    /// the current [`PivotConfig`]; update it to reconfigure
111    /// programmatically.
112    #[must_use]
113    pub fn pivot_state(&self) -> Option<&Entity<PivotState>> {
114        self.pivot.as_ref().map(|p| &p.state)
115    }
116
117    /// The currently active tab.
118    #[must_use]
119    pub fn active_tab(&self) -> GridTab {
120        self.active_tab
121    }
122
123    /// Whether the visible pivot tab currently rejects activation.
124    #[must_use]
125    pub fn pivot_locked(&self) -> bool {
126        self.pivot_locked
127    }
128
129    /// Lock or unlock the pivot tab while keeping it visible. `status` is
130    /// rendered beside the Pivot title while locked, for example `Loading`.
131    /// Locking an active pivot returns immediately to the flat grid so a host
132    /// can continue streaming rows without recomputing the pivot snapshot.
133    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    /// Switch between the flat grid and the pivot view. Switching to the
142    /// pivot re-syncs its source snapshot if the grid's data changed (e.g.
143    /// rows were appended). No-op when the pivot tab is not enabled and
144    /// `Pivot` is requested.
145    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    /// Enable the pivot tab at runtime with the given configuration. If
156    /// already enabled, the existing pivot state is reconfigured instead
157    /// (collapse/sort/filter state is preserved).
158    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    /// Remove the pivot tab and return to the flat grid.
171    pub fn disable_pivot(&mut self) {
172        self.pivot = None;
173        self.active_tab = GridTab::Grid;
174    }
175
176    /// Register (or replace) the pivot's right-click menu provider at
177    /// runtime. No-op when the pivot tab is not enabled — enable it first
178    /// (or register via
179    /// [`SqllyDataTableBuilder::pivot_context_menu_provider`]).
180    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    /// Push the grid's current data snapshot into the pivot state when it
193    /// changed (O(1) compare via Arc identity).
194    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
211/// Create the pivot entities over the grid's current data snapshot.
212fn 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
244/// Builder for `SqllyDataTable`.
245pub 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    /// Override the entire [`GridConfig`].
257    #[must_use]
258    pub fn config(mut self, config: GridConfig) -> Self {
259        self.config = config;
260        self
261    }
262
263    /// Override the [`GridTheme`]. Supplying an explicit theme opts out of the
264    /// automatic OS light/dark following; the grid uses exactly this theme.
265    #[must_use]
266    pub fn theme(mut self, theme: GridTheme) -> Self {
267        self.theme = Some(theme);
268        self
269    }
270
271    /// Register a custom right-click menu provider. When registered, the
272    /// provider fully controls the right-click menu for all targets (cells,
273    /// row headers, column headers). The built-in column-header menu is
274    /// suppressed; use
275    /// [`crate::grid::context_menu::ContextMenuItem::standard_column_header_items`]
276    /// to compose built-in actions.
277    #[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    /// Enable or disable the debug status bar. When enabled, a bar is painted
284    /// at the bottom of the grid showing click position, scroll offset, and
285    /// hovered cell coordinates. Off by default.
286    #[must_use]
287    pub fn debug_bar(mut self, enabled: bool) -> Self {
288        self.debug_bar = enabled;
289        self
290    }
291
292    /// Enable the pivot tab, preconfigured with `config`. The widget renders
293    /// a "Grid" / "Pivot" tab bar; the pivot tab shows the drag-and-drop
294    /// field sidebar next to the pivot grid. Pass
295    /// [`PivotConfig::default()`] for an unconfigured pivot the user builds
296    /// interactively.
297    #[must_use]
298    pub fn pivot(mut self, config: PivotConfig) -> Self {
299        self.pivot = Some(config);
300        self
301    }
302
303    /// Register a custom right-click menu provider for the pivot view. When
304    /// registered, the provider fully controls the pivot's context menu (the
305    /// built-in `pivot.*` action ids remain handled by the pivot; compose
306    /// them via [`crate::pivot::PivotMenuItem::standard_items`]). Only takes
307    /// effect together with [`SqllyDataTableBuilder::pivot`].
308    #[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    /// Build the widget inside the supplied [`gpui::App`].
318    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        // Follow the OS light/dark appearance: set the initial theme from the
358        // current window appearance and register a one-time observer that
359        // swaps the grid theme whenever the system appearance changes. Skipped
360        // when the caller supplied an explicit theme override.
361        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        // Keep the pivot's theme in lockstep with the grid theme (which may
376        // have just changed via the appearance observer).
377        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        // Drill-through: a double-click on a pivot cell (or the built-in
388        // "Show source rows" menu action) queued per-column value filters.
389        // Apply them to the flat grid and switch to the Grid tab so the user
390        // lands on exactly the rows that drive the clicked cell.
391        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                    // Filters are unsupported in windowed-row mode; the
396                    // drill-through is skipped rather than presenting a
397                    // filter that silently covers only resident rows.
398                    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                            // Route keyboard input to the now-visible view.
461                            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    /// The flat-grid element tree (canvas, overlays, input handlers). Shared
528    /// by the tabless render path and the "Grid" tab.
529    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        // Process any pending menu action from a previous mouse-down on a
546        // menu item (needs App access for clipboard).
547        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        // Process any pending custom context-menu action.
555        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        // Spawn an edge-scroll timer **only while a drag is in progress**, and
568        // **only one at a time**. Without the `edge_scroll_active` guard,
569        // `render` would spawn a fresh 16 ms loop on every frame/notify during
570        // a drag — each successful tick calls `cx.notify()`, which re-renders
571        // and spawned yet another task, stacking concurrent loops that each
572        // apply a scroll delta per tick and multiply the effective speed
573        // without bound. The task clears the flag when it exits.
574        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                        // Ignore grid input while a background task is running;
650                        // the busy overlay is shown and occludes interaction.
651                        if s.busy.is_some() {
652                            return;
653                        }
654                        // Normalize the absolute window pointer into the grid's
655                        // own frame. Menu hit-testing is handled by the deferred
656                        // overlay's own item handlers, so a left-click that
657                        // reaches the grid means the pointer was NOT on the menu;
658                        // dismiss any open menu and proceed with grid selection.
659                        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                        // No provider — existing built-in behavior.
685                        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                        // Provider exists — build custom menu.
700                        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
790/// Build the context-menu overlay as a `deferred` + `anchored` element so it
791/// paints — and receives mouse events — on top of everything, including
792/// regions outside the grid widget's own layout bounds. Returns `None` when no
793/// menu is open.
794///
795/// Positioning reuses [`menu::ContextMenu::resolved_position`] (window-viewport
796/// aware: flips up when there's no room below, shifts left at the right edge),
797/// then converts to absolute window coordinates for `anchored().position(..)`.
798/// Each selectable row carries its own `on_mouse_down` (dispatch) and
799/// `on_mouse_move` (hover highlight) handlers; a full-screen backdrop behind
800/// the menu dismisses it on any outside click.
801fn 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    // Build one row per item. `selectable_idx` counts only Action/Custom items
821    // so it matches the `hovered` index convention used elsewhere.
822    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                // Dispatch: set the pending action and close the menu. The
843                // pending fields are drained at the top of `render` (they need
844                // App access for clipboard).
845                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    // Full-window transparent backdrop: catches clicks outside the menu to
915    // dismiss it. Placed behind the menu within the same anchored overlay.
916    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
935/// Fixed width of the filter popover, in pixels.
936const FILTER_PANEL_WIDTH: f32 = 300.0;
937/// Max number of distinct value rows rendered at once (search narrows the set).
938const FILTER_PANEL_MAX_ROWS: usize = 200;
939
940/// Build the Numbers-style per-column filter popover as a `deferred` +
941/// `anchored` overlay, using the exact mechanism as
942/// [`render_context_menu_overlay`] so it paints and receives events outside the
943/// grid's own layout bounds. Returns `None` when no panel is open.
944#[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    // Anchor (grid-relative) -> absolute window coords. The default
959    // `SwitchAnchor` fit mode on `anchored()` handles viewport-edge flipping
960    // automatically using the actual rendered height, so we don't need a
961    // manual estimate or flip calculation here.
962    let abs_x = grid_ox + f32::from(panel.anchor.x);
963    let abs_y = grid_oy + f32::from(panel.anchor.y);
964
965    // Palette (all `Hsla` are `Copy`, so they move freely into closures).
966    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    // --- Sort row -----------------------------------------------------------
990    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    // --- Operator dropdown --------------------------------------------------
1040    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    // --- Operand field(s) ---------------------------------------------------
1091    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        // Text "matches" operator.
1128        "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    // --- Search box ---------------------------------------------------------
1153    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    // --- (Select All) + value checklist ------------------------------------
1184    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    // --- Clear (left, disabled when no active filter) + Close (right) -----
1239    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
1338/// Renders the loading overlay while a background task runs. Returns `None`
1339/// when the grid is not busy. Painted as an absolute, input-occluding scrim
1340/// over the whole grid area with a centered card showing the task label and a
1341/// progress bar (determinate when progress is known, otherwise an animated
1342/// indeterminate bar).
1343fn 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
1417/// What a menu row dispatches when clicked. Captured per-row so the click
1418/// handler owns its data without borrowing the menu snapshot.
1419enum MenuDispatch {
1420    Builtin(menu::MenuAction, usize),
1421    Custom(
1422        String,
1423        Option<crate::grid::context_menu::ContextMenuRequest>,
1424    ),
1425}