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