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