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