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, animations) = {
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            s.config.animations,
363        )
364    };
365    let focus = cx.focus_handle();
366    let state = cx.new(|_| {
367        let mut ps = PivotState::new(columns, rows, formats, config, key_bindings, focus);
368        ps.theme = theme;
369        ps.animations = animations;
370        ps.context_menu_provider = menu_provider;
371        ps.save_config_handler = save_config_handler;
372        ps.set_row_height(row_height);
373        ps.set_column_width(column_width);
374        ps
375    });
376    let grid = cx.new(|_| PivotGrid::new(state.clone()));
377    let sidebar = cx.new(|_| PivotSidebar::new(state.clone()));
378    PivotParts {
379        state,
380        grid,
381        sidebar,
382    }
383}
384
385/// Builder for `SqllyDataTable`.
386pub struct SqllyDataTableBuilder {
387    data: GridData,
388    config: GridConfig,
389    context_menu_provider: Option<ContextMenuProviderHandle>,
390    theme: Option<GridTheme>,
391    theme_family: Option<GridThemePair>,
392    debug_bar: bool,
393    grouped_column: Option<usize>,
394    pivot: Option<PivotConfig>,
395    pivot_context_menu_provider: Option<PivotContextMenuProviderHandle>,
396    pivot_save_config_handler: Option<PivotSaveConfigHandler>,
397    pivot_sidebar_position: PivotSidebarPosition,
398    pivot_sidebar_collapsed: bool,
399    pivot_sidebar_width: f32,
400    pivot_row_height: f32,
401    pivot_column_width: f32,
402}
403
404impl SqllyDataTableBuilder {
405    /// Override the entire [`GridConfig`].
406    #[must_use]
407    pub fn config(mut self, config: GridConfig) -> Self {
408        self.config = config;
409        self
410    }
411
412    /// Override the [`GridTheme`]. Supplying an explicit theme opts out of the
413    /// automatic OS light/dark following; the grid uses exactly this theme.
414    #[must_use]
415    pub fn theme(mut self, theme: GridTheme) -> Self {
416        self.theme = Some(theme);
417        self
418    }
419
420    /// Choose the light/dark theme family used while following the OS window
421    /// appearance (default: [`GridThemePair::neutral`]). Unlike
422    /// [`SqllyDataTableBuilder::theme`], this keeps automatic light/dark
423    /// following — the pair's matching variant is applied whenever the
424    /// system appearance changes. Ignored when an explicit theme override is
425    /// also supplied.
426    #[must_use]
427    pub fn theme_family(mut self, family: GridThemePair) -> Self {
428        self.theme_family = Some(family);
429        self
430    }
431
432    /// Register a custom right-click menu provider. When registered, the
433    /// provider fully controls the right-click menu for all targets (cells,
434    /// row headers, column headers). The built-in column-header menu is
435    /// suppressed; use
436    /// [`crate::grid::context_menu::ContextMenuItem::standard_column_header_items`]
437    /// to compose built-in actions.
438    #[must_use]
439    pub fn context_menu_provider(mut self, provider: impl ContextMenuProvider + 'static) -> Self {
440        self.context_menu_provider = Some(ContextMenuProviderHandle::new(provider));
441        self
442    }
443
444    /// Enable or disable the debug status bar. When enabled, a bar is painted
445    /// at the bottom of the grid showing click position, scroll offset, and
446    /// hovered cell coordinates. Off by default.
447    #[must_use]
448    pub fn debug_bar(mut self, enabled: bool) -> Self {
449        self.debug_bar = enabled;
450        self
451    }
452
453    /// Group the initial flat-grid rows into expandable sections using the
454    /// formatted values in `column`. Invalid indices are ignored at build time.
455    #[must_use]
456    pub fn group_by_column(mut self, column: usize) -> Self {
457        self.grouped_column = Some(column);
458        self
459    }
460
461    /// Enable the pivot tab, preconfigured with `config`. The widget renders
462    /// a "Grid" / "Pivot" tab bar; the pivot tab shows resizable accordion
463    /// controls next to the pivot grid. Pass
464    /// [`PivotConfig::default()`] for an unconfigured pivot the user builds
465    /// interactively.
466    #[must_use]
467    pub fn pivot(mut self, config: PivotConfig) -> Self {
468        self.pivot = Some(config);
469        self
470    }
471
472    /// Place the pivot control panel on the left or right side of the grid.
473    #[must_use]
474    pub fn pivot_sidebar_position(mut self, position: PivotSidebarPosition) -> Self {
475        self.pivot_sidebar_position = position;
476        self
477    }
478
479    /// Build the pivot control panel initially collapsed.
480    #[must_use]
481    pub fn pivot_sidebar_collapsed(mut self, collapsed: bool) -> Self {
482        self.pivot_sidebar_collapsed = collapsed;
483        self
484    }
485
486    /// Set the initial width of the expanded pivot control panel.
487    #[must_use]
488    pub fn pivot_sidebar_width(mut self, width: f32) -> Self {
489        self.pivot_sidebar_width = width.clamp(MIN_PIVOT_SIDEBAR_WIDTH, MAX_PIVOT_SIDEBAR_WIDTH);
490        self
491    }
492
493    /// Set the initial height of every row in the pivot view.
494    #[must_use]
495    pub fn pivot_row_height(mut self, height: f32) -> Self {
496        if height.is_finite() {
497            self.pivot_row_height = height.max(crate::pivot::state::MIN_PIVOT_ROW_HEIGHT);
498        }
499        self
500    }
501
502    /// Set the initial width of every value column in the pivot view.
503    #[must_use]
504    pub fn pivot_column_width(mut self, width: f32) -> Self {
505        if width.is_finite() {
506            self.pivot_column_width = width.max(crate::pivot::state::MIN_PIVOT_COLUMN_WIDTH);
507        }
508        self
509    }
510
511    /// Register a custom right-click menu provider for the pivot view. When
512    /// registered, the provider fully controls the pivot's context menu (the
513    /// built-in `pivot.*` action ids remain handled by the pivot; compose
514    /// them via [`crate::pivot::PivotMenuItem::standard_items`]). Only takes
515    /// effect together with [`SqllyDataTableBuilder::pivot`].
516    #[must_use]
517    pub fn pivot_context_menu_provider(
518        mut self,
519        provider: impl PivotContextMenuProvider + 'static,
520    ) -> Self {
521        self.pivot_context_menu_provider = Some(PivotContextMenuProviderHandle::new(provider));
522        self
523    }
524
525    /// Register a save-configuration action for the pivot view. While
526    /// registered, the pivot controls sidebar shows a save button next to
527    /// the Layout section that invokes `handler` with the live
528    /// [`PivotConfig`] (persist it and pass it back to
529    /// [`SqllyDataTableBuilder::pivot`] on the next launch). Without a
530    /// handler the button is not rendered. Only takes effect together with
531    /// [`SqllyDataTableBuilder::pivot`].
532    #[must_use]
533    pub fn pivot_save_config(mut self, handler: impl Fn(&PivotConfig, &mut App) + 'static) -> Self {
534        self.pivot_save_config_handler = Some(std::rc::Rc::new(handler));
535        self
536    }
537
538    /// Build the widget inside the supplied [`gpui::App`].
539    pub fn build(self, cx: &mut App) -> SqllyDataTable {
540        let focus = cx.focus_handle();
541        let provider = self.context_menu_provider;
542        let theme_override = self.theme;
543        let theme_family = self.theme_family;
544        let debug_bar = self.debug_bar;
545        let grouped_column = self.grouped_column;
546        let pivot_config = self.pivot;
547        let pivot_sidebar_position = self.pivot_sidebar_position;
548        let pivot_sidebar_collapsed = self.pivot_sidebar_collapsed;
549        let pivot_sidebar_width = self.pivot_sidebar_width;
550        let pivot_row_height = self.pivot_row_height;
551        let pivot_column_width = self.pivot_column_width;
552        let follow_system_appearance = theme_override.is_none();
553        let state = cx.new(|cx| {
554            let mut s = GridState::new(self.data, self.config, focus.clone());
555            s.context_menu_provider = provider;
556            s.debug_bar_enabled = debug_bar;
557            s.set_grouped_column(grouped_column);
558            s.self_weak = Some(cx.weak_entity());
559            if let Some(family) = theme_family {
560                s.theme = family.light.clone();
561                s.theme_family = family;
562            }
563            if let Some(theme) = theme_override {
564                s.theme = theme;
565            }
566            s
567        });
568        let pivot_menu_provider = self.pivot_context_menu_provider;
569        let pivot_save_config_handler = self.pivot_save_config_handler;
570        let pivot = pivot_config.map(|cfg| {
571            build_pivot_parts(
572                &state,
573                cfg,
574                pivot_menu_provider,
575                pivot_save_config_handler,
576                pivot_row_height,
577                pivot_column_width,
578                cx,
579            )
580        });
581        SqllyDataTable {
582            state,
583            pivot,
584            active_tab: GridTab::Grid,
585            pivot_locked: false,
586            pivot_status: None,
587            pivot_sidebar_position,
588            pivot_sidebar_collapsed,
589            pivot_sidebar_width,
590            pivot_sidebar_drag: None,
591            follow_system_appearance,
592            appearance_subscription: None,
593        }
594    }
595}
596
597impl Focusable for SqllyDataTable {
598    fn focus_handle(&self, cx: &App) -> FocusHandle {
599        self.state.read(cx).focus_handle.clone()
600    }
601}
602
603impl Render for SqllyDataTable {
604    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
605        // Follow the OS light/dark appearance. An observer swaps the theme
606        // whenever the system appearance changes, but we also reconcile the
607        // resolved theme against `window.appearance()` on every render: the
608        // appearance read on the very first frame can be stale (reported
609        // before the window has settled onto the OS appearance), which would
610        // otherwise strand the grid in the light variant on a dark-mode OS
611        // until the next appearance *change* event. Re-resolving each render
612        // self-heals that — it clones + compares one theme and only writes
613        // (and notifies) when the variant actually differs, so a steady state
614        // is a cheap no-op. Skipped entirely when the caller supplied an
615        // explicit theme override.
616        if self.follow_system_appearance {
617            let appearance = window.appearance();
618            let resolved = self.state.read(cx).theme_family.for_appearance(appearance);
619            if self.state.read(cx).theme != resolved {
620                self.state.update(cx, |s, cx| {
621                    s.theme = resolved;
622                    cx.notify();
623                });
624            }
625            if self.appearance_subscription.is_none() {
626                let state_appearance = self.state.clone();
627                self.appearance_subscription =
628                    Some(window.observe_window_appearance(move |window, cx| {
629                        let appearance = window.appearance();
630                        state_appearance.update(cx, |s, cx| {
631                            let resolved = s.theme_family.for_appearance(appearance);
632                            if s.theme != resolved {
633                                s.theme = resolved;
634                                cx.notify();
635                            }
636                        });
637                    }));
638            }
639        }
640
641        // Keep the pivot's theme in lockstep with the grid theme (which may
642        // have just changed via the appearance observer), and its sidebar
643        // width in sync with the resizable panel (the sidebar needs it to
644        // decide when chip labels are truncated).
645        if let Some(parts) = &self.pivot {
646            let grid_theme = self.state.read(cx).theme.clone();
647            let sidebar_width = self.pivot_sidebar_width;
648            parts.state.update(cx, |s, cx| {
649                let mut dirty = false;
650                if s.theme != grid_theme {
651                    s.theme = grid_theme;
652                    dirty = true;
653                }
654                if (s.sidebar_width - sidebar_width).abs() > 0.5 {
655                    s.sidebar_width = sidebar_width;
656                    dirty = true;
657                }
658                if dirty {
659                    cx.notify();
660                }
661            });
662        }
663
664        // Drill-through: a double-click on a pivot cell (or the built-in
665        // "Show source rows" menu action) queued per-column value filters.
666        // Apply them to the flat grid and switch to the Grid tab so the user
667        // lands on exactly the rows that drive the clicked cell.
668        if let Some(parts) = &self.pivot {
669            let drill = parts.state.update(cx, |s, _cx| s.take_pending_drill_down());
670            if let Some(filter_sets) = drill {
671                self.state.update(cx, |g, cx| {
672                    // Filters are unsupported in windowed-row mode; the
673                    // drill-through is skipped rather than presenting a
674                    // filter that silently covers only resident rows.
675                    if g.window.is_none() {
676                        for filter in &mut g.filters {
677                            *filter = ColumnFilter::default();
678                        }
679                        for (field, values) in filter_sets {
680                            if let Some(slot) = g.filters.get_mut(field) {
681                                *slot = ColumnFilter {
682                                    predicate: FilterPredicate::None,
683                                    values: Some(values),
684                                };
685                            }
686                        }
687                        g.recompute();
688                    }
689                    cx.notify();
690                });
691                self.active_tab = GridTab::Grid;
692                let focus = self.state.read(cx).focus_handle.clone();
693                window.focus(&focus);
694                cx.notify();
695            }
696        }
697
698        let grid_view = self.render_grid_view(cx);
699
700        let Some(parts) = &self.pivot else {
701            return div().size_full().child(grid_view);
702        };
703
704        let theme = self.state.read(cx).theme.clone();
705        let tab = |label: String,
706                   this_tab: GridTab,
707                   active: bool,
708                   locked: bool,
709                   cx: &mut Context<Self>| {
710            div()
711                .px(px(14.0))
712                .py(px(5.0))
713                .text_size(px(13.0))
714                .when(!locked, |tab| tab.cursor_pointer())
715                .when(locked, |tab| tab.opacity(0.55))
716                .bg(if active { theme.bg } else { theme.header_bg })
717                .text_color(if active {
718                    theme.header_fg
719                } else {
720                    theme.muted_text
721                })
722                .border_b_2()
723                .border_color(if active {
724                    theme.sort_indicator
725                } else {
726                    theme.header_bg
727                })
728                .child(label)
729                .when(!locked, |tab| {
730                    tab.on_mouse_down(
731                        MouseButton::Left,
732                        cx.listener(move |this, _e: &MouseDownEvent, window, cx| {
733                            if this.active_tab == this_tab {
734                                return;
735                            }
736                            this.set_active_tab(this_tab, cx);
737                            // Route keyboard input to the now-visible view.
738                            match this_tab {
739                                GridTab::Grid => {
740                                    let focus = this.state.read(cx).focus_handle.clone();
741                                    window.focus(&focus);
742                                }
743                                GridTab::Pivot => {
744                                    if let Some(p) = &this.pivot {
745                                        let focus = p.state.read(cx).focus_handle.clone();
746                                        window.focus(&focus);
747                                    }
748                                }
749                            }
750                            cx.notify();
751                        }),
752                    )
753                })
754        };
755
756        let pivot_label = self
757            .pivot_status
758            .as_deref()
759            .map_or_else(|| "Pivot".to_string(), |status| format!("Pivot  {status}"));
760
761        let tab_bar = div()
762            .flex()
763            .flex_row()
764            .flex_none()
765            .bg(theme.header_bg)
766            .border_b_1()
767            .border_color(theme.grid_line)
768            .child(tab(
769                "Grid".to_string(),
770                GridTab::Grid,
771                self.active_tab == GridTab::Grid,
772                false,
773                cx,
774            ))
775            .child(tab(
776                pivot_label,
777                GridTab::Pivot,
778                self.active_tab == GridTab::Pivot,
779                self.pivot_locked,
780                cx,
781            ));
782
783        let content: gpui::AnyElement = match self.active_tab {
784            GridTab::Grid => grid_view.into_any_element(),
785            GridTab::Pivot => {
786                let position = self.pivot_sidebar_position;
787                let collapsed = self.pivot_sidebar_collapsed;
788                let panel = div()
789                    .w(px(self.pivot_sidebar_width))
790                    .h_full()
791                    .flex_none()
792                    .child(parts.sidebar.clone());
793                let pivot_grid = div().flex_1().min_w_0().child(parts.grid.clone());
794                let direction = match position {
795                    PivotSidebarPosition::Left => CollapseDirection::Left,
796                    PivotSidebarPosition::Right => CollapseDirection::Right,
797                };
798                let divider_theme = PaneDividerTheme {
799                    background: theme.header_bg.into(),
800                    background_hover: theme.menu_hover_bg.into(),
801                    background_collapsed: theme.menu_bg.into(),
802                    foreground: theme.muted_text.into(),
803                    foreground_hover: theme.header_fg.into(),
804                    border: theme.grid_line.into(),
805                };
806                let table_toggle = cx.entity().clone();
807                let table_drag = cx.entity().clone();
808                let divider = PaneDivider::vertical("pivot-sidebar-divider", direction)
809                    .label("Pivot")
810                    .collapsed(collapsed)
811                    .theme(divider_theme)
812                    .on_toggle(move |collapsed, _window, cx| {
813                        table_toggle.update(cx, |table, cx| {
814                            table.set_pivot_sidebar_collapsed(collapsed);
815                            cx.notify();
816                        });
817                    })
818                    .on_drag_start(move |start_x, _window, cx| {
819                        table_drag.update(cx, |table, cx| {
820                            table.pivot_sidebar_drag = Some(PivotSidebarDrag {
821                                start_x,
822                                start_width: table.pivot_sidebar_width,
823                            });
824                            cx.notify();
825                        });
826                    });
827
828                let mut pivot_view = div().flex().flex_row().size_full();
829                pivot_view = match position {
830                    PivotSidebarPosition::Left => pivot_view
831                        .children((!collapsed).then_some(panel))
832                        .child(divider)
833                        .child(pivot_grid),
834                    PivotSidebarPosition::Right => pivot_view
835                        .child(pivot_grid)
836                        .child(divider)
837                        .children((!collapsed).then_some(panel)),
838                };
839
840                pivot_view.into_any_element()
841            }
842        };
843
844        let mut root = div()
845            .flex()
846            .flex_col()
847            .size_full()
848            .child(tab_bar)
849            .child(div().flex_1().min_h_0().child(content));
850
851        if let Some(drag) = self.pivot_sidebar_drag {
852            let position = self.pivot_sidebar_position;
853            let table_move = cx.entity().clone();
854            let table_up = cx.entity().clone();
855            let table_up_out = cx.entity().clone();
856            root = root
857                .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
858                    let current_x: f32 = event.position.x.into();
859                    let delta = match position {
860                        PivotSidebarPosition::Left => current_x - drag.start_x,
861                        PivotSidebarPosition::Right => drag.start_x - current_x,
862                    };
863                    table_move.update(cx, |table, cx| {
864                        table.set_pivot_sidebar_width(drag.start_width + delta);
865                        cx.notify();
866                    });
867                })
868                .on_mouse_up(MouseButton::Left, move |_event, _window, cx| {
869                    table_up.update(cx, |table, cx| {
870                        table.pivot_sidebar_drag = None;
871                        cx.notify();
872                    });
873                })
874                .on_mouse_up_out(MouseButton::Left, move |_event, _window, cx| {
875                    table_up_out.update(cx, |table, cx| {
876                        table.pivot_sidebar_drag = None;
877                        cx.notify();
878                    });
879                });
880        }
881
882        root
883    }
884}
885
886impl SqllyDataTable {
887    /// The flat-grid element tree (canvas, overlays, input handlers). Shared
888    /// by the tabless render path and the "Grid" tab.
889    fn render_grid_view(&mut self, cx: &mut Context<Self>) -> Div {
890        let state_canvas = self.state.clone();
891        let state_status = self.state.clone();
892        let state_mouse = self.state.clone();
893        let state_move = self.state.clone();
894        let state_up = self.state.clone();
895        let state_scroll = self.state.clone();
896        let state_key = self.state.clone();
897        let state_right = self.state.clone();
898        let bg = self.state.read(cx).theme.bg;
899        let focus_handle = self.state.read(cx).focus_handle.clone();
900        let focus_left = focus_handle.clone();
901        let focus_right = focus_handle.clone();
902        let debug_bar = self.state.read(cx).debug_bar_enabled;
903        let status_h = self.state.read(cx).status_bar_height;
904
905        // Process any pending menu action from a previous mouse-down on a
906        // menu item (needs App access for clipboard).
907        if let Some((action, col)) = self.state.read(cx).pending_action {
908            self.state.update(cx, |s, cx| {
909                s.execute_action(action, col, cx);
910                s.pending_action = None;
911            });
912        }
913
914        // Process any pending custom context-menu action.
915        if let Some(pending) = self
916            .state
917            .read(cx)
918            .pending_custom_context_menu_action
919            .clone()
920        {
921            self.state.update(cx, |s, cx| {
922                s.pending_custom_context_menu_action = None;
923                s.execute_custom_context_menu_action(pending, cx);
924            });
925        }
926
927        // Spawn an edge-scroll timer **only while a drag is in progress**, and
928        // **only one at a time**. Without the `edge_scroll_active` guard,
929        // `render` would spawn a fresh 16 ms loop on every frame/notify during
930        // a drag — each successful tick calls `cx.notify()`, which re-renders
931        // and spawned yet another task, stacking concurrent loops that each
932        // apply a scroll delta per tick and multiply the effective speed
933        // without bound. The task clears the flag when it exits.
934        if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
935            self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
936            let state_edge = self.state.clone();
937            cx.spawn(async move |_weak, cx| {
938                loop {
939                    gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
940                    let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
941                    if let Ok(true) = res {
942                        let _ = state_edge.update(cx, |_s, cx| cx.notify());
943                    }
944                    let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
945                    if !matches!(dragging_res, Ok(true)) {
946                        break;
947                    }
948                }
949                let _ =
950                    cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
951            })
952            .detach();
953        }
954
955        div()
956            .flex()
957            .flex_col()
958            .size_full()
959            .relative()
960            .track_focus(&focus_handle)
961            .bg(bg)
962            .child(
963                canvas(
964                    move |bounds, window, cx| -> PaintData {
965                        let viewport = window.viewport_size();
966                        state_canvas.update(cx, |s, cx| {
967                            let mut dirty = false;
968                            if s.bounds != bounds {
969                                s.bounds = bounds;
970                                s.clamp_scroll_to_bounds();
971                                dirty = true;
972                            }
973                            if s.window_viewport != viewport {
974                                s.window_viewport = viewport;
975                            }
976                            if dirty {
977                                cx.notify();
978                            }
979                        });
980                        let s = state_canvas.read(cx);
981                        let mut data = PaintData::from_state(s);
982                        data.focused = s.focus_handle.is_focused(window);
983                        data
984                    },
985                    move |bounds, data, window, cx| {
986                        paint_grid(&data, window, cx, bounds);
987                    },
988                )
989                .flex_1(),
990            )
991            .children(debug_bar.then(|| {
992                canvas(
993                    move |_bounds, _window, cx| -> StatusBarData {
994                        let s = state_status.read(cx);
995                        StatusBarData::from_state(s)
996                    },
997                    move |bounds, data, window, cx| {
998                        paint_status_bar(&data, window, cx, bounds);
999                    },
1000                )
1001                .h(px(status_h))
1002            }))
1003            .children(render_context_menu_overlay(&self.state, cx))
1004            .children(render_filter_panel_overlay(&self.state, cx))
1005            .children(render_busy_overlay(&self.state, cx))
1006            .on_mouse_down(
1007                MouseButton::Left,
1008                move |event: &MouseDownEvent, window, cx| {
1009                    window.focus(&focus_left);
1010                    state_mouse.update(cx, |s, cx| {
1011                        // Ignore grid input while a background task is running;
1012                        // the busy overlay is shown and occludes interaction.
1013                        if s.busy.is_some() {
1014                            return;
1015                        }
1016                        // Normalize the absolute window pointer into the grid's
1017                        // own frame. Menu hit-testing is handled by the deferred
1018                        // overlay's own item handlers, so a left-click that
1019                        // reaches the grid means the pointer was NOT on the menu;
1020                        // dismiss any open menu and proceed with grid selection.
1021                        let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
1022                        if s.context_menu.is_some() || s.filter_panel.is_some() {
1023                            s.context_menu = None;
1024                            s.filter_panel = None;
1025                        }
1026                        s.handle_mouse_down_with_modifiers(
1027                            rel,
1028                            event.modifiers.shift,
1029                            event.modifiers.platform,
1030                        );
1031                        cx.notify();
1032                    });
1033                },
1034            )
1035            .on_mouse_down(
1036                MouseButton::Right,
1037                move |event: &MouseDownEvent, window, cx| {
1038                    window.focus(&focus_right);
1039                    state_right.update(cx, |s, cx| {
1040                        if s.busy.is_some() {
1041                            return;
1042                        }
1043                        let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
1044                        let hit = s.hit_test(pos);
1045
1046                        // No provider — existing built-in behavior.
1047                        if s.context_menu_provider.is_none() {
1048                            match hit {
1049                                HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
1050                                    s.open_context_menu(col, pos);
1051                                }
1052                                _ => {
1053                                    s.context_menu = None;
1054                                    s.filter_panel = None;
1055                                }
1056                            }
1057                            cx.notify();
1058                            return;
1059                        }
1060
1061                        // Provider exists — build custom menu.
1062                        let Some(target) = s.context_menu_target_from_hit(hit) else {
1063                            s.context_menu = None;
1064                            s.filter_panel = None;
1065                            cx.notify();
1066                            return;
1067                        };
1068
1069                        let effective = s.effective_selection_for_context_target(&target);
1070                        if effective != s.selection {
1071                            s.selection = effective.clone();
1072                        }
1073
1074                        let request = s.build_context_menu_request(target, &effective);
1075                        let col = request.target.column_index().unwrap_or(0);
1076
1077                        let Some(provider) = s.context_menu_provider.clone() else {
1078                            return;
1079                        };
1080                        let public_items = provider.menu_items(&request);
1081                        let items = GridState::convert_context_menu_items(public_items);
1082
1083                        if items.is_empty() {
1084                            s.context_menu = None;
1085                        } else {
1086                            s.context_menu =
1087                                Some(menu::ContextMenu::custom(col, pos, items, request));
1088                        }
1089                        s.filter_panel = None;
1090                        cx.notify();
1091                    });
1092                },
1093            )
1094            .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
1095                state_move.update(cx, |s, cx| {
1096                    let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
1097                    s.handle_mouse_move(rel, event.pressed_button);
1098                    cx.notify();
1099                });
1100            })
1101            .on_mouse_up(
1102                MouseButton::Left,
1103                move |_event: &MouseUpEvent, _window, cx| {
1104                    state_up.update(cx, |s, cx| {
1105                        s.handle_mouse_up();
1106                        cx.notify();
1107                    });
1108                },
1109            )
1110            .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
1111                state_scroll.update(cx, |s, cx| {
1112                    let line_h = px(s.row_height);
1113                    let delta = event.delta.pixel_delta(line_h);
1114                    let scroll = s.scroll_handle.offset();
1115                    let (mx, my) = s.max_scroll();
1116                    let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
1117                    let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
1118                    s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
1119                    if s.drag_start.is_some() {
1120                        s.handle_scroll_drag();
1121                    }
1122                    cx.notify();
1123                });
1124            })
1125            .on_key_down(move |event: &KeyDownEvent, _window, cx| {
1126                let ks = &event.keystroke;
1127                if ks.modifiers.platform && ks.key == "q" {
1128                    cx.quit();
1129                    return;
1130                }
1131                state_key.update(cx, |s, cx| {
1132                    let kb = &s.config.key_bindings;
1133                    if kb.select_all.matches(ks) {
1134                        s.select_all();
1135                    } else if kb.copy.matches(ks) {
1136                        s.copy_selection(false, cx);
1137                    } else if kb.copy_with_headers.matches(ks) {
1138                        s.copy_selection(true, cx);
1139                    } else if kb.page_up.matches(ks) {
1140                        s.page_up();
1141                    } else if kb.page_down.matches(ks) {
1142                        s.page_down();
1143                    } else {
1144                        s.handle_key(ks);
1145                    }
1146                    cx.notify();
1147                });
1148            })
1149    }
1150}
1151
1152/// Build the context-menu overlay as a `deferred` + `anchored` element so it
1153/// paints — and receives mouse events — on top of everything, including
1154/// regions outside the grid widget's own layout bounds. Returns `None` when no
1155/// menu is open.
1156///
1157/// Positioning reuses [`menu::ContextMenu::resolved_position`] (window-viewport
1158/// aware: flips up when there's no room below, shifts left at the right edge),
1159/// then converts to absolute window coordinates for `anchored().position(..)`.
1160/// Each selectable row carries its own `on_mouse_down` (dispatch) and
1161/// `on_mouse_move` (hover highlight) handlers; a full-screen backdrop behind
1162/// the menu dismisses it on any outside click.
1163fn render_context_menu_overlay(
1164    state: &Entity<GridState>,
1165    cx: &mut Context<SqllyDataTable>,
1166) -> Option<impl IntoElement> {
1167    let s = state.read(cx);
1168    let menu = s.context_menu.clone()?;
1169    let theme = s.theme.clone();
1170    let animations = s.config.animations;
1171    let cw = s.char_width;
1172    let grid_ox = f32::from(s.bounds.origin.x);
1173    let grid_oy = f32::from(s.bounds.origin.y);
1174    let viewport = s.window_viewport;
1175    let vw = f32::from(viewport.width);
1176    let vh = f32::from(viewport.height);
1177
1178    let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
1179    let abs_x = grid_ox + f32::from(resolved.x);
1180    let abs_y = grid_oy + f32::from(resolved.y);
1181    let menu_w = menu.width_for(cw);
1182
1183    // Build one row per item. `selectable_idx` counts only Action/Custom items
1184    // so it matches the `hovered` index convention used elsewhere.
1185    let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
1186    let mut selectable_idx = 0usize;
1187    for item in &menu.items {
1188        match item {
1189            MenuItem::Separator => {
1190                rows.push(
1191                    div()
1192                        .h(px(menu::MENU_ITEM_HEIGHT))
1193                        .flex()
1194                        .items_center()
1195                        .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
1196                        .into_any_element(),
1197                );
1198            }
1199            MenuItem::Action(_) | MenuItem::Custom { .. } => {
1200                let this_idx = selectable_idx;
1201                selectable_idx += 1;
1202                let label = item.label().unwrap_or("").to_owned();
1203                let hovered = menu.hovered == Some(this_idx);
1204
1205                // Dispatch: set the pending action and close the menu. The
1206                // pending fields are drained at the top of `render` (they need
1207                // App access for clipboard).
1208                let action = match item {
1209                    MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
1210                    MenuItem::Custom { id, .. } => {
1211                        MenuDispatch::Custom(id.clone(), menu.request.clone())
1212                    }
1213                    MenuItem::Separator => unreachable!(),
1214                };
1215
1216                let state_click = state.clone();
1217                let state_hover = state.clone();
1218                let mut row = div()
1219                    .h(px(menu::MENU_ITEM_HEIGHT))
1220                    .px(px(menu::MENU_PADDING_X))
1221                    .flex()
1222                    .items_center()
1223                    .text_color(theme.menu_fg)
1224                    .text_size(px(menu::MENU_FONT_SIZE))
1225                    .child(label)
1226                    .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
1227                        state_hover.update(cx, |s, cx| {
1228                            if let Some(m) = s.context_menu.as_mut() {
1229                                if m.hovered != Some(this_idx) {
1230                                    m.hovered = Some(this_idx);
1231                                    cx.notify();
1232                                }
1233                            }
1234                        });
1235                    })
1236                    .on_mouse_down(
1237                        MouseButton::Left,
1238                        move |_e: &MouseDownEvent, _window, cx| {
1239                            state_click.update(cx, |s, cx| {
1240                                match &action {
1241                                    MenuDispatch::Builtin(a, col) => {
1242                                        s.pending_action = Some((*a, *col));
1243                                    }
1244                                    MenuDispatch::Custom(id, request) => {
1245                                        if let Some(request) = request {
1246                                            s.pending_custom_context_menu_action =
1247                                                Some(PendingCustomContextMenuAction {
1248                                                    id: id.clone(),
1249                                                    request: request.clone(),
1250                                                });
1251                                        }
1252                                    }
1253                                }
1254                                s.context_menu = None;
1255                                cx.notify();
1256                            });
1257                        },
1258                    );
1259                if hovered {
1260                    row = row.bg(theme.menu_hover_bg);
1261                }
1262                rows.push(row.into_any_element());
1263            }
1264        }
1265    }
1266
1267    let menu_body = div()
1268        .flex()
1269        .flex_col()
1270        .w(px(menu_w))
1271        .py(px(menu::MENU_INNER_PAD))
1272        .bg(theme.menu_bg)
1273        .border_1()
1274        .border_color(theme.grid_line)
1275        .children(rows);
1276
1277    // Full-window transparent backdrop: catches clicks outside the menu to
1278    // dismiss it. Placed behind the menu within the same anchored overlay.
1279    let state_backdrop = state.clone();
1280    let overlay = deferred(
1281        anchored().position(point(px(abs_x), px(abs_y))).child(
1282            div()
1283                .occlude()
1284                .child(crate::grid::motion::pop_in(
1285                    menu_body,
1286                    "grid-context-menu",
1287                    animations,
1288                ))
1289                .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
1290                    state_backdrop.update(cx, |s, cx| {
1291                        if s.context_menu.is_some() {
1292                            s.context_menu = None;
1293                            s.filter_panel = None;
1294                            cx.notify();
1295                        }
1296                    });
1297                }),
1298        ),
1299    )
1300    .with_priority(CONTEXT_MENU_PRIORITY);
1301
1302    Some(overlay)
1303}
1304
1305/// Fixed width of the filter popover, in pixels.
1306const FILTER_PANEL_WIDTH: f32 = 300.0;
1307/// Max number of distinct value rows rendered at once (search narrows the set).
1308const FILTER_PANEL_MAX_ROWS: usize = 200;
1309
1310/// Build the Numbers-style per-column filter popover as a `deferred` +
1311/// `anchored` overlay, using the exact mechanism as
1312/// [`render_context_menu_overlay`] so it paints and receives events outside the
1313/// grid's own layout bounds. Returns `None` when no panel is open.
1314#[allow(clippy::too_many_lines)]
1315fn render_filter_panel_overlay(
1316    state: &Entity<GridState>,
1317    cx: &mut Context<SqllyDataTable>,
1318) -> Option<impl IntoElement> {
1319    let s = state.read(cx);
1320    let panel = s.filter_panel.clone()?;
1321    let theme = s.theme.clone();
1322    let animations = s.config.animations;
1323    let col = panel.col;
1324    let current_sort = s.sort;
1325    let filter_active = s.filters.get(col).is_some_and(|f| f.is_active());
1326    let grid_ox = f32::from(s.bounds.origin.x);
1327    let grid_oy = f32::from(s.bounds.origin.y);
1328
1329    // Anchor (grid-relative) -> absolute window coords. The default
1330    // `SwitchAnchor` fit mode on `anchored()` handles viewport-edge flipping
1331    // automatically using the actual rendered height, so we don't need a
1332    // manual estimate or flip calculation here.
1333    let abs_x = grid_ox + f32::from(panel.anchor.x);
1334    let abs_y = grid_oy + f32::from(panel.anchor.y);
1335
1336    // Palette (all `Hsla` are `Copy`, so they move freely into closures).
1337    let c_bg = theme.menu_bg;
1338    let c_line = theme.grid_line;
1339    let c_fg = theme.menu_fg;
1340    let c_accent = theme.sort_indicator;
1341    let c_hover = theme.menu_hover_bg;
1342    let c_muted = theme.muted_text;
1343
1344    let checkbox = {
1345        let theme = theme.clone();
1346        move |checked: bool| crate::grid::checkbox(checked, &theme)
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        .id("filter-select-all")
1547        .h(px(24.0))
1548        .flex()
1549        .items_center()
1550        .gap(px(6.0))
1551        .px(px(4.0))
1552        .rounded(px(4.0))
1553        .cursor_pointer()
1554        .hover(move |style| style.bg(c_hover))
1555        .child(checkbox(panel.all_checked()))
1556        .child("(Select All)")
1557        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1558            st_all.update(cx, |s, cx| {
1559                s.toggle_filter_select_all();
1560                cx.notify();
1561            });
1562        });
1563
1564    let visible = panel.visible_indices();
1565    let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
1566    for &idx in visible.iter().take(FILTER_PANEL_MAX_ROWS) {
1567        let row = &panel.distinct[idx];
1568        let st_val = state.clone();
1569        value_rows.push(
1570            div()
1571                .id(("filter-value", idx))
1572                .h(px(22.0))
1573                .flex()
1574                .items_center()
1575                .gap(px(6.0))
1576                .px(px(4.0))
1577                .rounded(px(4.0))
1578                .cursor_pointer()
1579                .hover(move |style| style.bg(c_hover))
1580                .child(checkbox(row.checked))
1581                .child(div().text_color(c_fg).child(row.label.clone()))
1582                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1583                    st_val.update(cx, |s, cx| {
1584                        s.toggle_filter_value(idx);
1585                        cx.notify();
1586                    });
1587                })
1588                .into_any_element(),
1589        );
1590    }
1591    let truncated = visible.len() > FILTER_PANEL_MAX_ROWS;
1592    let value_list = div()
1593        .id("filter-value-list")
1594        .flex()
1595        .flex_col()
1596        .max_h(px(180.0))
1597        .overflow_y_scroll()
1598        .children(value_rows)
1599        .children(truncated.then(|| {
1600            div()
1601                .text_color(c_muted)
1602                .text_size(px(11.0))
1603                .child("Refine search to see more…")
1604        }));
1605
1606    // --- Clear (left, disabled when no active filter) + Close (right) -----
1607    let st_clear = state.clone();
1608    let st_close = state.clone();
1609    let clear_bg = if filter_active { c_hover } else { c_bg };
1610    let clear_fg = if filter_active { c_fg } else { c_muted };
1611    let clear_border = if filter_active { c_line } else { c_muted };
1612    let buttons_row = div()
1613        .flex()
1614        .gap(px(6.0))
1615        .child(
1616            div()
1617                .flex_1()
1618                .h(px(28.0))
1619                .flex()
1620                .items_center()
1621                .justify_center()
1622                .border_1()
1623                .border_color(clear_border)
1624                .bg(clear_bg)
1625                .text_color(clear_fg)
1626                .cursor_pointer()
1627                .child("Clear Filter")
1628                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1629                    if !filter_active {
1630                        return;
1631                    }
1632                    st_clear.update(cx, |s, cx| {
1633                        s.clear_filter_panel();
1634                        cx.notify();
1635                    });
1636                }),
1637        )
1638        .child(
1639            div()
1640                .flex_1()
1641                .h(px(28.0))
1642                .flex()
1643                .items_center()
1644                .justify_center()
1645                .border_1()
1646                .border_color(c_line)
1647                .bg(c_hover)
1648                .cursor_pointer()
1649                .child("Close")
1650                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1651                    st_close.update(cx, |s, cx| {
1652                        s.filter_panel = None;
1653                        cx.notify();
1654                    });
1655                }),
1656        );
1657
1658    let panel_body = div()
1659        .flex()
1660        .flex_col()
1661        .w(px(FILTER_PANEL_WIDTH))
1662        .p(px(10.0))
1663        .gap(px(8.0))
1664        .bg(c_bg)
1665        .border_1()
1666        .border_color(c_line)
1667        .text_color(c_fg)
1668        .text_size(px(13.0))
1669        .child(div().text_color(c_muted).text_size(px(11.0)).child("Sort"))
1670        .child(sort_row)
1671        .child(
1672            div()
1673                .text_color(c_muted)
1674                .text_size(px(11.0))
1675                .child("Filter"),
1676        )
1677        .child(op_button)
1678        .children(op_menu)
1679        .children(operands)
1680        .child(search_box)
1681        .child(select_all_row)
1682        .child(value_list)
1683        .child(buttons_row);
1684
1685    let st_backdrop = state.clone();
1686    let overlay = deferred(
1687        anchored()
1688            .anchor(Corner::BottomLeft)
1689            .position(point(px(abs_x), px(abs_y)))
1690            .child(
1691                div()
1692                    .occlude()
1693                    .child(crate::grid::motion::pop_in(
1694                        panel_body,
1695                        "grid-filter-panel",
1696                        animations,
1697                    ))
1698                    .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
1699                        st_backdrop.update(cx, |s, cx| {
1700                            if s.filter_panel.is_some() {
1701                                s.filter_panel = None;
1702                                cx.notify();
1703                            }
1704                        });
1705                    }),
1706            ),
1707    )
1708    .with_priority(CONTEXT_MENU_PRIORITY);
1709
1710    Some(overlay)
1711}
1712
1713/// Renders the loading overlay while a background task runs. Returns `None`
1714/// when the grid is not busy. Painted as an absolute, input-occluding scrim
1715/// over the whole grid area with a centered card showing the task label and a
1716/// progress bar (determinate when progress is known, otherwise an animated
1717/// indeterminate bar).
1718fn render_busy_overlay(
1719    state: &Entity<GridState>,
1720    cx: &mut Context<SqllyDataTable>,
1721) -> Option<impl IntoElement> {
1722    let s = state.read(cx);
1723    let busy = s.busy.clone()?;
1724    let theme = s.theme.clone();
1725    let animations = s.config.animations;
1726    let track = theme.grid_line;
1727    let accent = theme.sort_indicator;
1728
1729    let bar: gpui::AnyElement = if let Some(p) = busy.progress {
1730        let p = p.clamp(0.0, 1.0);
1731        div()
1732            .h_full()
1733            .w(relative(p))
1734            .rounded(px(3.0))
1735            .bg(accent)
1736            .into_any_element()
1737    } else {
1738        div()
1739            .h_full()
1740            .w(relative(0.3))
1741            .rounded(px(3.0))
1742            .bg(accent)
1743            .with_animation(
1744                "busy-indeterminate",
1745                Animation::new(std::time::Duration::from_millis(900))
1746                    .repeat()
1747                    .with_easing(pulsating_between(0.15, 0.85)),
1748                |el, delta| el.w(relative(delta)),
1749            )
1750            .into_any_element()
1751    };
1752
1753    let card = div()
1754        .flex()
1755        .flex_col()
1756        .gap(px(10.0))
1757        .p(px(16.0))
1758        .min_w(px(220.0))
1759        .rounded(px(8.0))
1760        .bg(theme.menu_bg)
1761        .border_1()
1762        .border_color(theme.grid_line)
1763        .child(
1764            div()
1765                .text_color(theme.menu_fg)
1766                .text_size(px(14.0))
1767                .child(busy.label.clone()),
1768        )
1769        .child(
1770            div()
1771                .w_full()
1772                .h(px(6.0))
1773                .rounded(px(3.0))
1774                .bg(track)
1775                .child(bar),
1776        );
1777
1778    let overlay = div()
1779        .absolute()
1780        .top_0()
1781        .left_0()
1782        .size_full()
1783        .occlude()
1784        .flex()
1785        .items_center()
1786        .justify_center()
1787        .bg(theme.overlay_scrim)
1788        .child(card);
1789
1790    Some(crate::grid::motion::fade_in(
1791        overlay,
1792        "grid-busy-overlay",
1793        crate::grid::motion::SCRIM_ENTER_MS,
1794        animations,
1795    ))
1796}
1797
1798/// What a menu row dispatches when clicked. Captured per-row so the click
1799/// handler owns its data without borrowing the menu snapshot.
1800enum MenuDispatch {
1801    Builtin(menu::MenuAction, usize),
1802    Custom(
1803        String,
1804        Option<crate::grid::context_menu::ContextMenuRequest>,
1805    ),
1806}