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                                s.clamp_scroll_to_bounds();
248                                dirty = true;
249                            }
250                            if s.window_viewport != viewport {
251                                s.window_viewport = viewport;
252                            }
253                            if dirty {
254                                cx.notify();
255                            }
256                        });
257                        let s = state_canvas.read(cx);
258                        PaintData::from_state(s)
259                    },
260                    move |bounds, data, window, cx| {
261                        paint_grid(&data, window, cx, bounds);
262                    },
263                )
264                .flex_1(),
265            )
266            .children(debug_bar.then(|| {
267                canvas(
268                    move |_bounds, _window, cx| -> StatusBarData {
269                        let s = state_status.read(cx);
270                        StatusBarData::from_state(s)
271                    },
272                    move |bounds, data, window, cx| {
273                        paint_status_bar(&data, window, cx, bounds);
274                    },
275                )
276                .h(px(status_h))
277            }))
278            .children(render_context_menu_overlay(&self.state, cx))
279            .children(render_filter_panel_overlay(&self.state, cx))
280            .children(render_busy_overlay(&self.state, cx))
281            .on_mouse_down(
282                MouseButton::Left,
283                move |event: &MouseDownEvent, window, cx| {
284                    window.focus(&focus_left);
285                    state_mouse.update(cx, |s, cx| {
286                        // Ignore grid input while a background task is running;
287                        // the busy overlay is shown and occludes interaction.
288                        if s.busy.is_some() {
289                            return;
290                        }
291                        // Normalize the absolute window pointer into the grid's
292                        // own frame. Menu hit-testing is handled by the deferred
293                        // overlay's own item handlers, so a left-click that
294                        // reaches the grid means the pointer was NOT on the menu;
295                        // dismiss any open menu and proceed with grid selection.
296                        let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
297                        if s.context_menu.is_some() || s.filter_panel.is_some() {
298                            s.context_menu = None;
299                            s.filter_panel = None;
300                        }
301                        s.handle_mouse_down_with_modifiers(
302                            rel,
303                            event.modifiers.shift,
304                            event.modifiers.platform,
305                        );
306                        cx.notify();
307                    });
308                },
309            )
310            .on_mouse_down(
311                MouseButton::Right,
312                move |event: &MouseDownEvent, window, cx| {
313                    window.focus(&focus_right);
314                    state_right.update(cx, |s, cx| {
315                        if s.busy.is_some() {
316                            return;
317                        }
318                        let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
319                        let hit = s.hit_test(pos);
320
321                        // No provider — existing built-in behavior.
322                        if s.context_menu_provider.is_none() {
323                            match hit {
324                                HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
325                                    s.open_context_menu(col, pos);
326                                }
327                                _ => {
328                                    s.context_menu = None;
329                                    s.filter_panel = None;
330                                }
331                            }
332                            cx.notify();
333                            return;
334                        }
335
336                        // Provider exists — build custom menu.
337                        let Some(target) = s.context_menu_target_from_hit(hit) else {
338                            s.context_menu = None;
339                            s.filter_panel = None;
340                            cx.notify();
341                            return;
342                        };
343
344                        let effective = s.effective_selection_for_context_target(&target);
345                        if effective != s.selection {
346                            s.selection = effective.clone();
347                        }
348
349                        let request = s.build_context_menu_request(target, &effective);
350                        let col = request.target.column_index().unwrap_or(0);
351
352                        let Some(provider) = s.context_menu_provider.clone() else {
353                            return;
354                        };
355                        let public_items = provider.menu_items(&request);
356                        let items = GridState::convert_context_menu_items(public_items);
357
358                        if items.is_empty() {
359                            s.context_menu = None;
360                        } else {
361                            s.context_menu =
362                                Some(menu::ContextMenu::custom(col, pos, items, request));
363                        }
364                        s.filter_panel = None;
365                        cx.notify();
366                    });
367                },
368            )
369            .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
370                state_move.update(cx, |s, cx| {
371                    let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
372                    s.handle_mouse_move(rel, event.pressed_button);
373                    cx.notify();
374                });
375            })
376            .on_mouse_up(
377                MouseButton::Left,
378                move |_event: &MouseUpEvent, _window, cx| {
379                    state_up.update(cx, |s, cx| {
380                        s.handle_mouse_up();
381                        cx.notify();
382                    });
383                },
384            )
385            .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
386                state_scroll.update(cx, |s, cx| {
387                    let line_h = px(s.row_height);
388                    let delta = event.delta.pixel_delta(line_h);
389                    let scroll = s.scroll_handle.offset();
390                    let (mx, my) = s.max_scroll();
391                    let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
392                    let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
393                    s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
394                    if s.drag_start.is_some() {
395                        s.handle_scroll_drag();
396                    }
397                    cx.notify();
398                });
399            })
400            .on_key_down(move |event: &KeyDownEvent, _window, cx| {
401                let ks = &event.keystroke;
402                if ks.modifiers.platform && ks.key == "q" {
403                    cx.quit();
404                    return;
405                }
406                state_key.update(cx, |s, cx| {
407                    let kb = &s.config.key_bindings;
408                    if kb.select_all.matches(ks) {
409                        s.select_all();
410                    } else if kb.copy.matches(ks) {
411                        s.copy_selection(false, cx);
412                    } else if kb.copy_with_headers.matches(ks) {
413                        s.copy_selection(true, cx);
414                    } else if kb.page_up.matches(ks) {
415                        s.page_up();
416                    } else if kb.page_down.matches(ks) {
417                        s.page_down();
418                    } else {
419                        s.handle_key(ks);
420                    }
421                    cx.notify();
422                });
423            })
424    }
425}
426
427/// Build the context-menu overlay as a `deferred` + `anchored` element so it
428/// paints — and receives mouse events — on top of everything, including
429/// regions outside the grid widget's own layout bounds. Returns `None` when no
430/// menu is open.
431///
432/// Positioning reuses [`menu::ContextMenu::resolved_position`] (window-viewport
433/// aware: flips up when there's no room below, shifts left at the right edge),
434/// then converts to absolute window coordinates for `anchored().position(..)`.
435/// Each selectable row carries its own `on_mouse_down` (dispatch) and
436/// `on_mouse_move` (hover highlight) handlers; a full-screen backdrop behind
437/// the menu dismisses it on any outside click.
438fn render_context_menu_overlay(
439    state: &Entity<GridState>,
440    cx: &mut Context<SqllyDataTable>,
441) -> Option<impl IntoElement> {
442    let s = state.read(cx);
443    let menu = s.context_menu.clone()?;
444    let theme = s.theme.clone();
445    let cw = s.char_width;
446    let grid_ox = f32::from(s.bounds.origin.x);
447    let grid_oy = f32::from(s.bounds.origin.y);
448    let viewport = s.window_viewport;
449    let vw = f32::from(viewport.width);
450    let vh = f32::from(viewport.height);
451
452    let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
453    let abs_x = grid_ox + f32::from(resolved.x);
454    let abs_y = grid_oy + f32::from(resolved.y);
455    let menu_w = menu.width_for(cw);
456
457    // Build one row per item. `selectable_idx` counts only Action/Custom items
458    // so it matches the `hovered` index convention used elsewhere.
459    let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
460    let mut selectable_idx = 0usize;
461    for item in &menu.items {
462        match item {
463            MenuItem::Separator => {
464                rows.push(
465                    div()
466                        .h(px(menu::MENU_ITEM_HEIGHT))
467                        .flex()
468                        .items_center()
469                        .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
470                        .into_any_element(),
471                );
472            }
473            MenuItem::Action(_) | MenuItem::Custom { .. } => {
474                let this_idx = selectable_idx;
475                selectable_idx += 1;
476                let label = item.label().unwrap_or("").to_owned();
477                let hovered = menu.hovered == Some(this_idx);
478
479                // Dispatch: set the pending action and close the menu. The
480                // pending fields are drained at the top of `render` (they need
481                // App access for clipboard).
482                let action = match item {
483                    MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
484                    MenuItem::Custom { id, .. } => {
485                        MenuDispatch::Custom(id.clone(), menu.request.clone())
486                    }
487                    MenuItem::Separator => unreachable!(),
488                };
489
490                let state_click = state.clone();
491                let state_hover = state.clone();
492                let mut row = div()
493                    .h(px(menu::MENU_ITEM_HEIGHT))
494                    .px(px(menu::MENU_PADDING_X))
495                    .flex()
496                    .items_center()
497                    .text_color(theme.menu_fg)
498                    .text_size(px(menu::MENU_FONT_SIZE))
499                    .child(label)
500                    .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
501                        state_hover.update(cx, |s, cx| {
502                            if let Some(m) = s.context_menu.as_mut() {
503                                if m.hovered != Some(this_idx) {
504                                    m.hovered = Some(this_idx);
505                                    cx.notify();
506                                }
507                            }
508                        });
509                    })
510                    .on_mouse_down(
511                        MouseButton::Left,
512                        move |_e: &MouseDownEvent, _window, cx| {
513                            state_click.update(cx, |s, cx| {
514                                match &action {
515                                    MenuDispatch::Builtin(a, col) => {
516                                        s.pending_action = Some((*a, *col));
517                                    }
518                                    MenuDispatch::Custom(id, request) => {
519                                        if let Some(request) = request {
520                                            s.pending_custom_context_menu_action =
521                                                Some(PendingCustomContextMenuAction {
522                                                    id: id.clone(),
523                                                    request: request.clone(),
524                                                });
525                                        }
526                                    }
527                                }
528                                s.context_menu = None;
529                                cx.notify();
530                            });
531                        },
532                    );
533                if hovered {
534                    row = row.bg(theme.menu_hover_bg);
535                }
536                rows.push(row.into_any_element());
537            }
538        }
539    }
540
541    let menu_body = div()
542        .flex()
543        .flex_col()
544        .w(px(menu_w))
545        .py(px(menu::MENU_INNER_PAD))
546        .bg(theme.menu_bg)
547        .border_1()
548        .border_color(theme.grid_line)
549        .children(rows);
550
551    // Full-window transparent backdrop: catches clicks outside the menu to
552    // dismiss it. Placed behind the menu within the same anchored overlay.
553    let state_backdrop = state.clone();
554    let overlay = deferred(anchored().position(point(px(abs_x), px(abs_y))).child(
555        div().occlude().child(menu_body).on_mouse_down_out(
556            move |_e: &MouseDownEvent, _window, cx| {
557                state_backdrop.update(cx, |s, cx| {
558                    if s.context_menu.is_some() {
559                        s.context_menu = None;
560                        s.filter_panel = None;
561                        cx.notify();
562                    }
563                });
564            },
565        ),
566    ))
567    .with_priority(CONTEXT_MENU_PRIORITY);
568
569    Some(overlay)
570}
571
572/// Fixed width of the filter popover, in pixels.
573const FILTER_PANEL_WIDTH: f32 = 300.0;
574/// Max number of distinct value rows rendered at once (search narrows the set).
575const FILTER_PANEL_MAX_ROWS: usize = 200;
576
577/// Build the Numbers-style per-column filter popover as a `deferred` +
578/// `anchored` overlay, using the exact mechanism as
579/// [`render_context_menu_overlay`] so it paints and receives events outside the
580/// grid's own layout bounds. Returns `None` when no panel is open.
581#[allow(clippy::too_many_lines)]
582fn render_filter_panel_overlay(
583    state: &Entity<GridState>,
584    cx: &mut Context<SqllyDataTable>,
585) -> Option<impl IntoElement> {
586    let s = state.read(cx);
587    let panel = s.filter_panel.clone()?;
588    let theme = s.theme.clone();
589    let col = panel.col;
590    let current_sort = s.sort;
591    let filter_active = s.filters.get(col).is_some_and(|f| f.is_active());
592    let grid_ox = f32::from(s.bounds.origin.x);
593    let grid_oy = f32::from(s.bounds.origin.y);
594
595    // Anchor (grid-relative) -> absolute window coords. The default
596    // `SwitchAnchor` fit mode on `anchored()` handles viewport-edge flipping
597    // automatically using the actual rendered height, so we don't need a
598    // manual estimate or flip calculation here.
599    let abs_x = grid_ox + f32::from(panel.anchor.x);
600    let abs_y = grid_oy + f32::from(panel.anchor.y);
601
602    // Palette (all `Hsla` are `Copy`, so they move freely into closures).
603    let c_bg = theme.menu_bg;
604    let c_line = theme.grid_line;
605    let c_fg = theme.menu_fg;
606    let c_accent = theme.sort_indicator;
607    let c_hover = theme.menu_hover_bg;
608    let c_muted = theme.muted_text;
609
610    let checkbox = move |checked: bool| {
611        let mut b = div()
612            .w(px(14.0))
613            .h(px(14.0))
614            .border_1()
615            .border_color(c_line)
616            .bg(c_bg)
617            .flex()
618            .items_center()
619            .justify_center();
620        if checked {
621            b = b.child(div().w(px(8.0)).h(px(8.0)).bg(c_accent));
622        }
623        b
624    };
625
626    // --- Sort row -----------------------------------------------------------
627    let (asc_active, desc_active) = match current_sort {
628        Some((c, SortDirection::Ascending)) if c == col => (true, false),
629        Some((c, SortDirection::Descending)) if c == col => (false, true),
630        _ => (false, false),
631    };
632    let st_asc = state.clone();
633    let st_desc = state.clone();
634    let sort_row = div()
635        .flex()
636        .gap(px(6.0))
637        .child(
638            div()
639                .flex_1()
640                .h(px(26.0))
641                .flex()
642                .items_center()
643                .justify_center()
644                .border_1()
645                .border_color(c_line)
646                .bg(if asc_active { c_accent } else { c_hover })
647                .cursor_pointer()
648                .child("Ascending")
649                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
650                    st_asc.update(cx, |s, cx| {
651                        s.set_panel_sort(SortDirection::Ascending);
652                        cx.notify();
653                    });
654                }),
655        )
656        .child(
657            div()
658                .flex_1()
659                .h(px(26.0))
660                .flex()
661                .items_center()
662                .justify_center()
663                .border_1()
664                .border_color(c_line)
665                .bg(if desc_active { c_accent } else { c_hover })
666                .cursor_pointer()
667                .child("Descending")
668                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
669                    st_desc.update(cx, |s, cx| {
670                        s.set_panel_sort(SortDirection::Descending);
671                        cx.notify();
672                    });
673                }),
674        );
675
676    // --- Operator dropdown --------------------------------------------------
677    let st_op_toggle = state.clone();
678    let op_button = div()
679        .h(px(26.0))
680        .px(px(8.0))
681        .flex()
682        .items_center()
683        .border_1()
684        .border_color(c_line)
685        .bg(c_bg)
686        .cursor_pointer()
687        .child(panel.current_op_label())
688        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
689            st_op_toggle.update(cx, |s, cx| {
690                s.toggle_filter_op_menu();
691                cx.notify();
692            });
693        });
694
695    let op_menu = panel.op_menu_open.then(|| {
696        let mut items: Vec<gpui::AnyElement> = Vec::new();
697        for (i, label) in panel.op_labels().iter().enumerate() {
698            let selected = i == panel.op_index;
699            let st_pick = state.clone();
700            items.push(
701                div()
702                    .h(px(24.0))
703                    .px(px(8.0))
704                    .flex()
705                    .items_center()
706                    .bg(if selected { c_accent } else { c_bg })
707                    .cursor_pointer()
708                    .child(*label)
709                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
710                        st_pick.update(cx, |s, cx| {
711                            s.set_filter_operator(i);
712                            cx.notify();
713                        });
714                    })
715                    .into_any_element(),
716            );
717        }
718        div()
719            .flex()
720            .flex_col()
721            .border_1()
722            .border_color(c_line)
723            .bg(c_bg)
724            .children(items)
725    });
726
727    // --- Operand field(s) ---------------------------------------------------
728    let operand_field = |value: &str, focused: bool, placeholder: &str, input: FilterInput| {
729        let st_focus = state.clone();
730        let (text, is_placeholder) = if value.is_empty() {
731            (placeholder.to_owned(), true)
732        } else {
733            (value.to_owned(), false)
734        };
735        div()
736            .h(px(26.0))
737            .px(px(6.0))
738            .flex()
739            .items_center()
740            .gap(px(2.0))
741            .border_1()
742            .border_color(if focused { c_accent } else { c_line })
743            .bg(c_bg)
744            .cursor_pointer()
745            .child(
746                div()
747                    .text_color(if is_placeholder { c_muted } else { c_fg })
748                    .child(text),
749            )
750            .children(focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
751            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
752                st_focus.update(cx, |s, cx| {
753                    s.set_filter_focus(input);
754                    cx.notify();
755                });
756            })
757    };
758
759    let operand_placeholder = if panel.kind == crate::data::ColumnKind::Date {
760        "YYYY-MM-DD"
761    } else if crate::filter::uses_number_ops(panel.kind) {
762        "value"
763    } else if panel.op_index == 7 {
764        // Text "matches" operator.
765        "regex"
766    } else {
767        "value"
768    };
769    let operands = panel.needs_operand().then(|| {
770        let mut row = div().flex().flex_col().gap(px(4.0)).child(operand_field(
771            &panel.operand_a.value,
772            panel.focus == FilterInput::OperandA,
773            operand_placeholder,
774            FilterInput::OperandA,
775        ));
776        if panel.needs_second_operand() {
777            row = row
778                .child(div().text_color(c_muted).text_size(px(11.0)).child("and"))
779                .child(operand_field(
780                    &panel.operand_b.value,
781                    panel.focus == FilterInput::OperandB,
782                    operand_placeholder,
783                    FilterInput::OperandB,
784                ));
785        }
786        row
787    });
788
789    // --- Search box ---------------------------------------------------------
790    let st_search = state.clone();
791    let search_focused = panel.focus == FilterInput::Search;
792    let (search_text, search_is_ph) = if panel.search.value.is_empty() {
793        ("Search".to_owned(), true)
794    } else {
795        (panel.search.value.clone(), false)
796    };
797    let search_box = div()
798        .h(px(26.0))
799        .px(px(6.0))
800        .flex()
801        .items_center()
802        .gap(px(2.0))
803        .border_1()
804        .border_color(if search_focused { c_accent } else { c_line })
805        .bg(c_bg)
806        .cursor_pointer()
807        .child(
808            div()
809                .text_color(if search_is_ph { c_muted } else { c_fg })
810                .child(search_text),
811        )
812        .children(search_focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
813        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
814            st_search.update(cx, |s, cx| {
815                s.set_filter_focus(FilterInput::Search);
816                cx.notify();
817            });
818        });
819
820    // --- (Select All) + value checklist ------------------------------------
821    let st_all = state.clone();
822    let select_all_row = div()
823        .h(px(24.0))
824        .flex()
825        .items_center()
826        .gap(px(6.0))
827        .cursor_pointer()
828        .child(checkbox(panel.all_checked()))
829        .child("(Select All)")
830        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
831            st_all.update(cx, |s, cx| {
832                s.toggle_filter_select_all();
833                cx.notify();
834            });
835        });
836
837    let visible = panel.visible_indices();
838    let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
839    for &idx in visible.iter().take(FILTER_PANEL_MAX_ROWS) {
840        let row = &panel.distinct[idx];
841        let st_val = state.clone();
842        value_rows.push(
843            div()
844                .h(px(22.0))
845                .flex()
846                .items_center()
847                .gap(px(6.0))
848                .cursor_pointer()
849                .child(checkbox(row.checked))
850                .child(div().text_color(c_fg).child(row.label.clone()))
851                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
852                    st_val.update(cx, |s, cx| {
853                        s.toggle_filter_value(idx);
854                        cx.notify();
855                    });
856                })
857                .into_any_element(),
858        );
859    }
860    let truncated = visible.len() > FILTER_PANEL_MAX_ROWS;
861    let value_list = div()
862        .id("filter-value-list")
863        .flex()
864        .flex_col()
865        .max_h(px(180.0))
866        .overflow_y_scroll()
867        .children(value_rows)
868        .children(truncated.then(|| {
869            div()
870                .text_color(c_muted)
871                .text_size(px(11.0))
872                .child("Refine search to see more…")
873        }));
874
875    // --- Clear (left, disabled when no active filter) + Close (right) -----
876    let st_clear = state.clone();
877    let st_close = state.clone();
878    let clear_bg = if filter_active { c_hover } else { c_bg };
879    let clear_fg = if filter_active { c_fg } else { c_muted };
880    let clear_border = if filter_active { c_line } else { c_muted };
881    let buttons_row = div()
882        .flex()
883        .gap(px(6.0))
884        .child(
885            div()
886                .flex_1()
887                .h(px(28.0))
888                .flex()
889                .items_center()
890                .justify_center()
891                .border_1()
892                .border_color(clear_border)
893                .bg(clear_bg)
894                .text_color(clear_fg)
895                .cursor_pointer()
896                .child("Clear Filter")
897                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
898                    if !filter_active {
899                        return;
900                    }
901                    st_clear.update(cx, |s, cx| {
902                        s.clear_filter_panel();
903                        cx.notify();
904                    });
905                }),
906        )
907        .child(
908            div()
909                .flex_1()
910                .h(px(28.0))
911                .flex()
912                .items_center()
913                .justify_center()
914                .border_1()
915                .border_color(c_line)
916                .bg(c_hover)
917                .cursor_pointer()
918                .child("Close")
919                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
920                    st_close.update(cx, |s, cx| {
921                        s.filter_panel = None;
922                        cx.notify();
923                    });
924                }),
925        );
926
927    let panel_body = div()
928        .flex()
929        .flex_col()
930        .w(px(FILTER_PANEL_WIDTH))
931        .p(px(10.0))
932        .gap(px(8.0))
933        .bg(c_bg)
934        .border_1()
935        .border_color(c_line)
936        .text_color(c_fg)
937        .text_size(px(13.0))
938        .child(div().text_color(c_muted).text_size(px(11.0)).child("Sort"))
939        .child(sort_row)
940        .child(
941            div()
942                .text_color(c_muted)
943                .text_size(px(11.0))
944                .child("Filter"),
945        )
946        .child(op_button)
947        .children(op_menu)
948        .children(operands)
949        .child(search_box)
950        .child(select_all_row)
951        .child(value_list)
952        .child(buttons_row);
953
954    let st_backdrop = state.clone();
955    let overlay = deferred(
956        anchored()
957            .anchor(Corner::BottomLeft)
958            .position(point(px(abs_x), px(abs_y)))
959            .child(div().occlude().child(panel_body).on_mouse_down_out(
960                move |_e: &MouseDownEvent, _window, cx| {
961                    st_backdrop.update(cx, |s, cx| {
962                        if s.filter_panel.is_some() {
963                            s.filter_panel = None;
964                            cx.notify();
965                        }
966                    });
967                },
968            )),
969    )
970    .with_priority(CONTEXT_MENU_PRIORITY);
971
972    Some(overlay)
973}
974
975/// Renders the loading overlay while a background task runs. Returns `None`
976/// when the grid is not busy. Painted as an absolute, input-occluding scrim
977/// over the whole grid area with a centered card showing the task label and a
978/// progress bar (determinate when progress is known, otherwise an animated
979/// indeterminate bar).
980fn render_busy_overlay(
981    state: &Entity<GridState>,
982    cx: &mut Context<SqllyDataTable>,
983) -> Option<impl IntoElement> {
984    let s = state.read(cx);
985    let busy = s.busy.clone()?;
986    let theme = s.theme.clone();
987    let track = theme.grid_line;
988    let accent = theme.sort_indicator;
989
990    let bar: gpui::AnyElement = if let Some(p) = busy.progress {
991        let p = p.clamp(0.0, 1.0);
992        div()
993            .h_full()
994            .w(relative(p))
995            .rounded(px(3.0))
996            .bg(accent)
997            .into_any_element()
998    } else {
999        div()
1000            .h_full()
1001            .w(relative(0.3))
1002            .rounded(px(3.0))
1003            .bg(accent)
1004            .with_animation(
1005                "busy-indeterminate",
1006                Animation::new(std::time::Duration::from_millis(900))
1007                    .repeat()
1008                    .with_easing(pulsating_between(0.15, 0.85)),
1009                |el, delta| el.w(relative(delta)),
1010            )
1011            .into_any_element()
1012    };
1013
1014    let card = div()
1015        .flex()
1016        .flex_col()
1017        .gap(px(10.0))
1018        .p(px(16.0))
1019        .min_w(px(220.0))
1020        .rounded(px(8.0))
1021        .bg(theme.menu_bg)
1022        .border_1()
1023        .border_color(theme.grid_line)
1024        .child(
1025            div()
1026                .text_color(theme.menu_fg)
1027                .text_size(px(14.0))
1028                .child(busy.label.clone()),
1029        )
1030        .child(
1031            div()
1032                .w_full()
1033                .h(px(6.0))
1034                .rounded(px(3.0))
1035                .bg(track)
1036                .child(bar),
1037        );
1038
1039    let overlay = div()
1040        .absolute()
1041        .top_0()
1042        .left_0()
1043        .size_full()
1044        .occlude()
1045        .flex()
1046        .items_center()
1047        .justify_center()
1048        .bg(hsla(0.0, 0.0, 0.0, 0.35))
1049        .child(card);
1050
1051    Some(overlay)
1052}
1053
1054/// What a menu row dispatches when clicked. Captured per-row so the click
1055/// handler owns its data without borrowing the menu snapshot.
1056enum MenuDispatch {
1057    Builtin(menu::MenuAction, usize),
1058    Custom(
1059        String,
1060        Option<crate::grid::context_menu::ContextMenuRequest>,
1061    ),
1062}