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::{GridState, EDGE_SCROLL_TICK_MS};
14use crate::grid::theme::GridTheme;
15use crate::grid::{menu, HitResult, MenuItem};
16
17use gpui::{
18    anchored, canvas, deferred, div, point, px, App, AppContext, Context, Entity, FocusHandle,
19    Focusable, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
20    MouseMoveEvent, MouseUpEvent, ParentElement, Render, ScrollWheelEvent, Styled, Window,
21};
22
23/// Draw order for the context-menu overlay. Deliberately far above any
24/// ordinary application UI so the menu — and, crucially, its event hitbox —
25/// sits on top of everything, even content painted outside the grid widget's
26/// own layout bounds (e.g. a host header above the grid). Deferred draws
27/// register their hitbox in a later pass, so this also fixes hover/click
28/// routing for menu items that visually overflow the grid area.
29const CONTEXT_MENU_PRIORITY: usize = 1_000_000;
30
31/// Top-level GPUI widget.
32pub struct SqllyDataTable {
33    pub state: Entity<GridState>,
34    /// When `true`, the grid swaps between the built-in light/dark
35    /// [`GridTheme`] palettes to follow the OS window appearance. Disabled
36    /// automatically when the caller supplies an explicit theme override.
37    follow_system_appearance: bool,
38    /// Retained appearance-observer subscription. Registered lazily on the
39    /// first render (that is where a `Window` is available); dropping it would
40    /// unregister the observer, so it is stored for the widget's lifetime.
41    appearance_subscription: Option<gpui::Subscription>,
42}
43
44impl SqllyDataTable {
45    /// Wrap an existing `Entity<GridState>`.
46    #[must_use]
47    pub fn new(state: Entity<GridState>) -> Self {
48        Self {
49            state,
50            follow_system_appearance: true,
51            appearance_subscription: None,
52        }
53    }
54
55    /// Construct from `GridData` using the default [`GridConfig`].
56    #[must_use]
57    pub fn builder(data: GridData) -> SqllyDataTableBuilder {
58        SqllyDataTableBuilder {
59            data,
60            config: GridConfig::default(),
61            context_menu_provider: None,
62            theme: None,
63        }
64    }
65}
66
67/// Builder for `SqllyDataTable`.
68pub struct SqllyDataTableBuilder {
69    data: GridData,
70    config: GridConfig,
71    context_menu_provider: Option<ContextMenuProviderHandle>,
72    theme: Option<GridTheme>,
73}
74
75impl SqllyDataTableBuilder {
76    /// Override the entire [`GridConfig`].
77    #[must_use]
78    pub fn config(mut self, config: GridConfig) -> Self {
79        self.config = config;
80        self
81    }
82
83    /// Override the [`GridTheme`]. Supplying an explicit theme opts out of the
84    /// automatic OS light/dark following; the grid uses exactly this theme.
85    #[must_use]
86    pub fn theme(mut self, theme: GridTheme) -> Self {
87        self.theme = Some(theme);
88        self
89    }
90
91    /// Register a custom right-click menu provider. When registered, the
92    /// provider fully controls the right-click menu for all targets (cells,
93    /// row headers, column headers). The built-in column-header menu is
94    /// suppressed; use
95    /// [`crate::grid::context_menu::ContextMenuItem::standard_column_header_items`]
96    /// to compose built-in actions.
97    #[must_use]
98    pub fn context_menu_provider(mut self, provider: impl ContextMenuProvider + 'static) -> Self {
99        self.context_menu_provider = Some(ContextMenuProviderHandle::new(provider));
100        self
101    }
102
103    /// Build the widget inside the supplied [`gpui::App`].
104    pub fn build(self, cx: &mut App) -> SqllyDataTable {
105        let focus = cx.focus_handle();
106        let provider = self.context_menu_provider;
107        let theme_override = self.theme;
108        let follow_system_appearance = theme_override.is_none();
109        let state = cx.new(|_cx| {
110            let mut s = GridState::new(self.data, self.config, focus.clone());
111            s.context_menu_provider = provider;
112            if let Some(theme) = theme_override {
113                s.theme = theme;
114            }
115            s
116        });
117        SqllyDataTable {
118            state,
119            follow_system_appearance,
120            appearance_subscription: None,
121        }
122    }
123}
124
125impl Focusable for SqllyDataTable {
126    fn focus_handle(&self, cx: &App) -> FocusHandle {
127        self.state.read(cx).focus_handle.clone()
128    }
129}
130
131impl Render for SqllyDataTable {
132    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
133        // Follow the OS light/dark appearance: set the initial theme from the
134        // current window appearance and register a one-time observer that
135        // swaps the grid theme whenever the system appearance changes. Skipped
136        // when the caller supplied an explicit theme override.
137        if self.follow_system_appearance && self.appearance_subscription.is_none() {
138            let initial = GridTheme::for_appearance(window.appearance());
139            self.state.update(cx, |s, _cx| s.theme = initial);
140            let state_appearance = self.state.clone();
141            self.appearance_subscription =
142                Some(window.observe_window_appearance(move |window, cx| {
143                    let theme = GridTheme::for_appearance(window.appearance());
144                    state_appearance.update(cx, |s, cx| {
145                        s.theme = theme;
146                        cx.notify();
147                    });
148                }));
149        }
150
151        let state_canvas = self.state.clone();
152        let state_status = self.state.clone();
153        let state_mouse = self.state.clone();
154        let state_move = self.state.clone();
155        let state_up = self.state.clone();
156        let state_scroll = self.state.clone();
157        let state_key = self.state.clone();
158        let state_right = self.state.clone();
159        let bg = self.state.read(cx).theme.bg;
160        let focus_handle = self.state.read(cx).focus_handle.clone();
161        let focus_left = focus_handle.clone();
162        let focus_right = focus_handle.clone();
163        let status_h = self.state.read(cx).status_bar_height;
164
165        // Process any pending menu action from a previous mouse-down on a
166        // menu item (needs App access for clipboard).
167        if let Some((action, col)) = self.state.read(cx).pending_action {
168            self.state.update(cx, |s, cx| {
169                s.execute_action(action, col, cx);
170                s.pending_action = None;
171            });
172        }
173
174        // Process any pending custom context-menu action.
175        if let Some(pending) = self
176            .state
177            .read(cx)
178            .pending_custom_context_menu_action
179            .clone()
180        {
181            self.state.update(cx, |s, cx| {
182                s.pending_custom_context_menu_action = None;
183                s.execute_custom_context_menu_action(pending, cx);
184            });
185        }
186
187        // Spawn an edge-scroll timer **only while a drag is in progress**, and
188        // **only one at a time**. Without the `edge_scroll_active` guard,
189        // `render` would spawn a fresh 16 ms loop on every frame/notify during
190        // a drag — each successful tick calls `cx.notify()`, which re-renders
191        // and spawned yet another task, stacking concurrent loops that each
192        // apply a scroll delta per tick and multiply the effective speed
193        // without bound. The task clears the flag when it exits.
194        if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
195            self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
196            let state_edge = self.state.clone();
197            cx.spawn(async move |_weak, cx| {
198                loop {
199                    gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
200                    let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
201                    if let Ok(true) = res {
202                        let _ = state_edge.update(cx, |_s, cx| cx.notify());
203                    }
204                    let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
205                    if !matches!(dragging_res, Ok(true)) {
206                        break;
207                    }
208                }
209                let _ =
210                    cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
211            })
212            .detach();
213        }
214
215        div()
216            .flex()
217            .flex_col()
218            .size_full()
219            .track_focus(&focus_handle)
220            .bg(bg)
221            .child(
222                canvas(
223                    move |bounds, window, cx| -> PaintData {
224                        let viewport = window.viewport_size();
225                        state_canvas.update(cx, |s, cx| {
226                            let mut dirty = false;
227                            if s.bounds != bounds {
228                                s.bounds = bounds;
229                                dirty = true;
230                            }
231                            if s.window_viewport != viewport {
232                                s.window_viewport = viewport;
233                            }
234                            if dirty {
235                                cx.notify();
236                            }
237                        });
238                        let s = state_canvas.read(cx);
239                        PaintData::from_state(s)
240                    },
241                    move |bounds, data, window, cx| {
242                        paint_grid(&data, window, cx, bounds);
243                    },
244                )
245                .flex_1(),
246            )
247            .child(
248                canvas(
249                    move |_bounds, _window, cx| -> StatusBarData {
250                        let s = state_status.read(cx);
251                        StatusBarData::from_state(s)
252                    },
253                    move |bounds, data, window, cx| {
254                        paint_status_bar(&data, window, cx, bounds);
255                    },
256                )
257                .h(px(status_h)),
258            )
259            .children(render_context_menu_overlay(&self.state, cx))
260            .on_mouse_down(
261                MouseButton::Left,
262                move |event: &MouseDownEvent, window, cx| {
263                    window.focus(&focus_left);
264                    state_mouse.update(cx, |s, cx| {
265                        // Normalize the absolute window pointer into the grid's
266                        // own frame. Menu hit-testing is handled by the deferred
267                        // overlay's own item handlers, so a left-click that
268                        // reaches the grid means the pointer was NOT on the menu;
269                        // dismiss any open menu and proceed with grid selection.
270                        let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
271                        if s.context_menu.is_some() {
272                            s.context_menu = None;
273                            s.filter_prompt = None;
274                        }
275                        s.handle_mouse_down(rel, event.modifiers.shift);
276                        cx.notify();
277                    });
278                },
279            )
280            .on_mouse_down(
281                MouseButton::Right,
282                move |event: &MouseDownEvent, window, cx| {
283                    window.focus(&focus_right);
284                    state_right.update(cx, |s, cx| {
285                        let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
286                        let hit = s.hit_test(pos);
287
288                        // No provider — existing built-in behavior.
289                        if s.context_menu_provider.is_none() {
290                            match hit {
291                                HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
292                                    s.open_context_menu(col, pos);
293                                }
294                                _ => {
295                                    s.context_menu = None;
296                                    s.filter_prompt = None;
297                                }
298                            }
299                            cx.notify();
300                            return;
301                        }
302
303                        // Provider exists — build custom menu.
304                        let Some(target) = s.context_menu_target_from_hit(hit) else {
305                            s.context_menu = None;
306                            s.filter_prompt = None;
307                            cx.notify();
308                            return;
309                        };
310
311                        let effective = s.effective_selection_for_context_target(&target);
312                        if effective != s.selection {
313                            s.selection = effective.clone();
314                        }
315
316                        let request = s.build_context_menu_request(target, &effective);
317                        let col = request.target.column_index().unwrap_or(0);
318
319                        let Some(provider) = s.context_menu_provider.clone() else {
320                            return;
321                        };
322                        let public_items = provider.menu_items(&request);
323                        let items = GridState::convert_context_menu_items(public_items);
324
325                        if items.is_empty() {
326                            s.context_menu = None;
327                        } else {
328                            s.context_menu =
329                                Some(menu::ContextMenu::custom(col, pos, items, request));
330                        }
331                        s.filter_prompt = None;
332                        cx.notify();
333                    });
334                },
335            )
336            .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
337                state_move.update(cx, |s, cx| {
338                    let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
339                    s.handle_mouse_move(rel, event.pressed_button);
340                    cx.notify();
341                });
342            })
343            .on_mouse_up(
344                MouseButton::Left,
345                move |_event: &MouseUpEvent, _window, cx| {
346                    state_up.update(cx, |s, cx| {
347                        s.handle_mouse_up();
348                        cx.notify();
349                    });
350                },
351            )
352            .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
353                state_scroll.update(cx, |s, cx| {
354                    let line_h = px(s.row_height);
355                    let delta = event.delta.pixel_delta(line_h);
356                    let scroll = s.scroll_handle.offset();
357                    let (mx, my) = s.max_scroll();
358                    let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
359                    let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
360                    s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
361                    if s.drag_start.is_some() {
362                        s.handle_scroll_drag();
363                    }
364                    cx.notify();
365                });
366            })
367            .on_key_down(move |event: &KeyDownEvent, _window, cx| {
368                let ks = &event.keystroke;
369                if ks.modifiers.platform && ks.key == "q" {
370                    cx.quit();
371                    return;
372                }
373                state_key.update(cx, |s, cx| {
374                    let kb = &s.config.key_bindings;
375                    if kb.select_all.matches(ks) {
376                        s.select_all();
377                    } else if kb.copy.matches(ks) {
378                        s.copy_selection(false, cx);
379                    } else if kb.copy_with_headers.matches(ks) {
380                        s.copy_selection(true, cx);
381                    } else if kb.page_up.matches(ks) {
382                        s.page_up();
383                    } else if kb.page_down.matches(ks) {
384                        s.page_down();
385                    } else {
386                        s.handle_key(ks);
387                    }
388                    cx.notify();
389                });
390            })
391    }
392}
393
394/// Build the context-menu overlay as a `deferred` + `anchored` element so it
395/// paints — and receives mouse events — on top of everything, including
396/// regions outside the grid widget's own layout bounds. Returns `None` when no
397/// menu is open.
398///
399/// Positioning reuses [`menu::ContextMenu::resolved_position`] (window-viewport
400/// aware: flips up when there's no room below, shifts left at the right edge),
401/// then converts to absolute window coordinates for `anchored().position(..)`.
402/// Each selectable row carries its own `on_mouse_down` (dispatch) and
403/// `on_mouse_move` (hover highlight) handlers; a full-screen backdrop behind
404/// the menu dismisses it on any outside click.
405fn render_context_menu_overlay(
406    state: &Entity<GridState>,
407    cx: &mut Context<SqllyDataTable>,
408) -> Option<impl IntoElement> {
409    let s = state.read(cx);
410    let menu = s.context_menu.clone()?;
411    let theme = s.theme.clone();
412    let cw = s.char_width;
413    let grid_ox = f32::from(s.bounds.origin.x);
414    let grid_oy = f32::from(s.bounds.origin.y);
415    let viewport = s.window_viewport;
416    let vw = f32::from(viewport.width);
417    let vh = f32::from(viewport.height);
418
419    let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
420    let abs_x = grid_ox + f32::from(resolved.x);
421    let abs_y = grid_oy + f32::from(resolved.y);
422    let menu_w = menu.width_for(cw);
423
424    // Build one row per item. `selectable_idx` counts only Action/Custom items
425    // so it matches the `hovered` index convention used elsewhere.
426    let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
427    let mut selectable_idx = 0usize;
428    for item in &menu.items {
429        match item {
430            MenuItem::Separator => {
431                rows.push(
432                    div()
433                        .h(px(menu::MENU_ITEM_HEIGHT))
434                        .flex()
435                        .items_center()
436                        .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
437                        .into_any_element(),
438                );
439            }
440            MenuItem::Action(_) | MenuItem::Custom { .. } => {
441                let this_idx = selectable_idx;
442                selectable_idx += 1;
443                let label = item.label().unwrap_or("").to_owned();
444                let hovered = menu.hovered == Some(this_idx);
445
446                // Dispatch: set the pending action and close the menu. The
447                // pending fields are drained at the top of `render` (they need
448                // App access for clipboard).
449                let action = match item {
450                    MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
451                    MenuItem::Custom { id, .. } => {
452                        MenuDispatch::Custom(id.clone(), menu.request.clone())
453                    }
454                    MenuItem::Separator => unreachable!(),
455                };
456
457                let state_click = state.clone();
458                let state_hover = state.clone();
459                let mut row = div()
460                    .h(px(menu::MENU_ITEM_HEIGHT))
461                    .px(px(menu::MENU_PADDING_X))
462                    .flex()
463                    .items_center()
464                    .text_color(theme.menu_fg)
465                    .text_size(px(menu::MENU_FONT_SIZE))
466                    .child(label)
467                    .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
468                        state_hover.update(cx, |s, cx| {
469                            if let Some(m) = s.context_menu.as_mut() {
470                                if m.hovered != Some(this_idx) {
471                                    m.hovered = Some(this_idx);
472                                    cx.notify();
473                                }
474                            }
475                        });
476                    })
477                    .on_mouse_down(
478                        MouseButton::Left,
479                        move |_e: &MouseDownEvent, _window, cx| {
480                            state_click.update(cx, |s, cx| {
481                                match &action {
482                                    MenuDispatch::Builtin(a, col) => {
483                                        s.pending_action = Some((*a, *col));
484                                    }
485                                    MenuDispatch::Custom(id, request) => {
486                                        if let Some(request) = request {
487                                            s.pending_custom_context_menu_action =
488                                                Some(PendingCustomContextMenuAction {
489                                                    id: id.clone(),
490                                                    request: request.clone(),
491                                                });
492                                        }
493                                    }
494                                }
495                                s.context_menu = None;
496                                cx.notify();
497                            });
498                        },
499                    );
500                if hovered {
501                    row = row.bg(theme.menu_hover_bg);
502                }
503                rows.push(row.into_any_element());
504            }
505        }
506    }
507
508    let menu_body = div()
509        .absolute()
510        .flex()
511        .flex_col()
512        .w(px(menu_w))
513        .py(px(menu::MENU_INNER_PAD))
514        .bg(theme.menu_bg)
515        .border_1()
516        .border_color(theme.grid_line)
517        .children(rows);
518
519    // Full-window transparent backdrop: catches clicks outside the menu to
520    // dismiss it. Placed behind the menu within the same anchored overlay.
521    let state_backdrop = state.clone();
522    let overlay = deferred(anchored().position(point(px(abs_x), px(abs_y))).child(
523        div().occlude().child(menu_body).on_mouse_down_out(
524            move |_e: &MouseDownEvent, _window, cx| {
525                state_backdrop.update(cx, |s, cx| {
526                    if s.context_menu.is_some() {
527                        s.context_menu = None;
528                        s.filter_prompt = None;
529                        cx.notify();
530                    }
531                });
532            },
533        ),
534    ))
535    .with_priority(CONTEXT_MENU_PRIORITY);
536
537    Some(overlay)
538}
539
540/// What a menu row dispatches when clicked. Captured per-row so the click
541/// handler owns its data without borrowing the menu snapshot.
542enum MenuDispatch {
543    Builtin(menu::MenuAction, usize),
544    Custom(
545        String,
546        Option<crate::grid::context_menu::ContextMenuRequest>,
547    ),
548}