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