Skip to main content

sqlly_datatable/grid/
widget.rs

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