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**.
138        // The task self-detaches when `wants_edge_scroll_tick` is false so it
139        // is no longer a 60 fps loop.
140        if self.state.read(cx).is_dragging {
141            let state_edge = self.state.clone();
142            cx.spawn(async move |_weak, cx| loop {
143                gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
144                let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
145                if let Ok(true) = res {
146                    let _ = state_edge.update(cx, |_s, cx| cx.notify());
147                }
148                let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
149                if !matches!(dragging_res, Ok(true)) {
150                    break;
151                }
152            })
153            .detach();
154        }
155
156        div()
157            .flex()
158            .flex_col()
159            .size_full()
160            .track_focus(&focus_handle)
161            .bg(bg)
162            .child(
163                canvas(
164                    move |bounds, _window, cx| -> PaintData {
165                        state_canvas.update(cx, |s, cx| {
166                            if s.bounds != bounds {
167                                s.bounds = bounds;
168                                cx.notify();
169                            }
170                        });
171                        let s = state_canvas.read(cx);
172                        PaintData::from_state(s)
173                    },
174                    move |bounds, data, window, cx| {
175                        paint_grid(&data, window, cx, bounds);
176                    },
177                )
178                .flex_1(),
179            )
180            .child(
181                canvas(
182                    move |_bounds, _window, cx| -> StatusBarData {
183                        let s = state_status.read(cx);
184                        StatusBarData::from_state(s)
185                    },
186                    move |bounds, data, window, cx| {
187                        paint_status_bar(&data, window, cx, bounds);
188                    },
189                )
190                .h(px(status_h)),
191            )
192            .on_mouse_down(
193                MouseButton::Left,
194                move |event: &MouseDownEvent, window, cx| {
195                    window.focus(&focus_left);
196                    state_mouse.update(cx, |s, cx| {
197                        // Normalize the absolute window pointer into the grid's
198                        // own frame once, up front. Everything downstream —
199                        // menu hit-testing and `handle_mouse_down` — works in
200                        // grid-relative coordinates.
201                        let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
202                        if let Some(menu) = s.context_menu.clone() {
203                            let cw = s.char_width;
204                            // The menu anchor is stored grid-relative, so the
205                            // pointer compares directly against it (no origin,
206                            // no scroll).
207                            let mx_rel = f32::from(rel.x);
208                            let my_rel = f32::from(rel.y);
209                            let w = menu.width_for(cw);
210                            let total_h = menu.total_height();
211                            let ax = f32::from(menu.anchor.x);
212                            let ay = f32::from(menu.anchor.y);
213                            if mx_rel >= ax
214                                && mx_rel <= ax + w
215                                && my_rel >= ay
216                                && my_rel <= ay + total_h
217                            {
218                                if let Some(action_idx) = menu::hover_at(&menu, mx_rel, my_rel, cw)
219                                {
220                                    let mut cur = 0;
221                                    for item in &menu.items {
222                                        match item {
223                                            MenuItem::Action(a) => {
224                                                if cur == action_idx {
225                                                    s.pending_action = Some((*a, menu.col));
226                                                    s.context_menu = None;
227                                                    cx.notify();
228                                                    return;
229                                                }
230                                                cur += 1;
231                                            }
232                                            MenuItem::Custom { id, .. } => {
233                                                if cur == action_idx {
234                                                    if let Some(request) = &menu.request {
235                                                        s.pending_custom_context_menu_action =
236                                                            Some(PendingCustomContextMenuAction {
237                                                                id: id.clone(),
238                                                                request: request.clone(),
239                                                            });
240                                                    }
241                                                    s.context_menu = None;
242                                                    cx.notify();
243                                                    return;
244                                                }
245                                                cur += 1;
246                                            }
247                                            MenuItem::Separator => {}
248                                        }
249                                    }
250                                }
251                            } else {
252                                s.context_menu = None;
253                                s.filter_prompt = None;
254                            }
255                        }
256                        s.handle_mouse_down(rel, event.modifiers.shift);
257                        cx.notify();
258                    });
259                },
260            )
261            .on_mouse_down(
262                MouseButton::Right,
263                move |event: &MouseDownEvent, window, cx| {
264                    window.focus(&focus_right);
265                    state_right.update(cx, |s, cx| {
266                        let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
267                        let hit = s.hit_test(pos);
268
269                        // No provider — existing built-in behavior.
270                        if s.context_menu_provider.is_none() {
271                            match hit {
272                                HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
273                                    s.open_context_menu(col, pos);
274                                }
275                                _ => {
276                                    s.context_menu = None;
277                                    s.filter_prompt = None;
278                                }
279                            }
280                            cx.notify();
281                            return;
282                        }
283
284                        // Provider exists — build custom menu.
285                        let Some(target) = s.context_menu_target_from_hit(hit) else {
286                            s.context_menu = None;
287                            s.filter_prompt = None;
288                            cx.notify();
289                            return;
290                        };
291
292                        let effective = s.effective_selection_for_context_target(&target);
293                        if effective != s.selection {
294                            s.selection = effective.clone();
295                        }
296
297                        let request = s.build_context_menu_request(target, &effective);
298                        let col = request.target.column_index().unwrap_or(0);
299
300                        let Some(provider) = s.context_menu_provider.clone() else {
301                            return;
302                        };
303                        let public_items = provider.menu_items(&request);
304                        let items = GridState::convert_context_menu_items(public_items);
305
306                        if items.is_empty() {
307                            s.context_menu = None;
308                        } else {
309                            s.context_menu =
310                                Some(menu::ContextMenu::custom(col, pos, items, request));
311                        }
312                        s.filter_prompt = None;
313                        cx.notify();
314                    });
315                },
316            )
317            .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
318                state_move.update(cx, |s, cx| {
319                    let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
320                    s.handle_mouse_move(rel, event.pressed_button);
321                    cx.notify();
322                });
323            })
324            .on_mouse_up(
325                MouseButton::Left,
326                move |_event: &MouseUpEvent, _window, cx| {
327                    state_up.update(cx, |s, cx| {
328                        s.handle_mouse_up();
329                        cx.notify();
330                    });
331                },
332            )
333            .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
334                state_scroll.update(cx, |s, cx| {
335                    let line_h = px(s.row_height);
336                    let delta = event.delta.pixel_delta(line_h);
337                    let scroll = s.scroll_handle.offset();
338                    let (mx, my) = s.max_scroll();
339                    let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
340                    let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
341                    s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
342                    if s.drag_start.is_some() {
343                        s.handle_scroll_drag();
344                    }
345                    cx.notify();
346                });
347            })
348            .on_key_down(move |event: &KeyDownEvent, _window, cx| {
349                let ks = &event.keystroke;
350                if ks.modifiers.platform && ks.key == "q" {
351                    cx.quit();
352                    return;
353                }
354                state_key.update(cx, |s, cx| {
355                    let kb = &s.config.key_bindings;
356                    if kb.select_all.matches(ks) {
357                        s.select_all();
358                    } else if kb.copy.matches(ks) {
359                        s.copy_selection(false, cx);
360                    } else if kb.copy_with_headers.matches(ks) {
361                        s.copy_selection(true, cx);
362                    } else if kb.page_up.matches(ks) {
363                        s.page_up();
364                    } else if kb.page_down.matches(ks) {
365                        s.page_down();
366                    } else {
367                        s.handle_key(ks);
368                    }
369                    cx.notify();
370                });
371            })
372    }
373}