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