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