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    canvas, div, point, px, App, AppContext, Context, Entity, FocusHandle, Focusable,
19    InteractiveElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
20    ParentElement, Render, ScrollWheelEvent, Styled, Window,
21};
22
23/// Top-level GPUI widget.
24pub struct SqllyDataTable {
25    pub state: Entity<GridState>,
26}
27
28impl SqllyDataTable {
29    /// Wrap an existing `Entity<GridState>`.
30    #[must_use]
31    pub fn new(state: Entity<GridState>) -> Self {
32        Self { state }
33    }
34
35    /// Construct from `GridData` using the default [`GridConfig`].
36    #[must_use]
37    pub fn builder(data: GridData) -> SqllyDataTableBuilder {
38        SqllyDataTableBuilder {
39            data,
40            config: GridConfig::default(),
41            context_menu_provider: None,
42        }
43    }
44}
45
46/// Builder for `SqllyDataTable`.
47pub struct SqllyDataTableBuilder {
48    data: GridData,
49    config: GridConfig,
50    context_menu_provider: Option<ContextMenuProviderHandle>,
51}
52
53impl SqllyDataTableBuilder {
54    /// Override the entire [`GridConfig`].
55    #[must_use]
56    pub fn config(mut self, config: GridConfig) -> Self {
57        self.config = config;
58        self
59    }
60
61    /// Override only the [`GridTheme`]. No-op for now; kept for symmetry.
62    #[must_use]
63    pub fn theme(self, theme: GridTheme) -> Self {
64        let _ = theme;
65        self
66    }
67
68    /// Register a custom right-click menu provider. When registered, the
69    /// provider fully controls the right-click menu for all targets (cells,
70    /// row headers, column headers). The built-in column-header menu is
71    /// suppressed; use
72    /// [`crate::grid::context_menu::ContextMenuItem::standard_column_header_items`]
73    /// to compose built-in actions.
74    #[must_use]
75    pub fn context_menu_provider(mut self, provider: impl ContextMenuProvider + 'static) -> Self {
76        self.context_menu_provider = Some(ContextMenuProviderHandle::new(provider));
77        self
78    }
79
80    /// Build the widget inside the supplied [`gpui::App`].
81    pub fn build(self, cx: &mut App) -> SqllyDataTable {
82        let focus = cx.focus_handle();
83        let provider = self.context_menu_provider;
84        let state = cx.new(|_cx| {
85            let mut s = GridState::new(self.data, self.config, focus.clone());
86            s.context_menu_provider = provider;
87            s
88        });
89        SqllyDataTable { state }
90    }
91}
92
93impl Focusable for SqllyDataTable {
94    fn focus_handle(&self, cx: &App) -> FocusHandle {
95        self.state.read(cx).focus_handle.clone()
96    }
97}
98
99impl Render for SqllyDataTable {
100    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
101        let state_canvas = self.state.clone();
102        let state_status = self.state.clone();
103        let state_mouse = self.state.clone();
104        let state_move = self.state.clone();
105        let state_up = self.state.clone();
106        let state_scroll = self.state.clone();
107        let state_key = self.state.clone();
108        let state_right = self.state.clone();
109        let bg = self.state.read(cx).theme.bg;
110        let focus_handle = self.state.read(cx).focus_handle.clone();
111        let focus_left = focus_handle.clone();
112        let focus_right = focus_handle.clone();
113        let status_h = self.state.read(cx).status_bar_height;
114
115        // Process any pending menu action from a previous mouse-down on a
116        // menu item (needs App access for clipboard).
117        if let Some((action, col)) = self.state.read(cx).pending_action {
118            self.state.update(cx, |s, cx| {
119                s.execute_action(action, col, cx);
120                s.pending_action = None;
121            });
122        }
123
124        // Process any pending custom context-menu action.
125        if let Some(pending) = self
126            .state
127            .read(cx)
128            .pending_custom_context_menu_action
129            .clone()
130        {
131            self.state.update(cx, |s, cx| {
132                s.pending_custom_context_menu_action = None;
133                s.execute_custom_context_menu_action(pending, cx);
134            });
135        }
136
137        // Spawn an edge-scroll timer **only while a drag is in progress**, and
138        // **only one at a time**. Without the `edge_scroll_active` guard,
139        // `render` would spawn a fresh 16 ms loop on every frame/notify during
140        // a drag — each successful tick calls `cx.notify()`, which re-renders
141        // and spawned yet another task, stacking concurrent loops that each
142        // apply a scroll delta per tick and multiply the effective speed
143        // without bound. The task clears the flag when it exits.
144        if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
145            self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
146            let state_edge = self.state.clone();
147            cx.spawn(async move |_weak, cx| {
148                loop {
149                    gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
150                    let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
151                    if let Ok(true) = res {
152                        let _ = state_edge.update(cx, |_s, cx| cx.notify());
153                    }
154                    let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
155                    if !matches!(dragging_res, Ok(true)) {
156                        break;
157                    }
158                }
159                let _ =
160                    cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
161            })
162            .detach();
163        }
164
165        div()
166            .flex()
167            .flex_col()
168            .size_full()
169            .track_focus(&focus_handle)
170            .bg(bg)
171            .child(
172                canvas(
173                    move |bounds, window, cx| -> PaintData {
174                        let viewport = window.viewport_size();
175                        state_canvas.update(cx, |s, cx| {
176                            let mut dirty = false;
177                            if s.bounds != bounds {
178                                s.bounds = bounds;
179                                dirty = true;
180                            }
181                            if s.window_viewport != viewport {
182                                s.window_viewport = viewport;
183                            }
184                            if dirty {
185                                cx.notify();
186                            }
187                        });
188                        let s = state_canvas.read(cx);
189                        PaintData::from_state(s)
190                    },
191                    move |bounds, data, window, cx| {
192                        paint_grid(&data, window, cx, bounds);
193                    },
194                )
195                .flex_1(),
196            )
197            .child(
198                canvas(
199                    move |_bounds, _window, cx| -> StatusBarData {
200                        let s = state_status.read(cx);
201                        StatusBarData::from_state(s)
202                    },
203                    move |bounds, data, window, cx| {
204                        paint_status_bar(&data, window, cx, bounds);
205                    },
206                )
207                .h(px(status_h)),
208            )
209            .on_mouse_down(
210                MouseButton::Left,
211                move |event: &MouseDownEvent, window, cx| {
212                    window.focus(&focus_left);
213                    state_mouse.update(cx, |s, cx| {
214                        // Normalize the absolute window pointer into the grid's
215                        // own frame once, up front. Everything downstream —
216                        // menu hit-testing and `handle_mouse_down` — works in
217                        // grid-relative coordinates.
218                        let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
219                        if let Some(menu) = s.context_menu.clone() {
220                            let cw = s.char_width;
221                            // Resolve the menu's on-screen position exactly as
222                            // paint does (window viewport, flip up / shift left)
223                            // so hit-testing matches what the user sees.
224                            let grid_ox = f32::from(s.bounds.origin.x);
225                            let grid_oy = f32::from(s.bounds.origin.y);
226                            let viewport = window.viewport_size();
227                            let vw = f32::from(viewport.width);
228                            let vh = f32::from(viewport.height);
229                            let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
230                            let mx_rel = f32::from(rel.x);
231                            let my_rel = f32::from(rel.y);
232                            let w = menu.width_for(cw);
233                            let total_h = menu.total_height();
234                            let ax = f32::from(resolved.x);
235                            let ay = f32::from(resolved.y);
236                            if mx_rel >= ax
237                                && mx_rel <= ax + w
238                                && my_rel >= ay
239                                && my_rel <= ay + total_h
240                            {
241                                if let Some(action_idx) =
242                                    menu::hover_at_anchor(&menu, resolved, mx_rel, my_rel, cw)
243                                {
244                                    let mut cur = 0;
245                                    for item in &menu.items {
246                                        match item {
247                                            MenuItem::Action(a) => {
248                                                if cur == action_idx {
249                                                    s.pending_action = Some((*a, menu.col));
250                                                    s.context_menu = None;
251                                                    cx.notify();
252                                                    return;
253                                                }
254                                                cur += 1;
255                                            }
256                                            MenuItem::Custom { id, .. } => {
257                                                if cur == action_idx {
258                                                    if let Some(request) = &menu.request {
259                                                        s.pending_custom_context_menu_action =
260                                                            Some(PendingCustomContextMenuAction {
261                                                                id: id.clone(),
262                                                                request: request.clone(),
263                                                            });
264                                                    }
265                                                    s.context_menu = None;
266                                                    cx.notify();
267                                                    return;
268                                                }
269                                                cur += 1;
270                                            }
271                                            MenuItem::Separator => {}
272                                        }
273                                    }
274                                }
275                            } else {
276                                s.context_menu = None;
277                                s.filter_prompt = None;
278                            }
279                        }
280                        s.handle_mouse_down(rel, event.modifiers.shift);
281                        cx.notify();
282                    });
283                },
284            )
285            .on_mouse_down(
286                MouseButton::Right,
287                move |event: &MouseDownEvent, window, cx| {
288                    window.focus(&focus_right);
289                    state_right.update(cx, |s, cx| {
290                        let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
291                        let hit = s.hit_test(pos);
292
293                        // No provider — existing built-in behavior.
294                        if s.context_menu_provider.is_none() {
295                            match hit {
296                                HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
297                                    s.open_context_menu(col, pos);
298                                }
299                                _ => {
300                                    s.context_menu = None;
301                                    s.filter_prompt = None;
302                                }
303                            }
304                            cx.notify();
305                            return;
306                        }
307
308                        // Provider exists — build custom menu.
309                        let Some(target) = s.context_menu_target_from_hit(hit) else {
310                            s.context_menu = None;
311                            s.filter_prompt = None;
312                            cx.notify();
313                            return;
314                        };
315
316                        let effective = s.effective_selection_for_context_target(&target);
317                        if effective != s.selection {
318                            s.selection = effective.clone();
319                        }
320
321                        let request = s.build_context_menu_request(target, &effective);
322                        let col = request.target.column_index().unwrap_or(0);
323
324                        let Some(provider) = s.context_menu_provider.clone() else {
325                            return;
326                        };
327                        let public_items = provider.menu_items(&request);
328                        let items = GridState::convert_context_menu_items(public_items);
329
330                        if items.is_empty() {
331                            s.context_menu = None;
332                        } else {
333                            s.context_menu =
334                                Some(menu::ContextMenu::custom(col, pos, items, request));
335                        }
336                        s.filter_prompt = None;
337                        cx.notify();
338                    });
339                },
340            )
341            .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
342                state_move.update(cx, |s, cx| {
343                    let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
344                    s.handle_mouse_move(rel, event.pressed_button);
345                    cx.notify();
346                });
347            })
348            .on_mouse_up(
349                MouseButton::Left,
350                move |_event: &MouseUpEvent, _window, cx| {
351                    state_up.update(cx, |s, cx| {
352                        s.handle_mouse_up();
353                        cx.notify();
354                    });
355                },
356            )
357            .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
358                state_scroll.update(cx, |s, cx| {
359                    let line_h = px(s.row_height);
360                    let delta = event.delta.pixel_delta(line_h);
361                    let scroll = s.scroll_handle.offset();
362                    let (mx, my) = s.max_scroll();
363                    let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
364                    let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
365                    s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
366                    if s.drag_start.is_some() {
367                        s.handle_scroll_drag();
368                    }
369                    cx.notify();
370                });
371            })
372            .on_key_down(move |event: &KeyDownEvent, _window, cx| {
373                let ks = &event.keystroke;
374                if ks.modifiers.platform && ks.key == "q" {
375                    cx.quit();
376                    return;
377                }
378                state_key.update(cx, |s, cx| {
379                    let kb = &s.config.key_bindings;
380                    if kb.select_all.matches(ks) {
381                        s.select_all();
382                    } else if kb.copy.matches(ks) {
383                        s.copy_selection(false, cx);
384                    } else if kb.copy_with_headers.matches(ks) {
385                        s.copy_selection(true, cx);
386                    } else if kb.page_up.matches(ks) {
387                        s.page_up();
388                    } else if kb.page_down.matches(ks) {
389                        s.page_down();
390                    } else {
391                        s.handle_key(ks);
392                    }
393                    cx.notify();
394                });
395            })
396    }
397}