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