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, GridThemePair};
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::{
21    PivotSaveConfigHandler, PivotState, DEFAULT_PIVOT_COLUMN_WIDTH, DEFAULT_PIVOT_ROW_HEIGHT,
22    DEFAULT_PIVOT_SIDEBAR_WIDTH,
23};
24use crate::pivot::widget::PivotGrid;
25
26use gpui::prelude::FluentBuilder;
27use gpui::{
28    anchored, canvas, deferred, div, point, pulsating_between, px, relative, Animation,
29    AnimationExt, App, AppContext, Context, Corner, Div, Entity, FocusHandle, Focusable,
30    InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
31    MouseUpEvent, ParentElement, Render, ScrollWheelEvent, StatefulInteractiveElement, Styled,
32    Window,
33};
34use gpui_component::resizable::{h_resizable, resizable_panel, ResizableState};
35use gpui_component::{Icon, IconName};
36use std::sync::Arc;
37
38/// Draw order for the context-menu overlay. Deliberately far above any
39/// ordinary application UI so the menu — and, crucially, its event hitbox —
40/// sits on top of everything, even content painted outside the grid widget's
41/// own layout bounds (e.g. a host header above the grid). Deferred draws
42/// register their hitbox in a later pass, so this also fixes hover/click
43/// routing for menu items that visually overflow the grid area.
44const CONTEXT_MENU_PRIORITY: usize = 1_000_000;
45const MIN_PIVOT_SIDEBAR_WIDTH: f32 = 180.0;
46const MAX_PIVOT_SIDEBAR_WIDTH: f32 = 480.0;
47
48/// Which view of the data is active when the pivot tab is enabled.
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum GridTab {
51    /// The flat data grid.
52    #[default]
53    Grid,
54    /// The pivot view (accordion controls + pivot grid).
55    Pivot,
56}
57
58/// Side of the pivot grid where the control panel is rendered.
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60pub enum PivotSidebarPosition {
61    /// Render the controls before the pivot grid.
62    #[default]
63    Left,
64    /// Render the controls after the pivot grid.
65    Right,
66}
67
68/// The entities backing the pivot tab. Created when pivoting is enabled.
69pub(crate) struct PivotParts {
70    pub(crate) state: Entity<PivotState>,
71    grid: Entity<PivotGrid>,
72    sidebar: Entity<PivotSidebar>,
73}
74
75/// Top-level GPUI widget.
76pub struct SqllyDataTable {
77    pub state: Entity<GridState>,
78    /// Present when the pivot tab is enabled (via
79    /// [`SqllyDataTableBuilder::pivot`] or [`SqllyDataTable::enable_pivot`]).
80    pub(crate) pivot: Option<PivotParts>,
81    /// The active tab. Only meaningful while the pivot tab is enabled.
82    active_tab: GridTab,
83    /// Prevents opening the pivot while a host is still loading its source
84    /// rows. The tab remains visible and may display `pivot_status`.
85    pivot_locked: bool,
86    pivot_status: Option<String>,
87    pivot_sidebar_position: PivotSidebarPosition,
88    pivot_sidebar_collapsed: bool,
89    pivot_sidebar_width: f32,
90    /// Split state owned by `gpui-component`'s resizable panel group. Created
91    /// lazily on the first pivot render; dropped (and thus re-seeded from
92    /// `pivot_sidebar_width`) when the sidebar switches sides, because panel
93    /// sizes in the group state are positional.
94    pivot_sidebar_resize: Option<Entity<ResizableState>>,
95    /// Set when `set_pivot_sidebar_width` is called so the next render pushes
96    /// the programmatic width into the resizable group state (which is
97    /// otherwise authoritative once the user has dragged the divider).
98    pivot_sidebar_width_dirty: bool,
99    /// Human-readable description of the column filters applied by the most
100    /// recent pivot drill-through (e.g. `region = East, txn_type = Sale`).
101    /// While set, the Grid tab shows a banner naming the filter with a
102    /// one-click clear; cleared by [`SqllyDataTable::clear_drill_filter`] or
103    /// replaced by the next drill.
104    drill_filter: Option<String>,
105    /// When `true`, the grid swaps between the built-in light/dark
106    /// [`GridTheme`] palettes to follow the OS window appearance. Disabled
107    /// automatically when the caller supplies an explicit theme override.
108    follow_system_appearance: bool,
109    /// Retained appearance-observer subscription. Registered lazily on the
110    /// first render (that is where a `Window` is available); dropping it would
111    /// unregister the observer, so it is stored for the widget's lifetime.
112    appearance_subscription: Option<gpui::Subscription>,
113}
114
115impl SqllyDataTable {
116    /// Wrap an existing `Entity<GridState>`.
117    #[must_use]
118    pub fn new(state: Entity<GridState>) -> Self {
119        Self {
120            state,
121            pivot: None,
122            active_tab: GridTab::Grid,
123            pivot_locked: false,
124            pivot_status: None,
125            pivot_sidebar_position: PivotSidebarPosition::Left,
126            pivot_sidebar_collapsed: false,
127            pivot_sidebar_width: DEFAULT_PIVOT_SIDEBAR_WIDTH,
128            pivot_sidebar_resize: None,
129            pivot_sidebar_width_dirty: false,
130            drill_filter: None,
131            follow_system_appearance: true,
132            appearance_subscription: None,
133        }
134    }
135
136    /// Construct from `GridData` using the default [`GridConfig`].
137    #[must_use]
138    pub fn builder(data: GridData) -> SqllyDataTableBuilder {
139        SqllyDataTableBuilder {
140            data,
141            config: GridConfig::default(),
142            context_menu_provider: None,
143            theme: None,
144            theme_family: None,
145            debug_bar: false,
146            grouped_column: None,
147            pivot: None,
148            pivot_context_menu_provider: None,
149            pivot_save_config_handler: None,
150            pivot_sidebar_position: PivotSidebarPosition::Left,
151            pivot_sidebar_collapsed: false,
152            pivot_sidebar_width: DEFAULT_PIVOT_SIDEBAR_WIDTH,
153            pivot_row_height: DEFAULT_PIVOT_ROW_HEIGHT,
154            pivot_column_width: DEFAULT_PIVOT_COLUMN_WIDTH,
155        }
156    }
157
158    /// The pivot state entity, when the pivot tab is enabled. Read it for the
159    /// current [`PivotConfig`], row height, or column width; update it to
160    /// reconfigure the pivot programmatically.
161    #[must_use]
162    pub fn pivot_state(&self) -> Option<&Entity<PivotState>> {
163        self.pivot.as_ref().map(|p| &p.state)
164    }
165
166    /// The currently active tab.
167    #[must_use]
168    pub fn active_tab(&self) -> GridTab {
169        self.active_tab
170    }
171
172    /// Swap the light/dark theme family at runtime while keeping automatic
173    /// OS light/dark following. The pair's variant matching the current
174    /// window appearance is applied immediately; subsequent appearance
175    /// changes resolve against the new family. Re-enables appearance
176    /// following if it was disabled by an explicit
177    /// [`SqllyDataTableBuilder::theme`] override.
178    pub fn set_theme_family(
179        &mut self,
180        family: GridThemePair,
181        window: &Window,
182        cx: &mut Context<Self>,
183    ) {
184        self.follow_system_appearance = true;
185        let appearance = window.appearance();
186        self.state.update(cx, |s, cx| {
187            s.theme = family.for_appearance(appearance);
188            s.theme_family = family;
189            cx.notify();
190        });
191        cx.notify();
192    }
193
194    /// Whether the visible pivot tab currently rejects activation.
195    #[must_use]
196    pub fn pivot_locked(&self) -> bool {
197        self.pivot_locked
198    }
199
200    /// Side of the pivot grid where the control panel is rendered.
201    #[must_use]
202    pub fn pivot_sidebar_position(&self) -> PivotSidebarPosition {
203        self.pivot_sidebar_position
204    }
205
206    /// Move the pivot control panel to the left or right of the pivot grid.
207    pub fn set_pivot_sidebar_position(&mut self, position: PivotSidebarPosition) {
208        if self.pivot_sidebar_position != position {
209            // Panel sizes in the resizable group state are positional, so a
210            // side switch must re-seed the group from `pivot_sidebar_width`.
211            self.pivot_sidebar_resize = None;
212        }
213        self.pivot_sidebar_position = position;
214    }
215
216    /// Whether the pivot control panel is collapsed into its divider.
217    #[must_use]
218    pub fn pivot_sidebar_collapsed(&self) -> bool {
219        self.pivot_sidebar_collapsed
220    }
221
222    /// Collapse or expand the pivot control panel.
223    pub fn set_pivot_sidebar_collapsed(&mut self, collapsed: bool) {
224        self.pivot_sidebar_collapsed = collapsed;
225    }
226
227    /// Current width of the expanded pivot control panel, in pixels.
228    #[must_use]
229    pub fn pivot_sidebar_width(&self) -> f32 {
230        self.pivot_sidebar_width
231    }
232
233    /// Resize the pivot control panel, clamped to its supported range.
234    pub fn set_pivot_sidebar_width(&mut self, width: f32) {
235        self.pivot_sidebar_width = width.clamp(MIN_PIVOT_SIDEBAR_WIDTH, MAX_PIVOT_SIDEBAR_WIDTH);
236        self.pivot_sidebar_width_dirty = true;
237    }
238
239    /// Human-readable description of the column filters applied by the most
240    /// recent pivot drill-through, while they are still marked as such. The
241    /// Grid tab shows this in a banner with a one-click clear.
242    #[must_use]
243    pub fn drill_filter(&self) -> Option<&str> {
244        self.drill_filter.as_deref()
245    }
246
247    /// Clear the filters applied by a pivot drill-through: every column
248    /// filter is reset and the Grid tab's drill banner is dismissed.
249    pub fn clear_drill_filter(&mut self, cx: &mut Context<Self>) {
250        if self.drill_filter.take().is_none() {
251            return;
252        }
253        self.state.update(cx, |g, cx| {
254            for filter in &mut g.filters {
255                *filter = ColumnFilter::default();
256            }
257            g.recompute();
258            cx.notify();
259        });
260        cx.notify();
261    }
262
263    /// Lock or unlock the pivot tab while keeping it visible. `status` is
264    /// rendered beside the Pivot title while locked, for example `Loading`.
265    /// Locking an active pivot returns immediately to the flat grid so a host
266    /// can continue streaming rows without recomputing the pivot snapshot.
267    pub fn set_pivot_locked(&mut self, locked: bool, status: Option<String>) {
268        self.pivot_locked = locked;
269        self.pivot_status = locked.then_some(status).flatten();
270        if locked {
271            self.active_tab = GridTab::Grid;
272        }
273    }
274
275    /// Switch between the flat grid and the pivot view. Switching to the
276    /// pivot re-syncs its source snapshot if the grid's data changed (e.g.
277    /// rows were appended). No-op when the pivot tab is not enabled and
278    /// `Pivot` is requested.
279    pub fn set_active_tab(&mut self, tab: GridTab, cx: &mut App) {
280        if tab == GridTab::Pivot {
281            if self.pivot.is_none() || self.pivot_locked {
282                return;
283            }
284            self.sync_pivot_source(cx);
285        }
286        self.active_tab = tab;
287    }
288
289    /// Enable the pivot tab at runtime with the given configuration. If
290    /// already enabled, the existing pivot state is reconfigured instead
291    /// (collapse/sort/filter state is preserved).
292    pub fn enable_pivot(&mut self, config: PivotConfig, cx: &mut App) {
293        if let Some(parts) = &self.pivot {
294            parts.state.update(cx, |s, cx| {
295                s.config = config;
296                s.recompute();
297                cx.notify();
298            });
299            return;
300        }
301        self.pivot = Some(build_pivot_parts(
302            &self.state,
303            config,
304            None,
305            None,
306            DEFAULT_PIVOT_ROW_HEIGHT,
307            DEFAULT_PIVOT_COLUMN_WIDTH,
308            cx,
309        ));
310    }
311
312    /// Remove the pivot tab and return to the flat grid.
313    pub fn disable_pivot(&mut self) {
314        self.pivot = None;
315        self.active_tab = GridTab::Grid;
316    }
317
318    /// Register (or replace) the pivot's right-click menu provider at
319    /// runtime. No-op when the pivot tab is not enabled — enable it first
320    /// (or register via
321    /// [`SqllyDataTableBuilder::pivot_context_menu_provider`]).
322    pub fn set_pivot_context_menu_provider(
323        &mut self,
324        provider: impl PivotContextMenuProvider + 'static,
325        cx: &mut App,
326    ) {
327        if let Some(parts) = &self.pivot {
328            parts.state.update(cx, |s, _cx| {
329                s.set_context_menu_provider(provider);
330            });
331        }
332    }
333
334    /// Register (or replace) the pivot's save-configuration action at
335    /// runtime. While registered, the pivot controls sidebar shows a save
336    /// button next to the Layout section that invokes `handler` with the
337    /// live [`PivotConfig`]. No-op when the pivot tab is not enabled — enable
338    /// it first (or register via
339    /// [`SqllyDataTableBuilder::pivot_save_config`]).
340    pub fn set_pivot_save_config(
341        &mut self,
342        handler: impl Fn(&PivotConfig, &mut App) + 'static,
343        cx: &mut App,
344    ) {
345        if let Some(parts) = &self.pivot {
346            parts.state.update(cx, |s, cx| {
347                s.on_save_config(handler);
348                cx.notify();
349            });
350        }
351    }
352
353    /// Remove the pivot's save-configuration action; the sidebar's save
354    /// button disappears. No-op when the pivot tab is not enabled.
355    pub fn clear_pivot_save_config(&mut self, cx: &mut App) {
356        if let Some(parts) = &self.pivot {
357            parts.state.update(cx, |s, cx| {
358                s.clear_save_config_handler();
359                cx.notify();
360            });
361        }
362    }
363
364    /// Push the grid's current data snapshot into the pivot state when it
365    /// changed (O(1) compare via Arc identity).
366    fn sync_pivot_source(&self, cx: &mut App) {
367        let Some(parts) = &self.pivot else {
368            return;
369        };
370        let (columns, rows) = {
371            let s = self.state.read(cx);
372            (s.data.columns.clone(), Arc::clone(&s.data_rows))
373        };
374        parts.state.update(cx, |ps, cx| {
375            if ps.source_differs(&rows) {
376                ps.set_source(columns, rows);
377                cx.notify();
378            }
379        });
380    }
381}
382
383/// Create the pivot entities over the grid's current data snapshot.
384fn build_pivot_parts(
385    grid_state: &Entity<GridState>,
386    config: PivotConfig,
387    menu_provider: Option<PivotContextMenuProviderHandle>,
388    save_config_handler: Option<PivotSaveConfigHandler>,
389    row_height: f32,
390    column_width: f32,
391    cx: &mut App,
392) -> PivotParts {
393    let (columns, rows, formats, key_bindings, theme, animations) = {
394        let s = grid_state.read(cx);
395        (
396            s.data.columns.clone(),
397            Arc::clone(&s.data_rows),
398            s.resolved_formats.as_ref().clone(),
399            s.config.key_bindings.clone(),
400            s.theme.clone(),
401            s.config.animations,
402        )
403    };
404    let focus = cx.focus_handle();
405    let state = cx.new(|_| {
406        let mut ps = PivotState::new(columns, rows, formats, config, key_bindings, focus);
407        ps.theme = theme;
408        ps.animations = animations;
409        ps.context_menu_provider = menu_provider;
410        ps.save_config_handler = save_config_handler;
411        ps.set_row_height(row_height);
412        ps.set_column_width(column_width);
413        ps
414    });
415    let grid = cx.new(|_| PivotGrid::new(state.clone()));
416    let sidebar = cx.new(|_| PivotSidebar::new(state.clone()));
417    PivotParts {
418        state,
419        grid,
420        sidebar,
421    }
422}
423
424/// Builder for `SqllyDataTable`.
425pub struct SqllyDataTableBuilder {
426    data: GridData,
427    config: GridConfig,
428    context_menu_provider: Option<ContextMenuProviderHandle>,
429    theme: Option<GridTheme>,
430    theme_family: Option<GridThemePair>,
431    debug_bar: bool,
432    grouped_column: Option<usize>,
433    pivot: Option<PivotConfig>,
434    pivot_context_menu_provider: Option<PivotContextMenuProviderHandle>,
435    pivot_save_config_handler: Option<PivotSaveConfigHandler>,
436    pivot_sidebar_position: PivotSidebarPosition,
437    pivot_sidebar_collapsed: bool,
438    pivot_sidebar_width: f32,
439    pivot_row_height: f32,
440    pivot_column_width: f32,
441}
442
443impl SqllyDataTableBuilder {
444    /// Override the entire [`GridConfig`].
445    #[must_use]
446    pub fn config(mut self, config: GridConfig) -> Self {
447        self.config = config;
448        self
449    }
450
451    /// Override the [`GridTheme`]. Supplying an explicit theme opts out of the
452    /// automatic OS light/dark following; the grid uses exactly this theme.
453    #[must_use]
454    pub fn theme(mut self, theme: GridTheme) -> Self {
455        self.theme = Some(theme);
456        self
457    }
458
459    /// Choose the light/dark theme family used while following the OS window
460    /// appearance (default: [`GridThemePair::neutral`]). Unlike
461    /// [`SqllyDataTableBuilder::theme`], this keeps automatic light/dark
462    /// following — the pair's matching variant is applied whenever the
463    /// system appearance changes. Ignored when an explicit theme override is
464    /// also supplied.
465    #[must_use]
466    pub fn theme_family(mut self, family: GridThemePair) -> Self {
467        self.theme_family = Some(family);
468        self
469    }
470
471    /// Register a custom right-click menu provider. When registered, the
472    /// provider fully controls the right-click menu for all targets (cells,
473    /// row headers, column headers). The built-in column-header menu is
474    /// suppressed; use
475    /// [`crate::grid::context_menu::ContextMenuItem::standard_column_header_items`]
476    /// to compose built-in actions.
477    #[must_use]
478    pub fn context_menu_provider(mut self, provider: impl ContextMenuProvider + 'static) -> Self {
479        self.context_menu_provider = Some(ContextMenuProviderHandle::new(provider));
480        self
481    }
482
483    /// Enable or disable the debug status bar. When enabled, a bar is painted
484    /// at the bottom of the grid showing click position, scroll offset, and
485    /// hovered cell coordinates. Off by default.
486    #[must_use]
487    pub fn debug_bar(mut self, enabled: bool) -> Self {
488        self.debug_bar = enabled;
489        self
490    }
491
492    /// Group the initial flat-grid rows into expandable sections using the
493    /// formatted values in `column`. Invalid indices are ignored at build time.
494    #[must_use]
495    pub fn group_by_column(mut self, column: usize) -> Self {
496        self.grouped_column = Some(column);
497        self
498    }
499
500    /// Enable the pivot tab, preconfigured with `config`. The widget renders
501    /// a "Grid" / "Pivot" tab bar; the pivot tab shows resizable accordion
502    /// controls next to the pivot grid. Pass
503    /// [`PivotConfig::default()`] for an unconfigured pivot the user builds
504    /// interactively.
505    #[must_use]
506    pub fn pivot(mut self, config: PivotConfig) -> Self {
507        self.pivot = Some(config);
508        self
509    }
510
511    /// Place the pivot control panel on the left or right side of the grid.
512    #[must_use]
513    pub fn pivot_sidebar_position(mut self, position: PivotSidebarPosition) -> Self {
514        self.pivot_sidebar_position = position;
515        self
516    }
517
518    /// Build the pivot control panel initially collapsed.
519    #[must_use]
520    pub fn pivot_sidebar_collapsed(mut self, collapsed: bool) -> Self {
521        self.pivot_sidebar_collapsed = collapsed;
522        self
523    }
524
525    /// Set the initial width of the expanded pivot control panel.
526    #[must_use]
527    pub fn pivot_sidebar_width(mut self, width: f32) -> Self {
528        self.pivot_sidebar_width = width.clamp(MIN_PIVOT_SIDEBAR_WIDTH, MAX_PIVOT_SIDEBAR_WIDTH);
529        self
530    }
531
532    /// Set the initial height of every row in the pivot view.
533    #[must_use]
534    pub fn pivot_row_height(mut self, height: f32) -> Self {
535        if height.is_finite() {
536            self.pivot_row_height = height.max(crate::pivot::state::MIN_PIVOT_ROW_HEIGHT);
537        }
538        self
539    }
540
541    /// Set the initial width of every value column in the pivot view.
542    #[must_use]
543    pub fn pivot_column_width(mut self, width: f32) -> Self {
544        if width.is_finite() {
545            self.pivot_column_width = width.max(crate::pivot::state::MIN_PIVOT_COLUMN_WIDTH);
546        }
547        self
548    }
549
550    /// Register a custom right-click menu provider for the pivot view. When
551    /// registered, the provider fully controls the pivot's context menu (the
552    /// built-in `pivot.*` action ids remain handled by the pivot; compose
553    /// them via [`crate::pivot::PivotMenuItem::standard_items`]). Only takes
554    /// effect together with [`SqllyDataTableBuilder::pivot`].
555    #[must_use]
556    pub fn pivot_context_menu_provider(
557        mut self,
558        provider: impl PivotContextMenuProvider + 'static,
559    ) -> Self {
560        self.pivot_context_menu_provider = Some(PivotContextMenuProviderHandle::new(provider));
561        self
562    }
563
564    /// Register a save-configuration action for the pivot view. While
565    /// registered, the pivot controls sidebar shows a save button next to
566    /// the Layout section that invokes `handler` with the live
567    /// [`PivotConfig`] (persist it and pass it back to
568    /// [`SqllyDataTableBuilder::pivot`] on the next launch). Without a
569    /// handler the button is not rendered. Only takes effect together with
570    /// [`SqllyDataTableBuilder::pivot`].
571    #[must_use]
572    pub fn pivot_save_config(mut self, handler: impl Fn(&PivotConfig, &mut App) + 'static) -> Self {
573        self.pivot_save_config_handler = Some(std::rc::Rc::new(handler));
574        self
575    }
576
577    /// Build the widget inside the supplied [`gpui::App`].
578    pub fn build(self, cx: &mut App) -> SqllyDataTable {
579        let focus = cx.focus_handle();
580        let provider = self.context_menu_provider;
581        let theme_override = self.theme;
582        let theme_family = self.theme_family;
583        let debug_bar = self.debug_bar;
584        let grouped_column = self.grouped_column;
585        let pivot_config = self.pivot;
586        let pivot_sidebar_position = self.pivot_sidebar_position;
587        let pivot_sidebar_collapsed = self.pivot_sidebar_collapsed;
588        let pivot_sidebar_width = self.pivot_sidebar_width;
589        let pivot_row_height = self.pivot_row_height;
590        let pivot_column_width = self.pivot_column_width;
591        let follow_system_appearance = theme_override.is_none();
592        let state = cx.new(|cx| {
593            let mut s = GridState::new(self.data, self.config, focus.clone());
594            s.context_menu_provider = provider;
595            s.debug_bar_enabled = debug_bar;
596            s.set_grouped_column(grouped_column);
597            s.self_weak = Some(cx.weak_entity());
598            if let Some(family) = theme_family {
599                s.theme = family.light.clone();
600                s.theme_family = family;
601            }
602            if let Some(theme) = theme_override {
603                s.theme = theme;
604            }
605            s
606        });
607        let pivot_menu_provider = self.pivot_context_menu_provider;
608        let pivot_save_config_handler = self.pivot_save_config_handler;
609        let pivot = pivot_config.map(|cfg| {
610            build_pivot_parts(
611                &state,
612                cfg,
613                pivot_menu_provider,
614                pivot_save_config_handler,
615                pivot_row_height,
616                pivot_column_width,
617                cx,
618            )
619        });
620        SqllyDataTable {
621            state,
622            pivot,
623            active_tab: GridTab::Grid,
624            pivot_locked: false,
625            pivot_status: None,
626            pivot_sidebar_position,
627            pivot_sidebar_collapsed,
628            pivot_sidebar_width,
629            pivot_sidebar_resize: None,
630            pivot_sidebar_width_dirty: false,
631            drill_filter: None,
632            follow_system_appearance,
633            appearance_subscription: None,
634        }
635    }
636}
637
638impl Focusable for SqllyDataTable {
639    fn focus_handle(&self, cx: &App) -> FocusHandle {
640        self.state.read(cx).focus_handle.clone()
641    }
642}
643
644impl Render for SqllyDataTable {
645    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
646        // Follow the OS light/dark appearance. An observer swaps the theme
647        // whenever the system appearance changes, but we also reconcile the
648        // resolved theme against `window.appearance()` on every render: the
649        // appearance read on the very first frame can be stale (reported
650        // before the window has settled onto the OS appearance), which would
651        // otherwise strand the grid in the light variant on a dark-mode OS
652        // until the next appearance *change* event. Re-resolving each render
653        // self-heals that — it clones + compares one theme and only writes
654        // (and notifies) when the variant actually differs, so a steady state
655        // is a cheap no-op. Skipped entirely when the caller supplied an
656        // explicit theme override.
657        if self.follow_system_appearance {
658            let appearance = window.appearance();
659            let resolved = self.state.read(cx).theme_family.for_appearance(appearance);
660            if self.state.read(cx).theme != resolved {
661                self.state.update(cx, |s, cx| {
662                    s.theme = resolved;
663                    cx.notify();
664                });
665            }
666            if self.appearance_subscription.is_none() {
667                let state_appearance = self.state.clone();
668                self.appearance_subscription =
669                    Some(window.observe_window_appearance(move |window, cx| {
670                        let appearance = window.appearance();
671                        state_appearance.update(cx, |s, cx| {
672                            let resolved = s.theme_family.for_appearance(appearance);
673                            if s.theme != resolved {
674                                s.theme = resolved;
675                                cx.notify();
676                            }
677                        });
678                    }));
679            }
680        }
681
682        // Keep the pivot's theme in lockstep with the grid theme (which may
683        // have just changed via the appearance observer), and its sidebar
684        // width in sync with the resizable panel (the sidebar needs it to
685        // decide when chip labels are truncated).
686        if let Some(parts) = &self.pivot {
687            let grid_theme = self.state.read(cx).theme.clone();
688            let sidebar_width = self.pivot_sidebar_width;
689            parts.state.update(cx, |s, cx| {
690                let mut dirty = false;
691                if s.theme != grid_theme {
692                    s.theme = grid_theme;
693                    dirty = true;
694                }
695                if (s.sidebar_width - sidebar_width).abs() > 0.5 {
696                    s.sidebar_width = sidebar_width;
697                    dirty = true;
698                }
699                if dirty {
700                    cx.notify();
701                }
702            });
703        }
704
705        // Drill-through: a double-click on a pivot cell (or the built-in
706        // "Show source rows" menu action) queued per-column value filters.
707        // Apply them to the flat grid and switch to the Grid tab so the user
708        // lands on exactly the rows that drive the clicked cell.
709        if let Some(parts) = &self.pivot {
710            let drill = parts.state.update(cx, |s, _cx| s.take_pending_drill_down());
711            if let Some(filter_sets) = drill {
712                let mut applied = false;
713                self.state.update(cx, |g, cx| {
714                    // Filters are unsupported in windowed-row mode; the
715                    // drill-through is skipped rather than presenting a
716                    // filter that silently covers only resident rows.
717                    if g.window.is_none() {
718                        for filter in &mut g.filters {
719                            *filter = ColumnFilter::default();
720                        }
721                        for (field, values) in &filter_sets {
722                            if let Some(slot) = g.filters.get_mut(*field) {
723                                *slot = ColumnFilter {
724                                    predicate: FilterPredicate::None,
725                                    values: Some(values.clone()),
726                                };
727                            }
728                        }
729                        g.recompute();
730                        applied = true;
731                    }
732                    cx.notify();
733                });
734                if applied {
735                    self.drill_filter =
736                        Some(drill_filter_label(&self.state.read(cx).data, &filter_sets));
737                }
738                self.active_tab = GridTab::Grid;
739                let focus = self.state.read(cx).focus_handle.clone();
740                window.focus(&focus);
741                cx.notify();
742            }
743        }
744
745        let grid_view = self.render_grid_view(cx);
746
747        let Some(parts) = &self.pivot else {
748            return div().size_full().child(grid_view);
749        };
750
751        let theme = self.state.read(cx).theme.clone();
752        let tab = |label: String,
753                   this_tab: GridTab,
754                   active: bool,
755                   locked: bool,
756                   cx: &mut Context<Self>| {
757            div()
758                .px(px(14.0))
759                .py(px(5.0))
760                .text_size(px(13.0))
761                .when(!locked, |tab| tab.cursor_pointer())
762                .when(locked, |tab| tab.opacity(0.55))
763                .bg(if active { theme.bg } else { theme.header_bg })
764                .text_color(if active {
765                    theme.header_fg
766                } else {
767                    theme.muted_text
768                })
769                .border_b_2()
770                .border_color(if active {
771                    theme.sort_indicator
772                } else {
773                    theme.header_bg
774                })
775                .child(label)
776                .when(!locked, |tab| {
777                    tab.on_mouse_down(
778                        MouseButton::Left,
779                        cx.listener(move |this, _e: &MouseDownEvent, window, cx| {
780                            if this.active_tab == this_tab {
781                                return;
782                            }
783                            this.set_active_tab(this_tab, cx);
784                            // Route keyboard input to the now-visible view.
785                            match this_tab {
786                                GridTab::Grid => {
787                                    let focus = this.state.read(cx).focus_handle.clone();
788                                    window.focus(&focus);
789                                }
790                                GridTab::Pivot => {
791                                    if let Some(p) = &this.pivot {
792                                        let focus = p.state.read(cx).focus_handle.clone();
793                                        window.focus(&focus);
794                                    }
795                                }
796                            }
797                            cx.notify();
798                        }),
799                    )
800                })
801        };
802
803        let pivot_label = self
804            .pivot_status
805            .as_deref()
806            .map_or_else(|| "Pivot".to_string(), |status| format!("Pivot  {status}"));
807
808        let tab_bar = div()
809            .flex()
810            .flex_row()
811            .flex_none()
812            .bg(theme.header_bg)
813            .border_b_1()
814            .border_color(theme.grid_line)
815            .child(tab(
816                "Grid".to_string(),
817                GridTab::Grid,
818                self.active_tab == GridTab::Grid,
819                false,
820                cx,
821            ))
822            .child(tab(
823                pivot_label,
824                GridTab::Pivot,
825                self.active_tab == GridTab::Pivot,
826                self.pivot_locked,
827                cx,
828            ));
829
830        let content: gpui::AnyElement = match self.active_tab {
831            GridTab::Grid => {
832                // While a pivot drill-through's filters are active, a banner
833                // names them and offers a one-click clear — otherwise the
834                // filtered grid is indistinguishable from the full dataset.
835                if let Some(label) = self.drill_filter.clone() {
836                    let table_clear = cx.entity().clone();
837                    let hover_bg = theme.menu_hover_bg;
838                    let banner = div()
839                        .flex()
840                        .flex_none()
841                        .items_center()
842                        .justify_between()
843                        .gap(px(8.0))
844                        .px(px(10.0))
845                        .py(px(4.0))
846                        .bg(theme.filter_active_bg)
847                        .border_b_1()
848                        .border_color(theme.grid_line)
849                        .text_size(px(12.0))
850                        .text_color(theme.header_fg)
851                        .child(
852                            div()
853                                .flex_1()
854                                .min_w(px(0.0))
855                                .truncate()
856                                .child(format!("Filtered from pivot: {label}")),
857                        )
858                        .child(
859                            div()
860                                .id("clear-drill-filter")
861                                .flex()
862                                .flex_none()
863                                .items_center()
864                                .gap(px(4.0))
865                                .px(px(6.0))
866                                .py(px(2.0))
867                                .rounded(px(4.0))
868                                .cursor_pointer()
869                                .hover(move |style| style.bg(hover_bg))
870                                .child(Icon::new(IconName::CircleX))
871                                .child("Clear filter")
872                                .on_mouse_down(MouseButton::Left, move |_event, _window, cx| {
873                                    table_clear.update(cx, |table, cx| {
874                                        table.clear_drill_filter(cx);
875                                    });
876                                }),
877                        );
878                    div()
879                        .flex()
880                        .flex_col()
881                        .size_full()
882                        .child(banner)
883                        .child(div().flex_1().min_h_0().child(grid_view))
884                        .into_any_element()
885                } else {
886                    grid_view.into_any_element()
887                }
888            }
889            GridTab::Pivot => {
890                let position = self.pivot_sidebar_position;
891                let collapsed = self.pivot_sidebar_collapsed;
892
893                if collapsed {
894                    // Collapsed: a slim labelled rail where the sidebar was;
895                    // clicking it expands the sidebar again.
896                    let rail = pivot_collapsed_rail(&theme, position, cx.entity().clone());
897                    let pivot_grid = div().flex_1().min_w_0().child(parts.grid.clone());
898                    let pivot_view = div().flex().flex_row().size_full();
899                    let pivot_view = match position {
900                        PivotSidebarPosition::Left => pivot_view.child(rail).child(pivot_grid),
901                        PivotSidebarPosition::Right => pivot_view.child(pivot_grid).child(rail),
902                    };
903                    pivot_view.into_any_element()
904                } else {
905                    // Expanded: a `gpui-component` resizable split. The group
906                    // state owns the live panel sizes; drag-to-resize is
907                    // handled entirely by the library's resize handle.
908                    // Apply a programmatic `set_pivot_sidebar_width` by
909                    // dropping the group state so a fresh one re-seeds from
910                    // `.size()` below (`ResizableState::resize_panel` is
911                    // private in gpui-component 0.5.1).
912                    if self.pivot_sidebar_width_dirty {
913                        self.pivot_sidebar_width_dirty = false;
914                        self.pivot_sidebar_resize = None;
915                    }
916                    let resize_state = match self.pivot_sidebar_resize.clone() {
917                        Some(state) => state,
918                        None => {
919                            let state = cx.new(|_| ResizableState::default());
920                            self.pivot_sidebar_resize = Some(state.clone());
921                            state
922                        }
923                    };
924                    let sidebar_ix = match position {
925                        PivotSidebarPosition::Left => 0,
926                        PivotSidebarPosition::Right => 1,
927                    };
928
929                    let toggle_strip = pivot_toggle_strip(&theme, position, cx.entity().clone());
930                    let sidebar_body = div()
931                        .flex_1()
932                        .min_w_0()
933                        .h_full()
934                        .child(parts.sidebar.clone());
935                    let sidebar_content = div().flex().flex_row().size_full();
936                    let sidebar_content = match position {
937                        PivotSidebarPosition::Left => {
938                            sidebar_content.child(sidebar_body).child(toggle_strip)
939                        }
940                        PivotSidebarPosition::Right => {
941                            sidebar_content.child(toggle_strip).child(sidebar_body)
942                        }
943                    };
944                    let sidebar_panel = resizable_panel()
945                        .size(px(self.pivot_sidebar_width))
946                        .size_range(px(MIN_PIVOT_SIDEBAR_WIDTH)..px(MAX_PIVOT_SIDEBAR_WIDTH))
947                        .child(sidebar_content);
948                    let grid_panel = resizable_panel()
949                        .child(div().size_full().min_w_0().child(parts.grid.clone()));
950
951                    let table_resize = cx.entity().clone();
952                    let group = h_resizable("pivot-sidebar-split")
953                        .with_state(&resize_state)
954                        .on_resize(move |state, _window, cx| {
955                            let Some(width) = state.read(cx).sizes().get(sidebar_ix).copied()
956                            else {
957                                return;
958                            };
959                            table_resize.update(cx, |table, cx| {
960                                // Direct field write: `set_pivot_sidebar_width`
961                                // would mark the width dirty and push it right
962                                // back into the group state next render.
963                                table.pivot_sidebar_width = f32::from(width)
964                                    .clamp(MIN_PIVOT_SIDEBAR_WIDTH, MAX_PIVOT_SIDEBAR_WIDTH);
965                                cx.notify();
966                            });
967                        });
968                    let group = match position {
969                        PivotSidebarPosition::Left => group.child(sidebar_panel).child(grid_panel),
970                        PivotSidebarPosition::Right => group.child(grid_panel).child(sidebar_panel),
971                    };
972                    group.into_any_element()
973                }
974            }
975        };
976
977        div()
978            .flex()
979            .flex_col()
980            .size_full()
981            .child(tab_bar)
982            .child(div().flex_1().min_h_0().child(content))
983    }
984}
985
986/// Human-readable summary of a drill-through's per-column value filters,
987/// e.g. `region = East, location = Boston, txn_type = Sale`. Multi-value
988/// sets list up to three values; longer sets are elided with a count.
989fn drill_filter_label(
990    data: &crate::data::GridData,
991    filter_sets: &[(usize, std::collections::HashSet<String>)],
992) -> String {
993    let mut parts = Vec::with_capacity(filter_sets.len());
994    for (field, values) in filter_sets {
995        let name = data.columns.get(*field).map_or("?", |c| c.name.as_str());
996        let mut sorted: Vec<&str> = values.iter().map(String::as_str).collect();
997        sorted.sort_unstable();
998        let shown = match sorted.len() {
999            0 => continue,
1000            1..=3 => sorted.join(" | "),
1001            n => format!("{} | … ({n} values)", sorted[..2].join(" | ")),
1002        };
1003        parts.push(format!("{name} = {shown}"));
1004    }
1005    parts.join(", ")
1006}
1007
1008/// The slim labelled rail shown in place of the pivot sidebar while it is
1009/// collapsed. Ports the old `gpui-ui-kit` pane-divider collapsed strip:
1010/// expand arrows above and below a vertically stacked label; any click
1011/// expands the sidebar.
1012fn pivot_collapsed_rail(
1013    theme: &GridTheme,
1014    position: PivotSidebarPosition,
1015    table: Entity<SqllyDataTable>,
1016) -> impl IntoElement {
1017    // Lucide panel-open icons pointing toward where the sidebar reappears.
1018    let icon_name = match position {
1019        PivotSidebarPosition::Left => IconName::PanelLeftOpen,
1020        PivotSidebarPosition::Right => IconName::PanelRightOpen,
1021    };
1022    let label_chars = "Pivot".chars().map({
1023        let fg = theme.muted_text;
1024        move |c| {
1025            div()
1026                .text_color(fg)
1027                .text_size(px(11.0))
1028                .child(c.to_string())
1029        }
1030    });
1031    let hover_bg = theme.menu_hover_bg;
1032    let arrow_glyph = move |fg: gpui::Hsla| {
1033        div()
1034            .text_color(fg)
1035            .text_size(px(13.0))
1036            .child(Icon::new(icon_name.clone()))
1037    };
1038
1039    div()
1040        .id("pivot-sidebar-rail")
1041        .w(px(24.0))
1042        .h_full()
1043        .flex_none()
1044        .flex()
1045        .flex_col()
1046        .items_center()
1047        .justify_center()
1048        .gap(px(4.0))
1049        .bg(theme.menu_bg)
1050        .border_x_1()
1051        .border_color(theme.grid_line)
1052        .cursor_pointer()
1053        .hover(move |style| style.bg(hover_bg))
1054        .child(arrow_glyph(theme.muted_text))
1055        .child(
1056            div()
1057                .flex()
1058                .flex_col()
1059                .items_center()
1060                .gap(px(2.0))
1061                .py_2()
1062                .children(label_chars),
1063        )
1064        .child(arrow_glyph(theme.muted_text))
1065        .on_mouse_down(MouseButton::Left, move |_event, _window, cx| {
1066            table.update(cx, |table, cx| {
1067                table.set_pivot_sidebar_collapsed(false);
1068                cx.notify();
1069            });
1070        })
1071}
1072
1073/// The slim collapse strip on the pivot sidebar's inner edge while expanded.
1074/// Clicking it collapses the sidebar into [`pivot_collapsed_rail`]. Sits
1075/// beside the resizable group's drag handle, replacing the old pane-divider
1076/// double-click-to-collapse affordance with a single click.
1077fn pivot_toggle_strip(
1078    theme: &GridTheme,
1079    position: PivotSidebarPosition,
1080    table: Entity<SqllyDataTable>,
1081) -> impl IntoElement {
1082    // Lucide panel-close icon pointing toward where the sidebar collapses.
1083    let icon_name = match position {
1084        PivotSidebarPosition::Left => IconName::PanelLeftClose,
1085        PivotSidebarPosition::Right => IconName::PanelRightClose,
1086    };
1087    let hover_bg = theme.menu_hover_bg;
1088
1089    div()
1090        .id("pivot-sidebar-toggle")
1091        .w(px(16.0))
1092        .h_full()
1093        .flex_none()
1094        .flex()
1095        .flex_col()
1096        .items_center()
1097        .justify_center()
1098        .bg(theme.header_bg)
1099        .border_x_1()
1100        .border_color(theme.grid_line)
1101        .cursor_pointer()
1102        .hover(move |style| style.bg(hover_bg))
1103        .child(
1104            div()
1105                .text_color(theme.muted_text)
1106                .text_size(px(13.0))
1107                .child(Icon::new(icon_name)),
1108        )
1109        .on_mouse_down(MouseButton::Left, move |_event, _window, cx| {
1110            table.update(cx, |table, cx| {
1111                table.set_pivot_sidebar_collapsed(true);
1112                cx.notify();
1113            });
1114        })
1115}
1116
1117impl SqllyDataTable {
1118    /// The flat-grid element tree (canvas, overlays, input handlers). Shared
1119    /// by the tabless render path and the "Grid" tab.
1120    fn render_grid_view(&mut self, cx: &mut Context<Self>) -> Div {
1121        let state_canvas = self.state.clone();
1122        let state_status = self.state.clone();
1123        let state_mouse = self.state.clone();
1124        let state_move = self.state.clone();
1125        let state_up = self.state.clone();
1126        let state_scroll = self.state.clone();
1127        let state_key = self.state.clone();
1128        let state_right = self.state.clone();
1129        let bg = self.state.read(cx).theme.bg;
1130        let focus_handle = self.state.read(cx).focus_handle.clone();
1131        let focus_left = focus_handle.clone();
1132        let focus_right = focus_handle.clone();
1133        let debug_bar = self.state.read(cx).debug_bar_enabled;
1134        let status_h = self.state.read(cx).status_bar_height;
1135
1136        // Process any pending menu action from a previous mouse-down on a
1137        // menu item (needs App access for clipboard).
1138        if let Some((action, col)) = self.state.read(cx).pending_action {
1139            self.state.update(cx, |s, cx| {
1140                s.execute_action(action, col, cx);
1141                s.pending_action = None;
1142            });
1143        }
1144
1145        // Process any pending custom context-menu action.
1146        if let Some(pending) = self
1147            .state
1148            .read(cx)
1149            .pending_custom_context_menu_action
1150            .clone()
1151        {
1152            self.state.update(cx, |s, cx| {
1153                s.pending_custom_context_menu_action = None;
1154                s.execute_custom_context_menu_action(pending, cx);
1155            });
1156        }
1157
1158        // Spawn an edge-scroll timer **only while a drag is in progress**, and
1159        // **only one at a time**. Without the `edge_scroll_active` guard,
1160        // `render` would spawn a fresh 16 ms loop on every frame/notify during
1161        // a drag — each successful tick calls `cx.notify()`, which re-renders
1162        // and spawned yet another task, stacking concurrent loops that each
1163        // apply a scroll delta per tick and multiply the effective speed
1164        // without bound. The task clears the flag when it exits.
1165        if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
1166            self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
1167            let state_edge = self.state.clone();
1168            cx.spawn(async move |_weak, cx| {
1169                loop {
1170                    cx.background_executor()
1171                        .timer(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS))
1172                        .await;
1173                    let scrolled = cx
1174                        .update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()))
1175                        .unwrap_or(false);
1176                    if scrolled {
1177                        let _ = state_edge.update(cx, |_s, cx| cx.notify());
1178                    }
1179                    let dragging = cx
1180                        .update(|cx| state_edge.read(cx).is_dragging)
1181                        .unwrap_or(false);
1182                    if !dragging {
1183                        break;
1184                    }
1185                }
1186                let _ =
1187                    cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
1188            })
1189            .detach();
1190        }
1191
1192        div()
1193            .flex()
1194            .flex_col()
1195            .size_full()
1196            .relative()
1197            .track_focus(&focus_handle)
1198            .bg(bg)
1199            .child(
1200                canvas(
1201                    move |bounds, window, cx| -> PaintData {
1202                        let viewport = window.viewport_size();
1203                        state_canvas.update(cx, |s, cx| {
1204                            let mut dirty = false;
1205                            if s.bounds != bounds {
1206                                s.bounds = bounds;
1207                                s.clamp_scroll_to_bounds();
1208                                dirty = true;
1209                            }
1210                            if s.window_viewport != viewport {
1211                                s.window_viewport = viewport;
1212                            }
1213                            if dirty {
1214                                cx.notify();
1215                            }
1216                        });
1217                        let s = state_canvas.read(cx);
1218                        let mut data = PaintData::from_state(s);
1219                        data.focused = s.focus_handle.is_focused(window);
1220                        data
1221                    },
1222                    move |bounds, data, window, cx| {
1223                        paint_grid(&data, window, cx, bounds);
1224                    },
1225                )
1226                .flex_1(),
1227            )
1228            .children(debug_bar.then(|| {
1229                canvas(
1230                    move |_bounds, _window, cx| -> StatusBarData {
1231                        let s = state_status.read(cx);
1232                        StatusBarData::from_state(s)
1233                    },
1234                    move |bounds, data, window, cx| {
1235                        paint_status_bar(&data, window, cx, bounds);
1236                    },
1237                )
1238                .h(px(status_h))
1239            }))
1240            .children(render_context_menu_overlay(&self.state, cx))
1241            .children(render_filter_panel_overlay(&self.state, cx))
1242            .children(render_busy_overlay(&self.state, cx))
1243            .on_mouse_down(
1244                MouseButton::Left,
1245                move |event: &MouseDownEvent, window, cx| {
1246                    window.focus(&focus_left);
1247                    state_mouse.update(cx, |s, cx| {
1248                        // Ignore grid input while a background task is running;
1249                        // the busy overlay is shown and occludes interaction.
1250                        if s.busy.is_some() {
1251                            return;
1252                        }
1253                        // Normalize the absolute window pointer into the grid's
1254                        // own frame. Menu hit-testing is handled by the deferred
1255                        // overlay's own item handlers, so a left-click that
1256                        // reaches the grid means the pointer was NOT on the menu;
1257                        // dismiss any open menu and proceed with grid selection.
1258                        let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
1259                        if s.context_menu.is_some() || s.filter_panel.is_some() {
1260                            s.context_menu = None;
1261                            s.filter_panel = None;
1262                        }
1263                        s.handle_mouse_down_with_modifiers(
1264                            rel,
1265                            event.modifiers.shift,
1266                            event.modifiers.platform,
1267                        );
1268                        cx.notify();
1269                    });
1270                },
1271            )
1272            .on_mouse_down(
1273                MouseButton::Right,
1274                move |event: &MouseDownEvent, window, cx| {
1275                    window.focus(&focus_right);
1276                    state_right.update(cx, |s, cx| {
1277                        if s.busy.is_some() {
1278                            return;
1279                        }
1280                        let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
1281                        let hit = s.hit_test(pos);
1282
1283                        // No provider — existing built-in behavior.
1284                        if s.context_menu_provider.is_none() {
1285                            match hit {
1286                                HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
1287                                    s.open_context_menu(col, pos);
1288                                }
1289                                _ => {
1290                                    s.context_menu = None;
1291                                    s.filter_panel = None;
1292                                }
1293                            }
1294                            cx.notify();
1295                            return;
1296                        }
1297
1298                        // Provider exists — build custom menu.
1299                        let Some(target) = s.context_menu_target_from_hit(hit) else {
1300                            s.context_menu = None;
1301                            s.filter_panel = None;
1302                            cx.notify();
1303                            return;
1304                        };
1305
1306                        let effective = s.effective_selection_for_context_target(&target);
1307                        if effective != s.selection {
1308                            s.selection = effective.clone();
1309                        }
1310
1311                        let request = s.build_context_menu_request(target, &effective);
1312                        let col = request.target.column_index().unwrap_or(0);
1313
1314                        let Some(provider) = s.context_menu_provider.clone() else {
1315                            return;
1316                        };
1317                        let public_items = provider.menu_items(&request);
1318                        let items = GridState::convert_context_menu_items(public_items);
1319
1320                        if items.is_empty() {
1321                            s.context_menu = None;
1322                        } else {
1323                            s.context_menu =
1324                                Some(menu::ContextMenu::custom(col, pos, items, request));
1325                        }
1326                        s.filter_panel = None;
1327                        cx.notify();
1328                    });
1329                },
1330            )
1331            .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
1332                state_move.update(cx, |s, cx| {
1333                    let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
1334                    s.handle_mouse_move(rel, event.pressed_button);
1335                    cx.notify();
1336                });
1337            })
1338            .on_mouse_up(
1339                MouseButton::Left,
1340                move |_event: &MouseUpEvent, _window, cx| {
1341                    state_up.update(cx, |s, cx| {
1342                        s.handle_mouse_up();
1343                        cx.notify();
1344                    });
1345                },
1346            )
1347            .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
1348                state_scroll.update(cx, |s, cx| {
1349                    let line_h = px(s.row_height);
1350                    let delta = event.delta.pixel_delta(line_h);
1351                    let scroll = s.scroll_handle.offset();
1352                    let (mx, my) = s.max_scroll();
1353                    let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
1354                    let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
1355                    s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
1356                    if s.drag_start.is_some() {
1357                        s.handle_scroll_drag();
1358                    }
1359                    cx.notify();
1360                });
1361            })
1362            .on_key_down(move |event: &KeyDownEvent, _window, cx| {
1363                let ks = &event.keystroke;
1364                if ks.modifiers.platform && ks.key == "q" {
1365                    cx.quit();
1366                    return;
1367                }
1368                state_key.update(cx, |s, cx| {
1369                    let kb = &s.config.key_bindings;
1370                    if kb.select_all.matches(ks) {
1371                        s.select_all();
1372                    } else if kb.copy.matches(ks) {
1373                        s.copy_selection(false, cx);
1374                    } else if kb.copy_with_headers.matches(ks) {
1375                        s.copy_selection(true, cx);
1376                    } else if kb.page_up.matches(ks) {
1377                        s.page_up();
1378                    } else if kb.page_down.matches(ks) {
1379                        s.page_down();
1380                    } else {
1381                        s.handle_key(ks);
1382                    }
1383                    cx.notify();
1384                });
1385            })
1386    }
1387}
1388
1389/// Build the context-menu overlay as a `deferred` + `anchored` element so it
1390/// paints — and receives mouse events — on top of everything, including
1391/// regions outside the grid widget's own layout bounds. Returns `None` when no
1392/// menu is open.
1393///
1394/// Positioning reuses [`menu::ContextMenu::resolved_position`] (window-viewport
1395/// aware: flips up when there's no room below, shifts left at the right edge),
1396/// then converts to absolute window coordinates for `anchored().position(..)`.
1397/// Each selectable row carries its own `on_mouse_down` (dispatch) and
1398/// `on_mouse_move` (hover highlight) handlers; a full-screen backdrop behind
1399/// the menu dismisses it on any outside click.
1400fn render_context_menu_overlay(
1401    state: &Entity<GridState>,
1402    cx: &mut Context<SqllyDataTable>,
1403) -> Option<impl IntoElement> {
1404    let s = state.read(cx);
1405    let menu = s.context_menu.clone()?;
1406    let theme = s.theme.clone();
1407    let animations = s.config.animations;
1408    let cw = s.char_width;
1409    let grid_ox = f32::from(s.bounds.origin.x);
1410    let grid_oy = f32::from(s.bounds.origin.y);
1411    let viewport = s.window_viewport;
1412    let vw = f32::from(viewport.width);
1413    let vh = f32::from(viewport.height);
1414
1415    let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
1416    let abs_x = grid_ox + f32::from(resolved.x);
1417    let abs_y = grid_oy + f32::from(resolved.y);
1418    let menu_w = menu.width_for(cw);
1419
1420    // Build one row per item. `selectable_idx` counts only Action/Custom items
1421    // so it matches the `hovered` index convention used elsewhere.
1422    let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
1423    let mut selectable_idx = 0usize;
1424    for item in &menu.items {
1425        match item {
1426            MenuItem::Separator => {
1427                rows.push(
1428                    div()
1429                        .h(px(menu::MENU_ITEM_HEIGHT))
1430                        .flex()
1431                        .items_center()
1432                        .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
1433                        .into_any_element(),
1434                );
1435            }
1436            MenuItem::Action(_) | MenuItem::Custom { .. } => {
1437                let this_idx = selectable_idx;
1438                selectable_idx += 1;
1439                let label = item.label().unwrap_or("").to_owned();
1440                let hovered = menu.hovered == Some(this_idx);
1441
1442                // Dispatch: set the pending action and close the menu. The
1443                // pending fields are drained at the top of `render` (they need
1444                // App access for clipboard).
1445                let action = match item {
1446                    MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
1447                    MenuItem::Custom { id, .. } => {
1448                        MenuDispatch::Custom(id.clone(), menu.request.clone())
1449                    }
1450                    MenuItem::Separator => unreachable!(),
1451                };
1452
1453                let state_click = state.clone();
1454                let state_hover = state.clone();
1455                let mut row = div()
1456                    .h(px(menu::MENU_ITEM_HEIGHT))
1457                    .px(px(menu::MENU_PADDING_X))
1458                    .flex()
1459                    .items_center()
1460                    .text_color(theme.menu_fg)
1461                    .text_size(px(menu::MENU_FONT_SIZE))
1462                    .child(label)
1463                    .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
1464                        state_hover.update(cx, |s, cx| {
1465                            if let Some(m) = s.context_menu.as_mut() {
1466                                if m.hovered != Some(this_idx) {
1467                                    m.hovered = Some(this_idx);
1468                                    cx.notify();
1469                                }
1470                            }
1471                        });
1472                    })
1473                    .on_mouse_down(
1474                        MouseButton::Left,
1475                        move |_e: &MouseDownEvent, _window, cx| {
1476                            state_click.update(cx, |s, cx| {
1477                                match &action {
1478                                    MenuDispatch::Builtin(a, col) => {
1479                                        s.pending_action = Some((*a, *col));
1480                                    }
1481                                    MenuDispatch::Custom(id, request) => {
1482                                        if let Some(request) = request {
1483                                            s.pending_custom_context_menu_action =
1484                                                Some(PendingCustomContextMenuAction {
1485                                                    id: id.clone(),
1486                                                    request: request.clone(),
1487                                                });
1488                                        }
1489                                    }
1490                                }
1491                                s.context_menu = None;
1492                                cx.notify();
1493                            });
1494                        },
1495                    );
1496                if hovered {
1497                    row = row.bg(theme.menu_hover_bg);
1498                }
1499                rows.push(row.into_any_element());
1500            }
1501        }
1502    }
1503
1504    let menu_body = div()
1505        .flex()
1506        .flex_col()
1507        .w(px(menu_w))
1508        .py(px(menu::MENU_INNER_PAD))
1509        .bg(theme.menu_bg)
1510        .border_1()
1511        .border_color(theme.grid_line)
1512        .children(rows);
1513
1514    // Full-window transparent backdrop: catches clicks outside the menu to
1515    // dismiss it. Placed behind the menu within the same anchored overlay.
1516    let state_backdrop = state.clone();
1517    let overlay = deferred(
1518        anchored().position(point(px(abs_x), px(abs_y))).child(
1519            div()
1520                .occlude()
1521                .child(crate::grid::motion::pop_in(
1522                    menu_body,
1523                    "grid-context-menu",
1524                    animations,
1525                ))
1526                .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
1527                    state_backdrop.update(cx, |s, cx| {
1528                        if s.context_menu.is_some() {
1529                            s.context_menu = None;
1530                            s.filter_panel = None;
1531                            cx.notify();
1532                        }
1533                    });
1534                }),
1535        ),
1536    )
1537    .with_priority(CONTEXT_MENU_PRIORITY);
1538
1539    Some(overlay)
1540}
1541
1542/// Fixed width of the filter popover, in pixels.
1543const FILTER_PANEL_WIDTH: f32 = 300.0;
1544/// Max number of distinct value rows rendered at once (search narrows the set).
1545const FILTER_PANEL_MAX_ROWS: usize = 200;
1546
1547/// Build the Numbers-style per-column filter popover as a `deferred` +
1548/// `anchored` overlay, using the exact mechanism as
1549/// [`render_context_menu_overlay`] so it paints and receives events outside the
1550/// grid's own layout bounds. Returns `None` when no panel is open.
1551#[allow(clippy::too_many_lines)]
1552fn render_filter_panel_overlay(
1553    state: &Entity<GridState>,
1554    cx: &mut Context<SqllyDataTable>,
1555) -> Option<impl IntoElement> {
1556    let s = state.read(cx);
1557    let panel = s.filter_panel.clone()?;
1558    let theme = s.theme.clone();
1559    let animations = s.config.animations;
1560    let col = panel.col;
1561    let current_sort = s.sort;
1562    let filter_active = s.filters.get(col).is_some_and(|f| f.is_active());
1563    let grid_ox = f32::from(s.bounds.origin.x);
1564    let grid_oy = f32::from(s.bounds.origin.y);
1565
1566    // Anchor (grid-relative) -> absolute window coords. The default
1567    // `SwitchAnchor` fit mode on `anchored()` handles viewport-edge flipping
1568    // automatically using the actual rendered height, so we don't need a
1569    // manual estimate or flip calculation here.
1570    let abs_x = grid_ox + f32::from(panel.anchor.x);
1571    let abs_y = grid_oy + f32::from(panel.anchor.y);
1572
1573    // Palette (all `Hsla` are `Copy`, so they move freely into closures).
1574    let c_bg = theme.menu_bg;
1575    let c_line = theme.grid_line;
1576    let c_fg = theme.menu_fg;
1577    let c_accent = theme.sort_indicator;
1578    let c_hover = theme.menu_hover_bg;
1579    let c_muted = theme.muted_text;
1580
1581    let checkbox = {
1582        let theme = theme.clone();
1583        move |checked: bool| crate::grid::checkbox(checked, &theme)
1584    };
1585
1586    // --- Sort row -----------------------------------------------------------
1587    let (asc_active, desc_active) = match current_sort {
1588        Some((c, SortDirection::Ascending)) if c == col => (true, false),
1589        Some((c, SortDirection::Descending)) if c == col => (false, true),
1590        _ => (false, false),
1591    };
1592    let st_asc = state.clone();
1593    let st_desc = state.clone();
1594    let sort_row = div()
1595        .flex()
1596        .gap(px(6.0))
1597        .child(
1598            div()
1599                .flex_1()
1600                .h(px(26.0))
1601                .flex()
1602                .items_center()
1603                .justify_center()
1604                .border_1()
1605                .border_color(c_line)
1606                .bg(if asc_active { c_accent } else { c_hover })
1607                .cursor_pointer()
1608                .child("Ascending")
1609                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1610                    st_asc.update(cx, |s, cx| {
1611                        s.set_panel_sort(SortDirection::Ascending);
1612                        cx.notify();
1613                    });
1614                }),
1615        )
1616        .child(
1617            div()
1618                .flex_1()
1619                .h(px(26.0))
1620                .flex()
1621                .items_center()
1622                .justify_center()
1623                .border_1()
1624                .border_color(c_line)
1625                .bg(if desc_active { c_accent } else { c_hover })
1626                .cursor_pointer()
1627                .child("Descending")
1628                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1629                    st_desc.update(cx, |s, cx| {
1630                        s.set_panel_sort(SortDirection::Descending);
1631                        cx.notify();
1632                    });
1633                }),
1634        );
1635
1636    // --- Operator dropdown --------------------------------------------------
1637    let st_op_toggle = state.clone();
1638    let op_button = div()
1639        .h(px(26.0))
1640        .px(px(8.0))
1641        .flex()
1642        .items_center()
1643        .border_1()
1644        .border_color(c_line)
1645        .bg(c_bg)
1646        .cursor_pointer()
1647        .child(panel.current_op_label())
1648        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1649            st_op_toggle.update(cx, |s, cx| {
1650                s.toggle_filter_op_menu();
1651                cx.notify();
1652            });
1653        });
1654
1655    let op_menu = panel.op_menu_open.then(|| {
1656        let mut items: Vec<gpui::AnyElement> = Vec::new();
1657        for (i, label) in panel.op_labels().iter().enumerate() {
1658            let selected = i == panel.op_index;
1659            let st_pick = state.clone();
1660            items.push(
1661                div()
1662                    .h(px(24.0))
1663                    .px(px(8.0))
1664                    .flex()
1665                    .items_center()
1666                    .bg(if selected { c_accent } else { c_bg })
1667                    .cursor_pointer()
1668                    .child(*label)
1669                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1670                        st_pick.update(cx, |s, cx| {
1671                            s.set_filter_operator(i);
1672                            cx.notify();
1673                        });
1674                    })
1675                    .into_any_element(),
1676            );
1677        }
1678        div()
1679            .flex()
1680            .flex_col()
1681            .border_1()
1682            .border_color(c_line)
1683            .bg(c_bg)
1684            .children(items)
1685    });
1686
1687    // --- Operand field(s) ---------------------------------------------------
1688    let operand_field = |value: &str, focused: bool, placeholder: &str, input: FilterInput| {
1689        let st_focus = state.clone();
1690        let (text, is_placeholder) = if value.is_empty() {
1691            (placeholder.to_owned(), true)
1692        } else {
1693            (value.to_owned(), false)
1694        };
1695        div()
1696            .h(px(26.0))
1697            .px(px(6.0))
1698            .flex()
1699            .items_center()
1700            .gap(px(2.0))
1701            .border_1()
1702            .border_color(if focused { c_accent } else { c_line })
1703            .bg(c_bg)
1704            .cursor_pointer()
1705            .child(
1706                div()
1707                    .text_color(if is_placeholder { c_muted } else { c_fg })
1708                    .child(text),
1709            )
1710            .children(focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1711            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1712                st_focus.update(cx, |s, cx| {
1713                    s.set_filter_focus(input);
1714                    cx.notify();
1715                });
1716            })
1717    };
1718
1719    let operand_placeholder = if panel.kind == crate::data::ColumnKind::Date {
1720        "YYYY-MM-DD"
1721    } else if crate::filter::uses_number_ops(panel.kind) {
1722        "value"
1723    } else if panel.op_index == 7 {
1724        // Text "matches" operator.
1725        "regex"
1726    } else {
1727        "value"
1728    };
1729    let operands = panel.needs_operand().then(|| {
1730        let mut row = div().flex().flex_col().gap(px(4.0)).child(operand_field(
1731            &panel.operand_a.value,
1732            panel.focus == FilterInput::OperandA,
1733            operand_placeholder,
1734            FilterInput::OperandA,
1735        ));
1736        if panel.needs_second_operand() {
1737            row = row
1738                .child(div().text_color(c_muted).text_size(px(11.0)).child("and"))
1739                .child(operand_field(
1740                    &panel.operand_b.value,
1741                    panel.focus == FilterInput::OperandB,
1742                    operand_placeholder,
1743                    FilterInput::OperandB,
1744                ));
1745        }
1746        row
1747    });
1748
1749    // --- Search box ---------------------------------------------------------
1750    let st_search = state.clone();
1751    let search_focused = panel.focus == FilterInput::Search;
1752    let (search_text, search_is_ph) = if panel.search.value.is_empty() {
1753        ("Search".to_owned(), true)
1754    } else {
1755        (panel.search.value.clone(), false)
1756    };
1757    let search_box = div()
1758        .h(px(26.0))
1759        .px(px(6.0))
1760        .flex()
1761        .items_center()
1762        .gap(px(2.0))
1763        .border_1()
1764        .border_color(if search_focused { c_accent } else { c_line })
1765        .bg(c_bg)
1766        .cursor_pointer()
1767        .child(
1768            div()
1769                .text_color(if search_is_ph { c_muted } else { c_fg })
1770                .child(search_text),
1771        )
1772        .children(search_focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1773        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1774            st_search.update(cx, |s, cx| {
1775                s.set_filter_focus(FilterInput::Search);
1776                cx.notify();
1777            });
1778        });
1779
1780    // --- (Select All) + value checklist ------------------------------------
1781    let st_all = state.clone();
1782    let select_all_row = div()
1783        .id("filter-select-all")
1784        .h(px(24.0))
1785        .flex()
1786        .items_center()
1787        .gap(px(6.0))
1788        .px(px(4.0))
1789        .rounded(px(4.0))
1790        .cursor_pointer()
1791        .hover(move |style| style.bg(c_hover))
1792        .child(checkbox(panel.all_checked()))
1793        .child("(Select All)")
1794        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1795            st_all.update(cx, |s, cx| {
1796                s.toggle_filter_select_all();
1797                cx.notify();
1798            });
1799        });
1800
1801    let visible = panel.visible_indices();
1802    let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
1803    for &idx in visible.iter().take(FILTER_PANEL_MAX_ROWS) {
1804        let row = &panel.distinct[idx];
1805        let st_val = state.clone();
1806        value_rows.push(
1807            div()
1808                .id(("filter-value", idx))
1809                .h(px(22.0))
1810                .flex()
1811                .items_center()
1812                .gap(px(6.0))
1813                .px(px(4.0))
1814                .rounded(px(4.0))
1815                .cursor_pointer()
1816                .hover(move |style| style.bg(c_hover))
1817                .child(checkbox(row.checked))
1818                .child(div().text_color(c_fg).child(row.label.clone()))
1819                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1820                    st_val.update(cx, |s, cx| {
1821                        s.toggle_filter_value(idx);
1822                        cx.notify();
1823                    });
1824                })
1825                .into_any_element(),
1826        );
1827    }
1828    let truncated = visible.len() > FILTER_PANEL_MAX_ROWS;
1829    let value_list = div()
1830        .id("filter-value-list")
1831        .flex()
1832        .flex_col()
1833        .max_h(px(180.0))
1834        .overflow_y_scroll()
1835        .children(value_rows)
1836        .children(truncated.then(|| {
1837            div()
1838                .text_color(c_muted)
1839                .text_size(px(11.0))
1840                .child("Refine search to see more…")
1841        }));
1842
1843    // --- Clear (left, disabled when no active filter) + Close (right) -----
1844    let st_clear = state.clone();
1845    let st_close = state.clone();
1846    let clear_bg = if filter_active { c_hover } else { c_bg };
1847    let clear_fg = if filter_active { c_fg } else { c_muted };
1848    let clear_border = if filter_active { c_line } else { c_muted };
1849    let buttons_row = div()
1850        .flex()
1851        .gap(px(6.0))
1852        .child(
1853            div()
1854                .flex_1()
1855                .h(px(28.0))
1856                .flex()
1857                .items_center()
1858                .justify_center()
1859                .border_1()
1860                .border_color(clear_border)
1861                .bg(clear_bg)
1862                .text_color(clear_fg)
1863                .cursor_pointer()
1864                .child("Clear Filter")
1865                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1866                    if !filter_active {
1867                        return;
1868                    }
1869                    st_clear.update(cx, |s, cx| {
1870                        s.clear_filter_panel();
1871                        cx.notify();
1872                    });
1873                }),
1874        )
1875        .child(
1876            div()
1877                .flex_1()
1878                .h(px(28.0))
1879                .flex()
1880                .items_center()
1881                .justify_center()
1882                .border_1()
1883                .border_color(c_line)
1884                .bg(c_hover)
1885                .cursor_pointer()
1886                .child("Close")
1887                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1888                    st_close.update(cx, |s, cx| {
1889                        s.filter_panel = None;
1890                        cx.notify();
1891                    });
1892                }),
1893        );
1894
1895    let panel_body = div()
1896        .flex()
1897        .flex_col()
1898        .w(px(FILTER_PANEL_WIDTH))
1899        .p(px(10.0))
1900        .gap(px(8.0))
1901        .bg(c_bg)
1902        .border_1()
1903        .border_color(c_line)
1904        .text_color(c_fg)
1905        .text_size(px(13.0))
1906        .child(div().text_color(c_muted).text_size(px(11.0)).child("Sort"))
1907        .child(sort_row)
1908        .child(
1909            div()
1910                .text_color(c_muted)
1911                .text_size(px(11.0))
1912                .child("Filter"),
1913        )
1914        .child(op_button)
1915        .children(op_menu)
1916        .children(operands)
1917        .child(search_box)
1918        .child(select_all_row)
1919        .child(value_list)
1920        .child(buttons_row);
1921
1922    let st_backdrop = state.clone();
1923    let overlay = deferred(
1924        anchored()
1925            .anchor(Corner::BottomLeft)
1926            .position(point(px(abs_x), px(abs_y)))
1927            .child(
1928                div()
1929                    .occlude()
1930                    .child(crate::grid::motion::pop_in(
1931                        panel_body,
1932                        "grid-filter-panel",
1933                        animations,
1934                    ))
1935                    .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
1936                        st_backdrop.update(cx, |s, cx| {
1937                            if s.filter_panel.is_some() {
1938                                s.filter_panel = None;
1939                                cx.notify();
1940                            }
1941                        });
1942                    }),
1943            ),
1944    )
1945    .with_priority(CONTEXT_MENU_PRIORITY);
1946
1947    Some(overlay)
1948}
1949
1950/// Renders the loading overlay while a background task runs. Returns `None`
1951/// when the grid is not busy. Painted as an absolute, input-occluding scrim
1952/// over the whole grid area with a centered card showing the task label and a
1953/// progress bar (determinate when progress is known, otherwise an animated
1954/// indeterminate bar).
1955fn render_busy_overlay(
1956    state: &Entity<GridState>,
1957    cx: &mut Context<SqllyDataTable>,
1958) -> Option<impl IntoElement> {
1959    let s = state.read(cx);
1960    let busy = s.busy.clone()?;
1961    let theme = s.theme.clone();
1962    let animations = s.config.animations;
1963    let track = theme.grid_line;
1964    let accent = theme.sort_indicator;
1965
1966    let bar: gpui::AnyElement = if let Some(p) = busy.progress {
1967        let p = p.clamp(0.0, 1.0);
1968        div()
1969            .h_full()
1970            .w(relative(p))
1971            .rounded(px(3.0))
1972            .bg(accent)
1973            .into_any_element()
1974    } else {
1975        div()
1976            .h_full()
1977            .w(relative(0.3))
1978            .rounded(px(3.0))
1979            .bg(accent)
1980            .with_animation(
1981                "busy-indeterminate",
1982                Animation::new(std::time::Duration::from_millis(900))
1983                    .repeat()
1984                    .with_easing(pulsating_between(0.15, 0.85)),
1985                |el, delta| el.w(relative(delta)),
1986            )
1987            .into_any_element()
1988    };
1989
1990    let card = div()
1991        .flex()
1992        .flex_col()
1993        .gap(px(10.0))
1994        .p(px(16.0))
1995        .min_w(px(220.0))
1996        .rounded(px(8.0))
1997        .bg(theme.menu_bg)
1998        .border_1()
1999        .border_color(theme.grid_line)
2000        .child(
2001            div()
2002                .text_color(theme.menu_fg)
2003                .text_size(px(14.0))
2004                .child(busy.label.clone()),
2005        )
2006        .child(
2007            div()
2008                .w_full()
2009                .h(px(6.0))
2010                .rounded(px(3.0))
2011                .bg(track)
2012                .child(bar),
2013        );
2014
2015    let overlay = div()
2016        .absolute()
2017        .top_0()
2018        .left_0()
2019        .size_full()
2020        .occlude()
2021        .flex()
2022        .items_center()
2023        .justify_center()
2024        .bg(theme.overlay_scrim)
2025        .child(card);
2026
2027    Some(crate::grid::motion::fade_in(
2028        overlay,
2029        "grid-busy-overlay",
2030        crate::grid::motion::SCRIM_ENTER_MS,
2031        animations,
2032    ))
2033}
2034
2035/// What a menu row dispatches when clicked. Captured per-row so the click
2036/// handler owns its data without borrowing the menu snapshot.
2037enum MenuDispatch {
2038    Builtin(menu::MenuAction, usize),
2039    Custom(
2040        String,
2041        Option<crate::grid::context_menu::ContextMenuRequest>,
2042    ),
2043}