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