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::grid::context_menu::{
9    ContextMenuProvider, ContextMenuProviderHandle, PendingCustomContextMenuAction,
10};
11use crate::grid::paint::{paint_grid, paint_status_bar, PaintData, StatusBarData};
12use crate::grid::state::state_inner;
13use crate::grid::state::{FilterInput, GridState, EDGE_SCROLL_TICK_MS};
14use crate::grid::theme::GridTheme;
15use crate::grid::{menu, HitResult, MenuItem, SortDirection};
16
17use gpui::{
18    anchored, canvas, deferred, div, hsla, point, pulsating_between, px, relative, Animation,
19    AnimationExt, App, AppContext, Context, Corner, Entity, FocusHandle, Focusable,
20    InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
21    MouseUpEvent, ParentElement, Render, ScrollWheelEvent, StatefulInteractiveElement, Styled,
22    Window,
23};
24
25/// Draw order for the context-menu overlay. Deliberately far above any
26/// ordinary application UI so the menu — and, crucially, its event hitbox —
27/// sits on top of everything, even content painted outside the grid widget's
28/// own layout bounds (e.g. a host header above the grid). Deferred draws
29/// register their hitbox in a later pass, so this also fixes hover/click
30/// routing for menu items that visually overflow the grid area.
31const CONTEXT_MENU_PRIORITY: usize = 1_000_000;
32
33/// Top-level GPUI widget.
34pub struct SqllyDataTable {
35    pub state: Entity<GridState>,
36    /// When `true`, the grid swaps between the built-in light/dark
37    /// [`GridTheme`] palettes to follow the OS window appearance. Disabled
38    /// automatically when the caller supplies an explicit theme override.
39    follow_system_appearance: bool,
40    /// Retained appearance-observer subscription. Registered lazily on the
41    /// first render (that is where a `Window` is available); dropping it would
42    /// unregister the observer, so it is stored for the widget's lifetime.
43    appearance_subscription: Option<gpui::Subscription>,
44}
45
46impl SqllyDataTable {
47    /// Wrap an existing `Entity<GridState>`.
48    #[must_use]
49    pub fn new(state: Entity<GridState>) -> Self {
50        Self {
51            state,
52            follow_system_appearance: true,
53            appearance_subscription: None,
54        }
55    }
56
57    /// Construct from `GridData` using the default [`GridConfig`].
58    #[must_use]
59    pub fn builder(data: GridData) -> SqllyDataTableBuilder {
60        SqllyDataTableBuilder {
61            data,
62            config: GridConfig::default(),
63            context_menu_provider: None,
64            theme: None,
65            debug_bar: false,
66        }
67    }
68}
69
70/// Builder for `SqllyDataTable`.
71pub struct SqllyDataTableBuilder {
72    data: GridData,
73    config: GridConfig,
74    context_menu_provider: Option<ContextMenuProviderHandle>,
75    theme: Option<GridTheme>,
76    debug_bar: bool,
77}
78
79impl SqllyDataTableBuilder {
80    /// Override the entire [`GridConfig`].
81    #[must_use]
82    pub fn config(mut self, config: GridConfig) -> Self {
83        self.config = config;
84        self
85    }
86
87    /// Override the [`GridTheme`]. Supplying an explicit theme opts out of the
88    /// automatic OS light/dark following; the grid uses exactly this theme.
89    #[must_use]
90    pub fn theme(mut self, theme: GridTheme) -> Self {
91        self.theme = Some(theme);
92        self
93    }
94
95    /// Register a custom right-click menu provider. When registered, the
96    /// provider fully controls the right-click menu for all targets (cells,
97    /// row headers, column headers). The built-in column-header menu is
98    /// suppressed; use
99    /// [`crate::grid::context_menu::ContextMenuItem::standard_column_header_items`]
100    /// to compose built-in actions.
101    #[must_use]
102    pub fn context_menu_provider(mut self, provider: impl ContextMenuProvider + 'static) -> Self {
103        self.context_menu_provider = Some(ContextMenuProviderHandle::new(provider));
104        self
105    }
106
107    /// Enable or disable the debug status bar. When enabled, a bar is painted
108    /// at the bottom of the grid showing click position, scroll offset, and
109    /// hovered cell coordinates. Off by default.
110    #[must_use]
111    pub fn debug_bar(mut self, enabled: bool) -> Self {
112        self.debug_bar = enabled;
113        self
114    }
115
116    /// Build the widget inside the supplied [`gpui::App`].
117    pub fn build(self, cx: &mut App) -> SqllyDataTable {
118        let focus = cx.focus_handle();
119        let provider = self.context_menu_provider;
120        let theme_override = self.theme;
121        let debug_bar = self.debug_bar;
122        let follow_system_appearance = theme_override.is_none();
123        let state = cx.new(|cx| {
124            let mut s = GridState::new(self.data, self.config, focus.clone());
125            s.context_menu_provider = provider;
126            s.debug_bar_enabled = debug_bar;
127            s.self_weak = Some(cx.weak_entity());
128            if let Some(theme) = theme_override {
129                s.theme = theme;
130            }
131            s
132        });
133        SqllyDataTable {
134            state,
135            follow_system_appearance,
136            appearance_subscription: None,
137        }
138    }
139}
140
141impl Focusable for SqllyDataTable {
142    fn focus_handle(&self, cx: &App) -> FocusHandle {
143        self.state.read(cx).focus_handle.clone()
144    }
145}
146
147impl Render for SqllyDataTable {
148    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
149        // Follow the OS light/dark appearance: set the initial theme from the
150        // current window appearance and register a one-time observer that
151        // swaps the grid theme whenever the system appearance changes. Skipped
152        // when the caller supplied an explicit theme override.
153        if self.follow_system_appearance && self.appearance_subscription.is_none() {
154            let initial = GridTheme::for_appearance(window.appearance());
155            self.state.update(cx, |s, _cx| s.theme = initial);
156            let state_appearance = self.state.clone();
157            self.appearance_subscription =
158                Some(window.observe_window_appearance(move |window, cx| {
159                    let theme = GridTheme::for_appearance(window.appearance());
160                    state_appearance.update(cx, |s, cx| {
161                        s.theme = theme;
162                        cx.notify();
163                    });
164                }));
165        }
166
167        let state_canvas = self.state.clone();
168        let state_status = self.state.clone();
169        let state_mouse = self.state.clone();
170        let state_move = self.state.clone();
171        let state_up = self.state.clone();
172        let state_scroll = self.state.clone();
173        let state_key = self.state.clone();
174        let state_right = self.state.clone();
175        let bg = self.state.read(cx).theme.bg;
176        let focus_handle = self.state.read(cx).focus_handle.clone();
177        let focus_left = focus_handle.clone();
178        let focus_right = focus_handle.clone();
179        let debug_bar = self.state.read(cx).debug_bar_enabled;
180        let status_h = self.state.read(cx).status_bar_height;
181
182        // Process any pending menu action from a previous mouse-down on a
183        // menu item (needs App access for clipboard).
184        if let Some((action, col)) = self.state.read(cx).pending_action {
185            self.state.update(cx, |s, cx| {
186                s.execute_action(action, col, cx);
187                s.pending_action = None;
188            });
189        }
190
191        // Process any pending custom context-menu action.
192        if let Some(pending) = self
193            .state
194            .read(cx)
195            .pending_custom_context_menu_action
196            .clone()
197        {
198            self.state.update(cx, |s, cx| {
199                s.pending_custom_context_menu_action = None;
200                s.execute_custom_context_menu_action(pending, cx);
201            });
202        }
203
204        // Spawn an edge-scroll timer **only while a drag is in progress**, and
205        // **only one at a time**. Without the `edge_scroll_active` guard,
206        // `render` would spawn a fresh 16 ms loop on every frame/notify during
207        // a drag — each successful tick calls `cx.notify()`, which re-renders
208        // and spawned yet another task, stacking concurrent loops that each
209        // apply a scroll delta per tick and multiply the effective speed
210        // without bound. The task clears the flag when it exits.
211        if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
212            self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
213            let state_edge = self.state.clone();
214            cx.spawn(async move |_weak, cx| {
215                loop {
216                    gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
217                    let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
218                    if let Ok(true) = res {
219                        let _ = state_edge.update(cx, |_s, cx| cx.notify());
220                    }
221                    let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
222                    if !matches!(dragging_res, Ok(true)) {
223                        break;
224                    }
225                }
226                let _ =
227                    cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
228            })
229            .detach();
230        }
231
232        div()
233            .flex()
234            .flex_col()
235            .size_full()
236            .relative()
237            .track_focus(&focus_handle)
238            .bg(bg)
239            .child(
240                canvas(
241                    move |bounds, window, cx| -> PaintData {
242                        let viewport = window.viewport_size();
243                        state_canvas.update(cx, |s, cx| {
244                            let mut dirty = false;
245                            if s.bounds != bounds {
246                                s.bounds = bounds;
247                                dirty = true;
248                            }
249                            if s.window_viewport != viewport {
250                                s.window_viewport = viewport;
251                            }
252                            if dirty {
253                                cx.notify();
254                            }
255                        });
256                        let s = state_canvas.read(cx);
257                        PaintData::from_state(s)
258                    },
259                    move |bounds, data, window, cx| {
260                        paint_grid(&data, window, cx, bounds);
261                    },
262                )
263                .flex_1(),
264            )
265            .children(debug_bar.then(|| {
266                canvas(
267                    move |_bounds, _window, cx| -> StatusBarData {
268                        let s = state_status.read(cx);
269                        StatusBarData::from_state(s)
270                    },
271                    move |bounds, data, window, cx| {
272                        paint_status_bar(&data, window, cx, bounds);
273                    },
274                )
275                .h(px(status_h))
276            }))
277            .children(render_context_menu_overlay(&self.state, cx))
278            .children(render_filter_panel_overlay(&self.state, cx))
279            .children(render_busy_overlay(&self.state, cx))
280            .on_mouse_down(
281                MouseButton::Left,
282                move |event: &MouseDownEvent, window, cx| {
283                    window.focus(&focus_left);
284                    state_mouse.update(cx, |s, cx| {
285                        // Ignore grid input while a background task is running;
286                        // the busy overlay is shown and occludes interaction.
287                        if s.busy.is_some() {
288                            return;
289                        }
290                        // Normalize the absolute window pointer into the grid's
291                        // own frame. Menu hit-testing is handled by the deferred
292                        // overlay's own item handlers, so a left-click that
293                        // reaches the grid means the pointer was NOT on the menu;
294                        // dismiss any open menu and proceed with grid selection.
295                        let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
296                        if s.context_menu.is_some() || s.filter_panel.is_some() {
297                            s.context_menu = None;
298                            s.filter_panel = None;
299                        }
300                        s.handle_mouse_down(rel, event.modifiers.shift);
301                        cx.notify();
302                    });
303                },
304            )
305            .on_mouse_down(
306                MouseButton::Right,
307                move |event: &MouseDownEvent, window, cx| {
308                    window.focus(&focus_right);
309                    state_right.update(cx, |s, cx| {
310                        if s.busy.is_some() {
311                            return;
312                        }
313                        let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
314                        let hit = s.hit_test(pos);
315
316                        // No provider — existing built-in behavior.
317                        if s.context_menu_provider.is_none() {
318                            match hit {
319                                HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
320                                    s.open_context_menu(col, pos);
321                                }
322                                _ => {
323                                    s.context_menu = None;
324                                    s.filter_panel = None;
325                                }
326                            }
327                            cx.notify();
328                            return;
329                        }
330
331                        // Provider exists — build custom menu.
332                        let Some(target) = s.context_menu_target_from_hit(hit) else {
333                            s.context_menu = None;
334                            s.filter_panel = None;
335                            cx.notify();
336                            return;
337                        };
338
339                        let effective = s.effective_selection_for_context_target(&target);
340                        if effective != s.selection {
341                            s.selection = effective.clone();
342                        }
343
344                        let request = s.build_context_menu_request(target, &effective);
345                        let col = request.target.column_index().unwrap_or(0);
346
347                        let Some(provider) = s.context_menu_provider.clone() else {
348                            return;
349                        };
350                        let public_items = provider.menu_items(&request);
351                        let items = GridState::convert_context_menu_items(public_items);
352
353                        if items.is_empty() {
354                            s.context_menu = None;
355                        } else {
356                            s.context_menu =
357                                Some(menu::ContextMenu::custom(col, pos, items, request));
358                        }
359                        s.filter_panel = None;
360                        cx.notify();
361                    });
362                },
363            )
364            .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
365                state_move.update(cx, |s, cx| {
366                    let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
367                    s.handle_mouse_move(rel, event.pressed_button);
368                    cx.notify();
369                });
370            })
371            .on_mouse_up(
372                MouseButton::Left,
373                move |_event: &MouseUpEvent, _window, cx| {
374                    state_up.update(cx, |s, cx| {
375                        s.handle_mouse_up();
376                        cx.notify();
377                    });
378                },
379            )
380            .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
381                state_scroll.update(cx, |s, cx| {
382                    let line_h = px(s.row_height);
383                    let delta = event.delta.pixel_delta(line_h);
384                    let scroll = s.scroll_handle.offset();
385                    let (mx, my) = s.max_scroll();
386                    let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
387                    let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
388                    s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
389                    if s.drag_start.is_some() {
390                        s.handle_scroll_drag();
391                    }
392                    cx.notify();
393                });
394            })
395            .on_key_down(move |event: &KeyDownEvent, _window, cx| {
396                let ks = &event.keystroke;
397                if ks.modifiers.platform && ks.key == "q" {
398                    cx.quit();
399                    return;
400                }
401                state_key.update(cx, |s, cx| {
402                    let kb = &s.config.key_bindings;
403                    if kb.select_all.matches(ks) {
404                        s.select_all();
405                    } else if kb.copy.matches(ks) {
406                        s.copy_selection(false, cx);
407                    } else if kb.copy_with_headers.matches(ks) {
408                        s.copy_selection(true, cx);
409                    } else if kb.page_up.matches(ks) {
410                        s.page_up();
411                    } else if kb.page_down.matches(ks) {
412                        s.page_down();
413                    } else {
414                        s.handle_key(ks);
415                    }
416                    cx.notify();
417                });
418            })
419    }
420}
421
422/// Build the context-menu overlay as a `deferred` + `anchored` element so it
423/// paints — and receives mouse events — on top of everything, including
424/// regions outside the grid widget's own layout bounds. Returns `None` when no
425/// menu is open.
426///
427/// Positioning reuses [`menu::ContextMenu::resolved_position`] (window-viewport
428/// aware: flips up when there's no room below, shifts left at the right edge),
429/// then converts to absolute window coordinates for `anchored().position(..)`.
430/// Each selectable row carries its own `on_mouse_down` (dispatch) and
431/// `on_mouse_move` (hover highlight) handlers; a full-screen backdrop behind
432/// the menu dismisses it on any outside click.
433fn render_context_menu_overlay(
434    state: &Entity<GridState>,
435    cx: &mut Context<SqllyDataTable>,
436) -> Option<impl IntoElement> {
437    let s = state.read(cx);
438    let menu = s.context_menu.clone()?;
439    let theme = s.theme.clone();
440    let cw = s.char_width;
441    let grid_ox = f32::from(s.bounds.origin.x);
442    let grid_oy = f32::from(s.bounds.origin.y);
443    let viewport = s.window_viewport;
444    let vw = f32::from(viewport.width);
445    let vh = f32::from(viewport.height);
446
447    let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
448    let abs_x = grid_ox + f32::from(resolved.x);
449    let abs_y = grid_oy + f32::from(resolved.y);
450    let menu_w = menu.width_for(cw);
451
452    // Build one row per item. `selectable_idx` counts only Action/Custom items
453    // so it matches the `hovered` index convention used elsewhere.
454    let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
455    let mut selectable_idx = 0usize;
456    for item in &menu.items {
457        match item {
458            MenuItem::Separator => {
459                rows.push(
460                    div()
461                        .h(px(menu::MENU_ITEM_HEIGHT))
462                        .flex()
463                        .items_center()
464                        .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
465                        .into_any_element(),
466                );
467            }
468            MenuItem::Action(_) | MenuItem::Custom { .. } => {
469                let this_idx = selectable_idx;
470                selectable_idx += 1;
471                let label = item.label().unwrap_or("").to_owned();
472                let hovered = menu.hovered == Some(this_idx);
473
474                // Dispatch: set the pending action and close the menu. The
475                // pending fields are drained at the top of `render` (they need
476                // App access for clipboard).
477                let action = match item {
478                    MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
479                    MenuItem::Custom { id, .. } => {
480                        MenuDispatch::Custom(id.clone(), menu.request.clone())
481                    }
482                    MenuItem::Separator => unreachable!(),
483                };
484
485                let state_click = state.clone();
486                let state_hover = state.clone();
487                let mut row = div()
488                    .h(px(menu::MENU_ITEM_HEIGHT))
489                    .px(px(menu::MENU_PADDING_X))
490                    .flex()
491                    .items_center()
492                    .text_color(theme.menu_fg)
493                    .text_size(px(menu::MENU_FONT_SIZE))
494                    .child(label)
495                    .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
496                        state_hover.update(cx, |s, cx| {
497                            if let Some(m) = s.context_menu.as_mut() {
498                                if m.hovered != Some(this_idx) {
499                                    m.hovered = Some(this_idx);
500                                    cx.notify();
501                                }
502                            }
503                        });
504                    })
505                    .on_mouse_down(
506                        MouseButton::Left,
507                        move |_e: &MouseDownEvent, _window, cx| {
508                            state_click.update(cx, |s, cx| {
509                                match &action {
510                                    MenuDispatch::Builtin(a, col) => {
511                                        s.pending_action = Some((*a, *col));
512                                    }
513                                    MenuDispatch::Custom(id, request) => {
514                                        if let Some(request) = request {
515                                            s.pending_custom_context_menu_action =
516                                                Some(PendingCustomContextMenuAction {
517                                                    id: id.clone(),
518                                                    request: request.clone(),
519                                                });
520                                        }
521                                    }
522                                }
523                                s.context_menu = None;
524                                cx.notify();
525                            });
526                        },
527                    );
528                if hovered {
529                    row = row.bg(theme.menu_hover_bg);
530                }
531                rows.push(row.into_any_element());
532            }
533        }
534    }
535
536    let menu_body = div()
537        .flex()
538        .flex_col()
539        .w(px(menu_w))
540        .py(px(menu::MENU_INNER_PAD))
541        .bg(theme.menu_bg)
542        .border_1()
543        .border_color(theme.grid_line)
544        .children(rows);
545
546    // Full-window transparent backdrop: catches clicks outside the menu to
547    // dismiss it. Placed behind the menu within the same anchored overlay.
548    let state_backdrop = state.clone();
549    let overlay = deferred(anchored().position(point(px(abs_x), px(abs_y))).child(
550        div().occlude().child(menu_body).on_mouse_down_out(
551            move |_e: &MouseDownEvent, _window, cx| {
552                state_backdrop.update(cx, |s, cx| {
553                    if s.context_menu.is_some() {
554                        s.context_menu = None;
555                        s.filter_panel = None;
556                        cx.notify();
557                    }
558                });
559            },
560        ),
561    ))
562    .with_priority(CONTEXT_MENU_PRIORITY);
563
564    Some(overlay)
565}
566
567/// Fixed width of the filter popover, in pixels.
568const FILTER_PANEL_WIDTH: f32 = 300.0;
569/// Max number of distinct value rows rendered at once (search narrows the set).
570const FILTER_PANEL_MAX_ROWS: usize = 200;
571
572/// Build the Numbers-style per-column filter popover as a `deferred` +
573/// `anchored` overlay, using the exact mechanism as
574/// [`render_context_menu_overlay`] so it paints and receives events outside the
575/// grid's own layout bounds. Returns `None` when no panel is open.
576#[allow(clippy::too_many_lines)]
577fn render_filter_panel_overlay(
578    state: &Entity<GridState>,
579    cx: &mut Context<SqllyDataTable>,
580) -> Option<impl IntoElement> {
581    let s = state.read(cx);
582    let panel = s.filter_panel.clone()?;
583    let theme = s.theme.clone();
584    let col = panel.col;
585    let current_sort = s.sort;
586    let filter_active = s.filters.get(col).is_some_and(|f| f.is_active());
587    let grid_ox = f32::from(s.bounds.origin.x);
588    let grid_oy = f32::from(s.bounds.origin.y);
589
590    // Anchor (grid-relative) -> absolute window coords. The default
591    // `SwitchAnchor` fit mode on `anchored()` handles viewport-edge flipping
592    // automatically using the actual rendered height, so we don't need a
593    // manual estimate or flip calculation here.
594    let abs_x = grid_ox + f32::from(panel.anchor.x);
595    let abs_y = grid_oy + f32::from(panel.anchor.y);
596
597    // Palette (all `Hsla` are `Copy`, so they move freely into closures).
598    let c_bg = theme.menu_bg;
599    let c_line = theme.grid_line;
600    let c_fg = theme.menu_fg;
601    let c_accent = theme.sort_indicator;
602    let c_hover = theme.menu_hover_bg;
603    let c_muted = theme.muted_text;
604
605    let checkbox = move |checked: bool| {
606        let mut b = div()
607            .w(px(14.0))
608            .h(px(14.0))
609            .border_1()
610            .border_color(c_line)
611            .bg(c_bg)
612            .flex()
613            .items_center()
614            .justify_center();
615        if checked {
616            b = b.child(div().w(px(8.0)).h(px(8.0)).bg(c_accent));
617        }
618        b
619    };
620
621    // --- Sort row -----------------------------------------------------------
622    let (asc_active, desc_active) = match current_sort {
623        Some((c, SortDirection::Ascending)) if c == col => (true, false),
624        Some((c, SortDirection::Descending)) if c == col => (false, true),
625        _ => (false, false),
626    };
627    let st_asc = state.clone();
628    let st_desc = state.clone();
629    let sort_row = div()
630        .flex()
631        .gap(px(6.0))
632        .child(
633            div()
634                .flex_1()
635                .h(px(26.0))
636                .flex()
637                .items_center()
638                .justify_center()
639                .border_1()
640                .border_color(c_line)
641                .bg(if asc_active { c_accent } else { c_hover })
642                .cursor_pointer()
643                .child("Ascending")
644                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
645                    st_asc.update(cx, |s, cx| {
646                        s.set_panel_sort(SortDirection::Ascending);
647                        cx.notify();
648                    });
649                }),
650        )
651        .child(
652            div()
653                .flex_1()
654                .h(px(26.0))
655                .flex()
656                .items_center()
657                .justify_center()
658                .border_1()
659                .border_color(c_line)
660                .bg(if desc_active { c_accent } else { c_hover })
661                .cursor_pointer()
662                .child("Descending")
663                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
664                    st_desc.update(cx, |s, cx| {
665                        s.set_panel_sort(SortDirection::Descending);
666                        cx.notify();
667                    });
668                }),
669        );
670
671    // --- Operator dropdown --------------------------------------------------
672    let st_op_toggle = state.clone();
673    let op_button = div()
674        .h(px(26.0))
675        .px(px(8.0))
676        .flex()
677        .items_center()
678        .border_1()
679        .border_color(c_line)
680        .bg(c_bg)
681        .cursor_pointer()
682        .child(panel.current_op_label())
683        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
684            st_op_toggle.update(cx, |s, cx| {
685                s.toggle_filter_op_menu();
686                cx.notify();
687            });
688        });
689
690    let op_menu = panel.op_menu_open.then(|| {
691        let mut items: Vec<gpui::AnyElement> = Vec::new();
692        for (i, label) in panel.op_labels().iter().enumerate() {
693            let selected = i == panel.op_index;
694            let st_pick = state.clone();
695            items.push(
696                div()
697                    .h(px(24.0))
698                    .px(px(8.0))
699                    .flex()
700                    .items_center()
701                    .bg(if selected { c_accent } else { c_bg })
702                    .cursor_pointer()
703                    .child(*label)
704                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
705                        st_pick.update(cx, |s, cx| {
706                            s.set_filter_operator(i);
707                            cx.notify();
708                        });
709                    })
710                    .into_any_element(),
711            );
712        }
713        div()
714            .flex()
715            .flex_col()
716            .border_1()
717            .border_color(c_line)
718            .bg(c_bg)
719            .children(items)
720    });
721
722    // --- Operand field(s) ---------------------------------------------------
723    let operand_field = |value: &str, focused: bool, placeholder: &str, input: FilterInput| {
724        let st_focus = state.clone();
725        let (text, is_placeholder) = if value.is_empty() {
726            (placeholder.to_owned(), true)
727        } else {
728            (value.to_owned(), false)
729        };
730        div()
731            .h(px(26.0))
732            .px(px(6.0))
733            .flex()
734            .items_center()
735            .gap(px(2.0))
736            .border_1()
737            .border_color(if focused { c_accent } else { c_line })
738            .bg(c_bg)
739            .cursor_pointer()
740            .child(
741                div()
742                    .text_color(if is_placeholder { c_muted } else { c_fg })
743                    .child(text),
744            )
745            .children(focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
746            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
747                st_focus.update(cx, |s, cx| {
748                    s.set_filter_focus(input);
749                    cx.notify();
750                });
751            })
752    };
753
754    let operand_placeholder = if panel.kind == crate::data::ColumnKind::Date {
755        "YYYY-MM-DD"
756    } else if crate::filter::uses_number_ops(panel.kind) {
757        "value"
758    } else if panel.op_index == 7 {
759        // Text "matches" operator.
760        "regex"
761    } else {
762        "value"
763    };
764    let operands = panel.needs_operand().then(|| {
765        let mut row = div().flex().flex_col().gap(px(4.0)).child(operand_field(
766            &panel.operand_a.value,
767            panel.focus == FilterInput::OperandA,
768            operand_placeholder,
769            FilterInput::OperandA,
770        ));
771        if panel.needs_second_operand() {
772            row = row
773                .child(div().text_color(c_muted).text_size(px(11.0)).child("and"))
774                .child(operand_field(
775                    &panel.operand_b.value,
776                    panel.focus == FilterInput::OperandB,
777                    operand_placeholder,
778                    FilterInput::OperandB,
779                ));
780        }
781        row
782    });
783
784    // --- Search box ---------------------------------------------------------
785    let st_search = state.clone();
786    let search_focused = panel.focus == FilterInput::Search;
787    let (search_text, search_is_ph) = if panel.search.value.is_empty() {
788        ("Search".to_owned(), true)
789    } else {
790        (panel.search.value.clone(), false)
791    };
792    let search_box = div()
793        .h(px(26.0))
794        .px(px(6.0))
795        .flex()
796        .items_center()
797        .gap(px(2.0))
798        .border_1()
799        .border_color(if search_focused { c_accent } else { c_line })
800        .bg(c_bg)
801        .cursor_pointer()
802        .child(
803            div()
804                .text_color(if search_is_ph { c_muted } else { c_fg })
805                .child(search_text),
806        )
807        .children(search_focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
808        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
809            st_search.update(cx, |s, cx| {
810                s.set_filter_focus(FilterInput::Search);
811                cx.notify();
812            });
813        });
814
815    // --- (Select All) + value checklist ------------------------------------
816    let st_all = state.clone();
817    let select_all_row = div()
818        .h(px(24.0))
819        .flex()
820        .items_center()
821        .gap(px(6.0))
822        .cursor_pointer()
823        .child(checkbox(panel.all_checked()))
824        .child("(Select All)")
825        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
826            st_all.update(cx, |s, cx| {
827                s.toggle_filter_select_all();
828                cx.notify();
829            });
830        });
831
832    let visible = panel.visible_indices();
833    let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
834    for &idx in visible.iter().take(FILTER_PANEL_MAX_ROWS) {
835        let row = &panel.distinct[idx];
836        let st_val = state.clone();
837        value_rows.push(
838            div()
839                .h(px(22.0))
840                .flex()
841                .items_center()
842                .gap(px(6.0))
843                .cursor_pointer()
844                .child(checkbox(row.checked))
845                .child(div().text_color(c_fg).child(row.label.clone()))
846                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
847                    st_val.update(cx, |s, cx| {
848                        s.toggle_filter_value(idx);
849                        cx.notify();
850                    });
851                })
852                .into_any_element(),
853        );
854    }
855    let truncated = visible.len() > FILTER_PANEL_MAX_ROWS;
856    let value_list = div()
857        .id("filter-value-list")
858        .flex()
859        .flex_col()
860        .max_h(px(180.0))
861        .overflow_y_scroll()
862        .children(value_rows)
863        .children(truncated.then(|| {
864            div()
865                .text_color(c_muted)
866                .text_size(px(11.0))
867                .child("Refine search to see more…")
868        }));
869
870    // --- Clear (left, disabled when no active filter) + Close (right) -----
871    let st_clear = state.clone();
872    let st_close = state.clone();
873    let clear_bg = if filter_active { c_hover } else { c_bg };
874    let clear_fg = if filter_active { c_fg } else { c_muted };
875    let clear_border = if filter_active { c_line } else { c_muted };
876    let buttons_row = div()
877        .flex()
878        .gap(px(6.0))
879        .child(
880            div()
881                .flex_1()
882                .h(px(28.0))
883                .flex()
884                .items_center()
885                .justify_center()
886                .border_1()
887                .border_color(clear_border)
888                .bg(clear_bg)
889                .text_color(clear_fg)
890                .cursor_pointer()
891                .child("Clear Filter")
892                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
893                    if !filter_active {
894                        return;
895                    }
896                    st_clear.update(cx, |s, cx| {
897                        s.clear_filter_panel();
898                        cx.notify();
899                    });
900                }),
901        )
902        .child(
903            div()
904                .flex_1()
905                .h(px(28.0))
906                .flex()
907                .items_center()
908                .justify_center()
909                .border_1()
910                .border_color(c_line)
911                .bg(c_hover)
912                .cursor_pointer()
913                .child("Close")
914                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
915                    st_close.update(cx, |s, cx| {
916                        s.filter_panel = None;
917                        cx.notify();
918                    });
919                }),
920        );
921
922    let panel_body = div()
923        .flex()
924        .flex_col()
925        .w(px(FILTER_PANEL_WIDTH))
926        .p(px(10.0))
927        .gap(px(8.0))
928        .bg(c_bg)
929        .border_1()
930        .border_color(c_line)
931        .text_color(c_fg)
932        .text_size(px(13.0))
933        .child(div().text_color(c_muted).text_size(px(11.0)).child("Sort"))
934        .child(sort_row)
935        .child(
936            div()
937                .text_color(c_muted)
938                .text_size(px(11.0))
939                .child("Filter"),
940        )
941        .child(op_button)
942        .children(op_menu)
943        .children(operands)
944        .child(search_box)
945        .child(select_all_row)
946        .child(value_list)
947        .child(buttons_row);
948
949    let st_backdrop = state.clone();
950    let overlay = deferred(
951        anchored()
952            .anchor(Corner::BottomLeft)
953            .position(point(px(abs_x), px(abs_y)))
954            .child(div().occlude().child(panel_body).on_mouse_down_out(
955                move |_e: &MouseDownEvent, _window, cx| {
956                    st_backdrop.update(cx, |s, cx| {
957                        if s.filter_panel.is_some() {
958                            s.filter_panel = None;
959                            cx.notify();
960                        }
961                    });
962                },
963            )),
964    )
965    .with_priority(CONTEXT_MENU_PRIORITY);
966
967    Some(overlay)
968}
969
970/// Renders the loading overlay while a background task runs. Returns `None`
971/// when the grid is not busy. Painted as an absolute, input-occluding scrim
972/// over the whole grid area with a centered card showing the task label and a
973/// progress bar (determinate when progress is known, otherwise an animated
974/// indeterminate bar).
975fn render_busy_overlay(
976    state: &Entity<GridState>,
977    cx: &mut Context<SqllyDataTable>,
978) -> Option<impl IntoElement> {
979    let s = state.read(cx);
980    let busy = s.busy.clone()?;
981    let theme = s.theme.clone();
982    let track = theme.grid_line;
983    let accent = theme.sort_indicator;
984
985    let bar: gpui::AnyElement = if let Some(p) = busy.progress {
986        let p = p.clamp(0.0, 1.0);
987        div()
988            .h_full()
989            .w(relative(p))
990            .rounded(px(3.0))
991            .bg(accent)
992            .into_any_element()
993    } else {
994        div()
995            .h_full()
996            .w(relative(0.3))
997            .rounded(px(3.0))
998            .bg(accent)
999            .with_animation(
1000                "busy-indeterminate",
1001                Animation::new(std::time::Duration::from_millis(900))
1002                    .repeat()
1003                    .with_easing(pulsating_between(0.15, 0.85)),
1004                |el, delta| el.w(relative(delta)),
1005            )
1006            .into_any_element()
1007    };
1008
1009    let card = div()
1010        .flex()
1011        .flex_col()
1012        .gap(px(10.0))
1013        .p(px(16.0))
1014        .min_w(px(220.0))
1015        .rounded(px(8.0))
1016        .bg(theme.menu_bg)
1017        .border_1()
1018        .border_color(theme.grid_line)
1019        .child(
1020            div()
1021                .text_color(theme.menu_fg)
1022                .text_size(px(14.0))
1023                .child(busy.label.clone()),
1024        )
1025        .child(
1026            div()
1027                .w_full()
1028                .h(px(6.0))
1029                .rounded(px(3.0))
1030                .bg(track)
1031                .child(bar),
1032        );
1033
1034    let overlay = div()
1035        .absolute()
1036        .top_0()
1037        .left_0()
1038        .size_full()
1039        .occlude()
1040        .flex()
1041        .items_center()
1042        .justify_center()
1043        .bg(hsla(0.0, 0.0, 0.0, 0.35))
1044        .child(card);
1045
1046    Some(overlay)
1047}
1048
1049/// What a menu row dispatches when clicked. Captured per-row so the click
1050/// handler owns its data without borrowing the menu snapshot.
1051enum MenuDispatch {
1052    Builtin(menu::MenuAction, usize),
1053    Custom(
1054        String,
1055        Option<crate::grid::context_menu::ContextMenuRequest>,
1056    ),
1057}