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.as_ref().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. An observer swaps the theme
604        // whenever the system appearance changes, but we also reconcile the
605        // resolved theme against `window.appearance()` on every render: the
606        // appearance read on the very first frame can be stale (reported
607        // before the window has settled onto the OS appearance), which would
608        // otherwise strand the grid in the light variant on a dark-mode OS
609        // until the next appearance *change* event. Re-resolving each render
610        // self-heals that — it clones + compares one theme and only writes
611        // (and notifies) when the variant actually differs, so a steady state
612        // is a cheap no-op. Skipped entirely when the caller supplied an
613        // explicit theme override.
614        if self.follow_system_appearance {
615            let appearance = window.appearance();
616            let resolved = self.state.read(cx).theme_family.for_appearance(appearance);
617            if self.state.read(cx).theme != resolved {
618                self.state.update(cx, |s, cx| {
619                    s.theme = resolved;
620                    cx.notify();
621                });
622            }
623            if self.appearance_subscription.is_none() {
624                let state_appearance = self.state.clone();
625                self.appearance_subscription =
626                    Some(window.observe_window_appearance(move |window, cx| {
627                        let appearance = window.appearance();
628                        state_appearance.update(cx, |s, cx| {
629                            let resolved = s.theme_family.for_appearance(appearance);
630                            if s.theme != resolved {
631                                s.theme = resolved;
632                                cx.notify();
633                            }
634                        });
635                    }));
636            }
637        }
638
639        // Keep the pivot's theme in lockstep with the grid theme (which may
640        // have just changed via the appearance observer), and its sidebar
641        // width in sync with the resizable panel (the sidebar needs it to
642        // decide when chip labels are truncated).
643        if let Some(parts) = &self.pivot {
644            let grid_theme = self.state.read(cx).theme.clone();
645            let sidebar_width = self.pivot_sidebar_width;
646            parts.state.update(cx, |s, cx| {
647                let mut dirty = false;
648                if s.theme != grid_theme {
649                    s.theme = grid_theme;
650                    dirty = true;
651                }
652                if (s.sidebar_width - sidebar_width).abs() > 0.5 {
653                    s.sidebar_width = sidebar_width;
654                    dirty = true;
655                }
656                if dirty {
657                    cx.notify();
658                }
659            });
660        }
661
662        // Drill-through: a double-click on a pivot cell (or the built-in
663        // "Show source rows" menu action) queued per-column value filters.
664        // Apply them to the flat grid and switch to the Grid tab so the user
665        // lands on exactly the rows that drive the clicked cell.
666        if let Some(parts) = &self.pivot {
667            let drill = parts.state.update(cx, |s, _cx| s.take_pending_drill_down());
668            if let Some(filter_sets) = drill {
669                self.state.update(cx, |g, cx| {
670                    // Filters are unsupported in windowed-row mode; the
671                    // drill-through is skipped rather than presenting a
672                    // filter that silently covers only resident rows.
673                    if g.window.is_none() {
674                        for filter in &mut g.filters {
675                            *filter = ColumnFilter::default();
676                        }
677                        for (field, values) in filter_sets {
678                            if let Some(slot) = g.filters.get_mut(field) {
679                                *slot = ColumnFilter {
680                                    predicate: FilterPredicate::None,
681                                    values: Some(values),
682                                };
683                            }
684                        }
685                        g.recompute();
686                    }
687                    cx.notify();
688                });
689                self.active_tab = GridTab::Grid;
690                let focus = self.state.read(cx).focus_handle.clone();
691                window.focus(&focus);
692                cx.notify();
693            }
694        }
695
696        let grid_view = self.render_grid_view(cx);
697
698        let Some(parts) = &self.pivot else {
699            return div().size_full().child(grid_view);
700        };
701
702        let theme = self.state.read(cx).theme.clone();
703        let tab = |label: String,
704                   this_tab: GridTab,
705                   active: bool,
706                   locked: bool,
707                   cx: &mut Context<Self>| {
708            div()
709                .px(px(14.0))
710                .py(px(5.0))
711                .text_size(px(13.0))
712                .when(!locked, |tab| tab.cursor_pointer())
713                .when(locked, |tab| tab.opacity(0.55))
714                .bg(if active { theme.bg } else { theme.header_bg })
715                .text_color(if active {
716                    theme.header_fg
717                } else {
718                    theme.muted_text
719                })
720                .border_b_2()
721                .border_color(if active {
722                    theme.sort_indicator
723                } else {
724                    theme.header_bg
725                })
726                .child(label)
727                .when(!locked, |tab| {
728                    tab.on_mouse_down(
729                        MouseButton::Left,
730                        cx.listener(move |this, _e: &MouseDownEvent, window, cx| {
731                            if this.active_tab == this_tab {
732                                return;
733                            }
734                            this.set_active_tab(this_tab, cx);
735                            // Route keyboard input to the now-visible view.
736                            match this_tab {
737                                GridTab::Grid => {
738                                    let focus = this.state.read(cx).focus_handle.clone();
739                                    window.focus(&focus);
740                                }
741                                GridTab::Pivot => {
742                                    if let Some(p) = &this.pivot {
743                                        let focus = p.state.read(cx).focus_handle.clone();
744                                        window.focus(&focus);
745                                    }
746                                }
747                            }
748                            cx.notify();
749                        }),
750                    )
751                })
752        };
753
754        let pivot_label = self
755            .pivot_status
756            .as_deref()
757            .map_or_else(|| "Pivot".to_string(), |status| format!("Pivot  {status}"));
758
759        let tab_bar = div()
760            .flex()
761            .flex_row()
762            .flex_none()
763            .bg(theme.header_bg)
764            .border_b_1()
765            .border_color(theme.grid_line)
766            .child(tab(
767                "Grid".to_string(),
768                GridTab::Grid,
769                self.active_tab == GridTab::Grid,
770                false,
771                cx,
772            ))
773            .child(tab(
774                pivot_label,
775                GridTab::Pivot,
776                self.active_tab == GridTab::Pivot,
777                self.pivot_locked,
778                cx,
779            ));
780
781        let content: gpui::AnyElement = match self.active_tab {
782            GridTab::Grid => grid_view.into_any_element(),
783            GridTab::Pivot => {
784                let position = self.pivot_sidebar_position;
785                let collapsed = self.pivot_sidebar_collapsed;
786                let panel = div()
787                    .w(px(self.pivot_sidebar_width))
788                    .h_full()
789                    .flex_none()
790                    .child(parts.sidebar.clone());
791                let pivot_grid = div().flex_1().min_w_0().child(parts.grid.clone());
792                let direction = match position {
793                    PivotSidebarPosition::Left => CollapseDirection::Left,
794                    PivotSidebarPosition::Right => CollapseDirection::Right,
795                };
796                let divider_theme = PaneDividerTheme {
797                    background: theme.header_bg.into(),
798                    background_hover: theme.menu_hover_bg.into(),
799                    background_collapsed: theme.menu_bg.into(),
800                    foreground: theme.muted_text.into(),
801                    foreground_hover: theme.header_fg.into(),
802                    border: theme.grid_line.into(),
803                };
804                let table_toggle = cx.entity().clone();
805                let table_drag = cx.entity().clone();
806                let divider = PaneDivider::vertical("pivot-sidebar-divider", direction)
807                    .label("Pivot")
808                    .collapsed(collapsed)
809                    .theme(divider_theme)
810                    .on_toggle(move |collapsed, _window, cx| {
811                        table_toggle.update(cx, |table, cx| {
812                            table.set_pivot_sidebar_collapsed(collapsed);
813                            cx.notify();
814                        });
815                    })
816                    .on_drag_start(move |start_x, _window, cx| {
817                        table_drag.update(cx, |table, cx| {
818                            table.pivot_sidebar_drag = Some(PivotSidebarDrag {
819                                start_x,
820                                start_width: table.pivot_sidebar_width,
821                            });
822                            cx.notify();
823                        });
824                    });
825
826                let mut pivot_view = div().flex().flex_row().size_full();
827                pivot_view = match position {
828                    PivotSidebarPosition::Left => pivot_view
829                        .children((!collapsed).then_some(panel))
830                        .child(divider)
831                        .child(pivot_grid),
832                    PivotSidebarPosition::Right => pivot_view
833                        .child(pivot_grid)
834                        .child(divider)
835                        .children((!collapsed).then_some(panel)),
836                };
837
838                pivot_view.into_any_element()
839            }
840        };
841
842        let mut root = div()
843            .flex()
844            .flex_col()
845            .size_full()
846            .child(tab_bar)
847            .child(div().flex_1().min_h_0().child(content));
848
849        if let Some(drag) = self.pivot_sidebar_drag {
850            let position = self.pivot_sidebar_position;
851            let table_move = cx.entity().clone();
852            let table_up = cx.entity().clone();
853            let table_up_out = cx.entity().clone();
854            root = root
855                .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
856                    let current_x: f32 = event.position.x.into();
857                    let delta = match position {
858                        PivotSidebarPosition::Left => current_x - drag.start_x,
859                        PivotSidebarPosition::Right => drag.start_x - current_x,
860                    };
861                    table_move.update(cx, |table, cx| {
862                        table.set_pivot_sidebar_width(drag.start_width + delta);
863                        cx.notify();
864                    });
865                })
866                .on_mouse_up(MouseButton::Left, move |_event, _window, cx| {
867                    table_up.update(cx, |table, cx| {
868                        table.pivot_sidebar_drag = None;
869                        cx.notify();
870                    });
871                })
872                .on_mouse_up_out(MouseButton::Left, move |_event, _window, cx| {
873                    table_up_out.update(cx, |table, cx| {
874                        table.pivot_sidebar_drag = None;
875                        cx.notify();
876                    });
877                });
878        }
879
880        root
881    }
882}
883
884impl SqllyDataTable {
885    /// The flat-grid element tree (canvas, overlays, input handlers). Shared
886    /// by the tabless render path and the "Grid" tab.
887    fn render_grid_view(&mut self, cx: &mut Context<Self>) -> Div {
888        let state_canvas = self.state.clone();
889        let state_status = self.state.clone();
890        let state_mouse = self.state.clone();
891        let state_move = self.state.clone();
892        let state_up = self.state.clone();
893        let state_scroll = self.state.clone();
894        let state_key = self.state.clone();
895        let state_right = self.state.clone();
896        let bg = self.state.read(cx).theme.bg;
897        let focus_handle = self.state.read(cx).focus_handle.clone();
898        let focus_left = focus_handle.clone();
899        let focus_right = focus_handle.clone();
900        let debug_bar = self.state.read(cx).debug_bar_enabled;
901        let status_h = self.state.read(cx).status_bar_height;
902
903        // Process any pending menu action from a previous mouse-down on a
904        // menu item (needs App access for clipboard).
905        if let Some((action, col)) = self.state.read(cx).pending_action {
906            self.state.update(cx, |s, cx| {
907                s.execute_action(action, col, cx);
908                s.pending_action = None;
909            });
910        }
911
912        // Process any pending custom context-menu action.
913        if let Some(pending) = self
914            .state
915            .read(cx)
916            .pending_custom_context_menu_action
917            .clone()
918        {
919            self.state.update(cx, |s, cx| {
920                s.pending_custom_context_menu_action = None;
921                s.execute_custom_context_menu_action(pending, cx);
922            });
923        }
924
925        // Spawn an edge-scroll timer **only while a drag is in progress**, and
926        // **only one at a time**. Without the `edge_scroll_active` guard,
927        // `render` would spawn a fresh 16 ms loop on every frame/notify during
928        // a drag — each successful tick calls `cx.notify()`, which re-renders
929        // and spawned yet another task, stacking concurrent loops that each
930        // apply a scroll delta per tick and multiply the effective speed
931        // without bound. The task clears the flag when it exits.
932        if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
933            self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
934            let state_edge = self.state.clone();
935            cx.spawn(async move |_weak, cx| {
936                loop {
937                    gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
938                    let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
939                    if let Ok(true) = res {
940                        let _ = state_edge.update(cx, |_s, cx| cx.notify());
941                    }
942                    let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
943                    if !matches!(dragging_res, Ok(true)) {
944                        break;
945                    }
946                }
947                let _ =
948                    cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
949            })
950            .detach();
951        }
952
953        div()
954            .flex()
955            .flex_col()
956            .size_full()
957            .relative()
958            .track_focus(&focus_handle)
959            .bg(bg)
960            .child(
961                canvas(
962                    move |bounds, window, cx| -> PaintData {
963                        let viewport = window.viewport_size();
964                        state_canvas.update(cx, |s, cx| {
965                            let mut dirty = false;
966                            if s.bounds != bounds {
967                                s.bounds = bounds;
968                                s.clamp_scroll_to_bounds();
969                                dirty = true;
970                            }
971                            if s.window_viewport != viewport {
972                                s.window_viewport = viewport;
973                            }
974                            if dirty {
975                                cx.notify();
976                            }
977                        });
978                        let s = state_canvas.read(cx);
979                        let mut data = PaintData::from_state(s);
980                        data.focused = s.focus_handle.is_focused(window);
981                        data
982                    },
983                    move |bounds, data, window, cx| {
984                        paint_grid(&data, window, cx, bounds);
985                    },
986                )
987                .flex_1(),
988            )
989            .children(debug_bar.then(|| {
990                canvas(
991                    move |_bounds, _window, cx| -> StatusBarData {
992                        let s = state_status.read(cx);
993                        StatusBarData::from_state(s)
994                    },
995                    move |bounds, data, window, cx| {
996                        paint_status_bar(&data, window, cx, bounds);
997                    },
998                )
999                .h(px(status_h))
1000            }))
1001            .children(render_context_menu_overlay(&self.state, cx))
1002            .children(render_filter_panel_overlay(&self.state, cx))
1003            .children(render_busy_overlay(&self.state, cx))
1004            .on_mouse_down(
1005                MouseButton::Left,
1006                move |event: &MouseDownEvent, window, cx| {
1007                    window.focus(&focus_left);
1008                    state_mouse.update(cx, |s, cx| {
1009                        // Ignore grid input while a background task is running;
1010                        // the busy overlay is shown and occludes interaction.
1011                        if s.busy.is_some() {
1012                            return;
1013                        }
1014                        // Normalize the absolute window pointer into the grid's
1015                        // own frame. Menu hit-testing is handled by the deferred
1016                        // overlay's own item handlers, so a left-click that
1017                        // reaches the grid means the pointer was NOT on the menu;
1018                        // dismiss any open menu and proceed with grid selection.
1019                        let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
1020                        if s.context_menu.is_some() || s.filter_panel.is_some() {
1021                            s.context_menu = None;
1022                            s.filter_panel = None;
1023                        }
1024                        s.handle_mouse_down_with_modifiers(
1025                            rel,
1026                            event.modifiers.shift,
1027                            event.modifiers.platform,
1028                        );
1029                        cx.notify();
1030                    });
1031                },
1032            )
1033            .on_mouse_down(
1034                MouseButton::Right,
1035                move |event: &MouseDownEvent, window, cx| {
1036                    window.focus(&focus_right);
1037                    state_right.update(cx, |s, cx| {
1038                        if s.busy.is_some() {
1039                            return;
1040                        }
1041                        let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
1042                        let hit = s.hit_test(pos);
1043
1044                        // No provider — existing built-in behavior.
1045                        if s.context_menu_provider.is_none() {
1046                            match hit {
1047                                HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
1048                                    s.open_context_menu(col, pos);
1049                                }
1050                                _ => {
1051                                    s.context_menu = None;
1052                                    s.filter_panel = None;
1053                                }
1054                            }
1055                            cx.notify();
1056                            return;
1057                        }
1058
1059                        // Provider exists — build custom menu.
1060                        let Some(target) = s.context_menu_target_from_hit(hit) else {
1061                            s.context_menu = None;
1062                            s.filter_panel = None;
1063                            cx.notify();
1064                            return;
1065                        };
1066
1067                        let effective = s.effective_selection_for_context_target(&target);
1068                        if effective != s.selection {
1069                            s.selection = effective.clone();
1070                        }
1071
1072                        let request = s.build_context_menu_request(target, &effective);
1073                        let col = request.target.column_index().unwrap_or(0);
1074
1075                        let Some(provider) = s.context_menu_provider.clone() else {
1076                            return;
1077                        };
1078                        let public_items = provider.menu_items(&request);
1079                        let items = GridState::convert_context_menu_items(public_items);
1080
1081                        if items.is_empty() {
1082                            s.context_menu = None;
1083                        } else {
1084                            s.context_menu =
1085                                Some(menu::ContextMenu::custom(col, pos, items, request));
1086                        }
1087                        s.filter_panel = None;
1088                        cx.notify();
1089                    });
1090                },
1091            )
1092            .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
1093                state_move.update(cx, |s, cx| {
1094                    let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
1095                    s.handle_mouse_move(rel, event.pressed_button);
1096                    cx.notify();
1097                });
1098            })
1099            .on_mouse_up(
1100                MouseButton::Left,
1101                move |_event: &MouseUpEvent, _window, cx| {
1102                    state_up.update(cx, |s, cx| {
1103                        s.handle_mouse_up();
1104                        cx.notify();
1105                    });
1106                },
1107            )
1108            .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
1109                state_scroll.update(cx, |s, cx| {
1110                    let line_h = px(s.row_height);
1111                    let delta = event.delta.pixel_delta(line_h);
1112                    let scroll = s.scroll_handle.offset();
1113                    let (mx, my) = s.max_scroll();
1114                    let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
1115                    let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
1116                    s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
1117                    if s.drag_start.is_some() {
1118                        s.handle_scroll_drag();
1119                    }
1120                    cx.notify();
1121                });
1122            })
1123            .on_key_down(move |event: &KeyDownEvent, _window, cx| {
1124                let ks = &event.keystroke;
1125                if ks.modifiers.platform && ks.key == "q" {
1126                    cx.quit();
1127                    return;
1128                }
1129                state_key.update(cx, |s, cx| {
1130                    let kb = &s.config.key_bindings;
1131                    if kb.select_all.matches(ks) {
1132                        s.select_all();
1133                    } else if kb.copy.matches(ks) {
1134                        s.copy_selection(false, cx);
1135                    } else if kb.copy_with_headers.matches(ks) {
1136                        s.copy_selection(true, cx);
1137                    } else if kb.page_up.matches(ks) {
1138                        s.page_up();
1139                    } else if kb.page_down.matches(ks) {
1140                        s.page_down();
1141                    } else {
1142                        s.handle_key(ks);
1143                    }
1144                    cx.notify();
1145                });
1146            })
1147    }
1148}
1149
1150/// Build the context-menu overlay as a `deferred` + `anchored` element so it
1151/// paints — and receives mouse events — on top of everything, including
1152/// regions outside the grid widget's own layout bounds. Returns `None` when no
1153/// menu is open.
1154///
1155/// Positioning reuses [`menu::ContextMenu::resolved_position`] (window-viewport
1156/// aware: flips up when there's no room below, shifts left at the right edge),
1157/// then converts to absolute window coordinates for `anchored().position(..)`.
1158/// Each selectable row carries its own `on_mouse_down` (dispatch) and
1159/// `on_mouse_move` (hover highlight) handlers; a full-screen backdrop behind
1160/// the menu dismisses it on any outside click.
1161fn render_context_menu_overlay(
1162    state: &Entity<GridState>,
1163    cx: &mut Context<SqllyDataTable>,
1164) -> Option<impl IntoElement> {
1165    let s = state.read(cx);
1166    let menu = s.context_menu.clone()?;
1167    let theme = s.theme.clone();
1168    let cw = s.char_width;
1169    let grid_ox = f32::from(s.bounds.origin.x);
1170    let grid_oy = f32::from(s.bounds.origin.y);
1171    let viewport = s.window_viewport;
1172    let vw = f32::from(viewport.width);
1173    let vh = f32::from(viewport.height);
1174
1175    let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
1176    let abs_x = grid_ox + f32::from(resolved.x);
1177    let abs_y = grid_oy + f32::from(resolved.y);
1178    let menu_w = menu.width_for(cw);
1179
1180    // Build one row per item. `selectable_idx` counts only Action/Custom items
1181    // so it matches the `hovered` index convention used elsewhere.
1182    let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
1183    let mut selectable_idx = 0usize;
1184    for item in &menu.items {
1185        match item {
1186            MenuItem::Separator => {
1187                rows.push(
1188                    div()
1189                        .h(px(menu::MENU_ITEM_HEIGHT))
1190                        .flex()
1191                        .items_center()
1192                        .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
1193                        .into_any_element(),
1194                );
1195            }
1196            MenuItem::Action(_) | MenuItem::Custom { .. } => {
1197                let this_idx = selectable_idx;
1198                selectable_idx += 1;
1199                let label = item.label().unwrap_or("").to_owned();
1200                let hovered = menu.hovered == Some(this_idx);
1201
1202                // Dispatch: set the pending action and close the menu. The
1203                // pending fields are drained at the top of `render` (they need
1204                // App access for clipboard).
1205                let action = match item {
1206                    MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
1207                    MenuItem::Custom { id, .. } => {
1208                        MenuDispatch::Custom(id.clone(), menu.request.clone())
1209                    }
1210                    MenuItem::Separator => unreachable!(),
1211                };
1212
1213                let state_click = state.clone();
1214                let state_hover = state.clone();
1215                let mut row = div()
1216                    .h(px(menu::MENU_ITEM_HEIGHT))
1217                    .px(px(menu::MENU_PADDING_X))
1218                    .flex()
1219                    .items_center()
1220                    .text_color(theme.menu_fg)
1221                    .text_size(px(menu::MENU_FONT_SIZE))
1222                    .child(label)
1223                    .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
1224                        state_hover.update(cx, |s, cx| {
1225                            if let Some(m) = s.context_menu.as_mut() {
1226                                if m.hovered != Some(this_idx) {
1227                                    m.hovered = Some(this_idx);
1228                                    cx.notify();
1229                                }
1230                            }
1231                        });
1232                    })
1233                    .on_mouse_down(
1234                        MouseButton::Left,
1235                        move |_e: &MouseDownEvent, _window, cx| {
1236                            state_click.update(cx, |s, cx| {
1237                                match &action {
1238                                    MenuDispatch::Builtin(a, col) => {
1239                                        s.pending_action = Some((*a, *col));
1240                                    }
1241                                    MenuDispatch::Custom(id, request) => {
1242                                        if let Some(request) = request {
1243                                            s.pending_custom_context_menu_action =
1244                                                Some(PendingCustomContextMenuAction {
1245                                                    id: id.clone(),
1246                                                    request: request.clone(),
1247                                                });
1248                                        }
1249                                    }
1250                                }
1251                                s.context_menu = None;
1252                                cx.notify();
1253                            });
1254                        },
1255                    );
1256                if hovered {
1257                    row = row.bg(theme.menu_hover_bg);
1258                }
1259                rows.push(row.into_any_element());
1260            }
1261        }
1262    }
1263
1264    let menu_body = div()
1265        .flex()
1266        .flex_col()
1267        .w(px(menu_w))
1268        .py(px(menu::MENU_INNER_PAD))
1269        .bg(theme.menu_bg)
1270        .border_1()
1271        .border_color(theme.grid_line)
1272        .children(rows);
1273
1274    // Full-window transparent backdrop: catches clicks outside the menu to
1275    // dismiss it. Placed behind the menu within the same anchored overlay.
1276    let state_backdrop = state.clone();
1277    let overlay = deferred(anchored().position(point(px(abs_x), px(abs_y))).child(
1278        div().occlude().child(menu_body).on_mouse_down_out(
1279            move |_e: &MouseDownEvent, _window, cx| {
1280                state_backdrop.update(cx, |s, cx| {
1281                    if s.context_menu.is_some() {
1282                        s.context_menu = None;
1283                        s.filter_panel = None;
1284                        cx.notify();
1285                    }
1286                });
1287            },
1288        ),
1289    ))
1290    .with_priority(CONTEXT_MENU_PRIORITY);
1291
1292    Some(overlay)
1293}
1294
1295/// Fixed width of the filter popover, in pixels.
1296const FILTER_PANEL_WIDTH: f32 = 300.0;
1297/// Max number of distinct value rows rendered at once (search narrows the set).
1298const FILTER_PANEL_MAX_ROWS: usize = 200;
1299
1300/// Build the Numbers-style per-column filter popover as a `deferred` +
1301/// `anchored` overlay, using the exact mechanism as
1302/// [`render_context_menu_overlay`] so it paints and receives events outside the
1303/// grid's own layout bounds. Returns `None` when no panel is open.
1304#[allow(clippy::too_many_lines)]
1305fn render_filter_panel_overlay(
1306    state: &Entity<GridState>,
1307    cx: &mut Context<SqllyDataTable>,
1308) -> Option<impl IntoElement> {
1309    let s = state.read(cx);
1310    let panel = s.filter_panel.clone()?;
1311    let theme = s.theme.clone();
1312    let col = panel.col;
1313    let current_sort = s.sort;
1314    let filter_active = s.filters.get(col).is_some_and(|f| f.is_active());
1315    let grid_ox = f32::from(s.bounds.origin.x);
1316    let grid_oy = f32::from(s.bounds.origin.y);
1317
1318    // Anchor (grid-relative) -> absolute window coords. The default
1319    // `SwitchAnchor` fit mode on `anchored()` handles viewport-edge flipping
1320    // automatically using the actual rendered height, so we don't need a
1321    // manual estimate or flip calculation here.
1322    let abs_x = grid_ox + f32::from(panel.anchor.x);
1323    let abs_y = grid_oy + f32::from(panel.anchor.y);
1324
1325    // Palette (all `Hsla` are `Copy`, so they move freely into closures).
1326    let c_bg = theme.menu_bg;
1327    let c_line = theme.grid_line;
1328    let c_fg = theme.menu_fg;
1329    let c_accent = theme.sort_indicator;
1330    let c_hover = theme.menu_hover_bg;
1331    let c_muted = theme.muted_text;
1332
1333    let checkbox = move |checked: bool| {
1334        let mut b = div()
1335            .w(px(14.0))
1336            .h(px(14.0))
1337            .border_1()
1338            .border_color(c_line)
1339            .bg(c_bg)
1340            .flex()
1341            .items_center()
1342            .justify_center();
1343        if checked {
1344            b = b.child(div().w(px(8.0)).h(px(8.0)).bg(c_accent));
1345        }
1346        b
1347    };
1348
1349    // --- Sort row -----------------------------------------------------------
1350    let (asc_active, desc_active) = match current_sort {
1351        Some((c, SortDirection::Ascending)) if c == col => (true, false),
1352        Some((c, SortDirection::Descending)) if c == col => (false, true),
1353        _ => (false, false),
1354    };
1355    let st_asc = state.clone();
1356    let st_desc = state.clone();
1357    let sort_row = div()
1358        .flex()
1359        .gap(px(6.0))
1360        .child(
1361            div()
1362                .flex_1()
1363                .h(px(26.0))
1364                .flex()
1365                .items_center()
1366                .justify_center()
1367                .border_1()
1368                .border_color(c_line)
1369                .bg(if asc_active { c_accent } else { c_hover })
1370                .cursor_pointer()
1371                .child("Ascending")
1372                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1373                    st_asc.update(cx, |s, cx| {
1374                        s.set_panel_sort(SortDirection::Ascending);
1375                        cx.notify();
1376                    });
1377                }),
1378        )
1379        .child(
1380            div()
1381                .flex_1()
1382                .h(px(26.0))
1383                .flex()
1384                .items_center()
1385                .justify_center()
1386                .border_1()
1387                .border_color(c_line)
1388                .bg(if desc_active { c_accent } else { c_hover })
1389                .cursor_pointer()
1390                .child("Descending")
1391                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1392                    st_desc.update(cx, |s, cx| {
1393                        s.set_panel_sort(SortDirection::Descending);
1394                        cx.notify();
1395                    });
1396                }),
1397        );
1398
1399    // --- Operator dropdown --------------------------------------------------
1400    let st_op_toggle = state.clone();
1401    let op_button = div()
1402        .h(px(26.0))
1403        .px(px(8.0))
1404        .flex()
1405        .items_center()
1406        .border_1()
1407        .border_color(c_line)
1408        .bg(c_bg)
1409        .cursor_pointer()
1410        .child(panel.current_op_label())
1411        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1412            st_op_toggle.update(cx, |s, cx| {
1413                s.toggle_filter_op_menu();
1414                cx.notify();
1415            });
1416        });
1417
1418    let op_menu = panel.op_menu_open.then(|| {
1419        let mut items: Vec<gpui::AnyElement> = Vec::new();
1420        for (i, label) in panel.op_labels().iter().enumerate() {
1421            let selected = i == panel.op_index;
1422            let st_pick = state.clone();
1423            items.push(
1424                div()
1425                    .h(px(24.0))
1426                    .px(px(8.0))
1427                    .flex()
1428                    .items_center()
1429                    .bg(if selected { c_accent } else { c_bg })
1430                    .cursor_pointer()
1431                    .child(*label)
1432                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1433                        st_pick.update(cx, |s, cx| {
1434                            s.set_filter_operator(i);
1435                            cx.notify();
1436                        });
1437                    })
1438                    .into_any_element(),
1439            );
1440        }
1441        div()
1442            .flex()
1443            .flex_col()
1444            .border_1()
1445            .border_color(c_line)
1446            .bg(c_bg)
1447            .children(items)
1448    });
1449
1450    // --- Operand field(s) ---------------------------------------------------
1451    let operand_field = |value: &str, focused: bool, placeholder: &str, input: FilterInput| {
1452        let st_focus = state.clone();
1453        let (text, is_placeholder) = if value.is_empty() {
1454            (placeholder.to_owned(), true)
1455        } else {
1456            (value.to_owned(), false)
1457        };
1458        div()
1459            .h(px(26.0))
1460            .px(px(6.0))
1461            .flex()
1462            .items_center()
1463            .gap(px(2.0))
1464            .border_1()
1465            .border_color(if focused { c_accent } else { c_line })
1466            .bg(c_bg)
1467            .cursor_pointer()
1468            .child(
1469                div()
1470                    .text_color(if is_placeholder { c_muted } else { c_fg })
1471                    .child(text),
1472            )
1473            .children(focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1474            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1475                st_focus.update(cx, |s, cx| {
1476                    s.set_filter_focus(input);
1477                    cx.notify();
1478                });
1479            })
1480    };
1481
1482    let operand_placeholder = if panel.kind == crate::data::ColumnKind::Date {
1483        "YYYY-MM-DD"
1484    } else if crate::filter::uses_number_ops(panel.kind) {
1485        "value"
1486    } else if panel.op_index == 7 {
1487        // Text "matches" operator.
1488        "regex"
1489    } else {
1490        "value"
1491    };
1492    let operands = panel.needs_operand().then(|| {
1493        let mut row = div().flex().flex_col().gap(px(4.0)).child(operand_field(
1494            &panel.operand_a.value,
1495            panel.focus == FilterInput::OperandA,
1496            operand_placeholder,
1497            FilterInput::OperandA,
1498        ));
1499        if panel.needs_second_operand() {
1500            row = row
1501                .child(div().text_color(c_muted).text_size(px(11.0)).child("and"))
1502                .child(operand_field(
1503                    &panel.operand_b.value,
1504                    panel.focus == FilterInput::OperandB,
1505                    operand_placeholder,
1506                    FilterInput::OperandB,
1507                ));
1508        }
1509        row
1510    });
1511
1512    // --- Search box ---------------------------------------------------------
1513    let st_search = state.clone();
1514    let search_focused = panel.focus == FilterInput::Search;
1515    let (search_text, search_is_ph) = if panel.search.value.is_empty() {
1516        ("Search".to_owned(), true)
1517    } else {
1518        (panel.search.value.clone(), false)
1519    };
1520    let search_box = div()
1521        .h(px(26.0))
1522        .px(px(6.0))
1523        .flex()
1524        .items_center()
1525        .gap(px(2.0))
1526        .border_1()
1527        .border_color(if search_focused { c_accent } else { c_line })
1528        .bg(c_bg)
1529        .cursor_pointer()
1530        .child(
1531            div()
1532                .text_color(if search_is_ph { c_muted } else { c_fg })
1533                .child(search_text),
1534        )
1535        .children(search_focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1536        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1537            st_search.update(cx, |s, cx| {
1538                s.set_filter_focus(FilterInput::Search);
1539                cx.notify();
1540            });
1541        });
1542
1543    // --- (Select All) + value checklist ------------------------------------
1544    let st_all = state.clone();
1545    let select_all_row = div()
1546        .h(px(24.0))
1547        .flex()
1548        .items_center()
1549        .gap(px(6.0))
1550        .cursor_pointer()
1551        .child(checkbox(panel.all_checked()))
1552        .child("(Select All)")
1553        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1554            st_all.update(cx, |s, cx| {
1555                s.toggle_filter_select_all();
1556                cx.notify();
1557            });
1558        });
1559
1560    let visible = panel.visible_indices();
1561    let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
1562    for &idx in visible.iter().take(FILTER_PANEL_MAX_ROWS) {
1563        let row = &panel.distinct[idx];
1564        let st_val = state.clone();
1565        value_rows.push(
1566            div()
1567                .h(px(22.0))
1568                .flex()
1569                .items_center()
1570                .gap(px(6.0))
1571                .cursor_pointer()
1572                .child(checkbox(row.checked))
1573                .child(div().text_color(c_fg).child(row.label.clone()))
1574                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1575                    st_val.update(cx, |s, cx| {
1576                        s.toggle_filter_value(idx);
1577                        cx.notify();
1578                    });
1579                })
1580                .into_any_element(),
1581        );
1582    }
1583    let truncated = visible.len() > FILTER_PANEL_MAX_ROWS;
1584    let value_list = div()
1585        .id("filter-value-list")
1586        .flex()
1587        .flex_col()
1588        .max_h(px(180.0))
1589        .overflow_y_scroll()
1590        .children(value_rows)
1591        .children(truncated.then(|| {
1592            div()
1593                .text_color(c_muted)
1594                .text_size(px(11.0))
1595                .child("Refine search to see more…")
1596        }));
1597
1598    // --- Clear (left, disabled when no active filter) + Close (right) -----
1599    let st_clear = state.clone();
1600    let st_close = state.clone();
1601    let clear_bg = if filter_active { c_hover } else { c_bg };
1602    let clear_fg = if filter_active { c_fg } else { c_muted };
1603    let clear_border = if filter_active { c_line } else { c_muted };
1604    let buttons_row = div()
1605        .flex()
1606        .gap(px(6.0))
1607        .child(
1608            div()
1609                .flex_1()
1610                .h(px(28.0))
1611                .flex()
1612                .items_center()
1613                .justify_center()
1614                .border_1()
1615                .border_color(clear_border)
1616                .bg(clear_bg)
1617                .text_color(clear_fg)
1618                .cursor_pointer()
1619                .child("Clear Filter")
1620                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1621                    if !filter_active {
1622                        return;
1623                    }
1624                    st_clear.update(cx, |s, cx| {
1625                        s.clear_filter_panel();
1626                        cx.notify();
1627                    });
1628                }),
1629        )
1630        .child(
1631            div()
1632                .flex_1()
1633                .h(px(28.0))
1634                .flex()
1635                .items_center()
1636                .justify_center()
1637                .border_1()
1638                .border_color(c_line)
1639                .bg(c_hover)
1640                .cursor_pointer()
1641                .child("Close")
1642                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1643                    st_close.update(cx, |s, cx| {
1644                        s.filter_panel = None;
1645                        cx.notify();
1646                    });
1647                }),
1648        );
1649
1650    let panel_body = div()
1651        .flex()
1652        .flex_col()
1653        .w(px(FILTER_PANEL_WIDTH))
1654        .p(px(10.0))
1655        .gap(px(8.0))
1656        .bg(c_bg)
1657        .border_1()
1658        .border_color(c_line)
1659        .text_color(c_fg)
1660        .text_size(px(13.0))
1661        .child(div().text_color(c_muted).text_size(px(11.0)).child("Sort"))
1662        .child(sort_row)
1663        .child(
1664            div()
1665                .text_color(c_muted)
1666                .text_size(px(11.0))
1667                .child("Filter"),
1668        )
1669        .child(op_button)
1670        .children(op_menu)
1671        .children(operands)
1672        .child(search_box)
1673        .child(select_all_row)
1674        .child(value_list)
1675        .child(buttons_row);
1676
1677    let st_backdrop = state.clone();
1678    let overlay = deferred(
1679        anchored()
1680            .anchor(Corner::BottomLeft)
1681            .position(point(px(abs_x), px(abs_y)))
1682            .child(div().occlude().child(panel_body).on_mouse_down_out(
1683                move |_e: &MouseDownEvent, _window, cx| {
1684                    st_backdrop.update(cx, |s, cx| {
1685                        if s.filter_panel.is_some() {
1686                            s.filter_panel = None;
1687                            cx.notify();
1688                        }
1689                    });
1690                },
1691            )),
1692    )
1693    .with_priority(CONTEXT_MENU_PRIORITY);
1694
1695    Some(overlay)
1696}
1697
1698/// Renders the loading overlay while a background task runs. Returns `None`
1699/// when the grid is not busy. Painted as an absolute, input-occluding scrim
1700/// over the whole grid area with a centered card showing the task label and a
1701/// progress bar (determinate when progress is known, otherwise an animated
1702/// indeterminate bar).
1703fn render_busy_overlay(
1704    state: &Entity<GridState>,
1705    cx: &mut Context<SqllyDataTable>,
1706) -> Option<impl IntoElement> {
1707    let s = state.read(cx);
1708    let busy = s.busy.clone()?;
1709    let theme = s.theme.clone();
1710    let track = theme.grid_line;
1711    let accent = theme.sort_indicator;
1712
1713    let bar: gpui::AnyElement = if let Some(p) = busy.progress {
1714        let p = p.clamp(0.0, 1.0);
1715        div()
1716            .h_full()
1717            .w(relative(p))
1718            .rounded(px(3.0))
1719            .bg(accent)
1720            .into_any_element()
1721    } else {
1722        div()
1723            .h_full()
1724            .w(relative(0.3))
1725            .rounded(px(3.0))
1726            .bg(accent)
1727            .with_animation(
1728                "busy-indeterminate",
1729                Animation::new(std::time::Duration::from_millis(900))
1730                    .repeat()
1731                    .with_easing(pulsating_between(0.15, 0.85)),
1732                |el, delta| el.w(relative(delta)),
1733            )
1734            .into_any_element()
1735    };
1736
1737    let card = div()
1738        .flex()
1739        .flex_col()
1740        .gap(px(10.0))
1741        .p(px(16.0))
1742        .min_w(px(220.0))
1743        .rounded(px(8.0))
1744        .bg(theme.menu_bg)
1745        .border_1()
1746        .border_color(theme.grid_line)
1747        .child(
1748            div()
1749                .text_color(theme.menu_fg)
1750                .text_size(px(14.0))
1751                .child(busy.label.clone()),
1752        )
1753        .child(
1754            div()
1755                .w_full()
1756                .h(px(6.0))
1757                .rounded(px(3.0))
1758                .bg(track)
1759                .child(bar),
1760        );
1761
1762    let overlay = div()
1763        .absolute()
1764        .top_0()
1765        .left_0()
1766        .size_full()
1767        .occlude()
1768        .flex()
1769        .items_center()
1770        .justify_center()
1771        .bg(theme.overlay_scrim)
1772        .child(card);
1773
1774    Some(overlay)
1775}
1776
1777/// What a menu row dispatches when clicked. Captured per-row so the click
1778/// handler owns its data without borrowing the menu snapshot.
1779enum MenuDispatch {
1780    Builtin(menu::MenuAction, usize),
1781    Custom(
1782        String,
1783        Option<crate::grid::context_menu::ContextMenuRequest>,
1784    ),
1785}