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::paint::{paint_grid, paint_status_bar, PaintData, StatusBarData};
9use crate::grid::state::state_inner;
10use crate::grid::state::{GridState, EDGE_SCROLL_TICK_MS};
11use crate::grid::theme::GridTheme;
12use crate::grid::{menu, HitResult, MenuItem};
13
14use gpui::{
15    canvas, div, point, px, App, AppContext, Context, Entity, FocusHandle, Focusable,
16    InteractiveElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
17    ParentElement, Render, ScrollWheelEvent, Styled, Window,
18};
19
20/// Top-level GPUI widget.
21pub struct SqllyDataTable {
22    pub state: Entity<GridState>,
23}
24
25impl SqllyDataTable {
26    /// Wrap an existing `Entity<GridState>`.
27    #[must_use]
28    pub fn new(state: Entity<GridState>) -> Self {
29        Self { state }
30    }
31
32    /// Construct from `GridData` using the default [`GridConfig`].
33    #[must_use]
34    pub fn builder(data: GridData) -> SqllyDataTableBuilder {
35        SqllyDataTableBuilder {
36            data,
37            config: GridConfig::default(),
38        }
39    }
40}
41
42/// Builder for `SqllyDataTable`.
43pub struct SqllyDataTableBuilder {
44    data: GridData,
45    config: GridConfig,
46}
47
48impl SqllyDataTableBuilder {
49    /// Override the entire [`GridConfig`].
50    #[must_use]
51    pub fn config(mut self, config: GridConfig) -> Self {
52        self.config = config;
53        self
54    }
55
56    /// Override only the [`GridTheme`]. No-op for now; kept for symmetry.
57    #[must_use]
58    pub fn theme(self, theme: GridTheme) -> Self {
59        let _ = theme;
60        self
61    }
62
63    /// Build the widget inside the supplied [`gpui::App`].
64    pub fn build(self, cx: &mut App) -> SqllyDataTable {
65        let focus = cx.focus_handle();
66        let state = cx.new(|_cx| GridState::new(self.data, self.config, focus.clone()));
67        SqllyDataTable { state }
68    }
69}
70
71impl Focusable for SqllyDataTable {
72    fn focus_handle(&self, cx: &App) -> FocusHandle {
73        self.state.read(cx).focus_handle.clone()
74    }
75}
76
77impl Render for SqllyDataTable {
78    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
79        let state_canvas = self.state.clone();
80        let state_status = self.state.clone();
81        let state_mouse = self.state.clone();
82        let state_move = self.state.clone();
83        let state_up = self.state.clone();
84        let state_scroll = self.state.clone();
85        let state_key = self.state.clone();
86        let state_right = self.state.clone();
87        let bg = self.state.read(cx).theme.bg;
88        let _focus_handle = self.state.read(cx).focus_handle.clone();
89        let status_h = self.state.read(cx).status_bar_height;
90
91        // Process any pending menu action from a previous mouse-down on a
92        // menu item (needs App access for clipboard).
93        if let Some((action, col)) = self.state.read(cx).pending_action {
94            self.state.update(cx, |s, cx| {
95                s.execute_action(action, col, cx);
96                s.pending_action = None;
97            });
98        }
99
100        // Spawn an edge-scroll timer **only while a drag is in progress**.
101        // The task self-detaches when `wants_edge_scroll_tick` is false so it
102        // is no longer a 60 fps loop.
103        if self.state.read(cx).is_dragging {
104            let state_edge = self.state.clone();
105            cx.spawn(async move |_weak, cx| loop {
106                gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
107                let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
108                if let Ok(true) = res {
109                    let _ = state_edge.update(cx, |_s, cx| cx.notify());
110                }
111                let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
112                if !matches!(dragging_res, Ok(true)) {
113                    break;
114                }
115            })
116            .detach();
117        }
118
119        div()
120            .flex()
121            .flex_col()
122            .size_full()
123            .bg(bg)
124            .child(
125                canvas(
126                    move |bounds, _window, cx| -> PaintData {
127                        state_canvas.update(cx, |s, cx| {
128                            if s.bounds != bounds {
129                                s.bounds = bounds;
130                                cx.notify();
131                            }
132                        });
133                        let s = state_canvas.read(cx);
134                        PaintData::from_state(s)
135                    },
136                    move |bounds, data, window, cx| {
137                        paint_grid(&data, window, cx, bounds);
138                    },
139                )
140                .flex_1(),
141            )
142            .child(
143                canvas(
144                    move |_bounds, _window, cx| -> StatusBarData {
145                        let s = state_status.read(cx);
146                        StatusBarData::from_state(s)
147                    },
148                    move |bounds, data, window, cx| {
149                        paint_status_bar(&data, window, cx, bounds);
150                    },
151                )
152                .h(px(status_h)),
153            )
154            .on_mouse_down(
155                MouseButton::Left,
156                move |event: &MouseDownEvent, _window, cx| {
157                    state_mouse.update(cx, |s, cx| {
158                        if let Some(menu) = s.context_menu.clone() {
159                            let cw = s.char_width;
160                            let (mx_rel, my_rel) = state_inner::screen_to_content(
161                                event.position,
162                                s.bounds.origin,
163                                s.scroll_handle.offset(),
164                            );
165                            let w = menu.width_for(cw);
166                            let total_h = menu.total_height();
167                            let ax = f32::from(menu.anchor.x);
168                            let ay = f32::from(menu.anchor.y);
169                            if mx_rel >= ax
170                                && mx_rel <= ax + w
171                                && my_rel >= ay
172                                && my_rel <= ay + total_h
173                            {
174                                if let Some(action_idx) = menu::hover_at(&menu, mx_rel, my_rel, cw)
175                                {
176                                    let mut cur = 0;
177                                    for item in &menu.items {
178                                        if let MenuItem::Action(a) = item {
179                                            if cur == action_idx {
180                                                s.pending_action = Some((*a, menu.col));
181                                                s.context_menu = None;
182                                                cx.notify();
183                                                return;
184                                            }
185                                            cur += 1;
186                                        }
187                                    }
188                                }
189                            } else {
190                                s.context_menu = None;
191                                s.filter_prompt = None;
192                            }
193                        }
194                        s.handle_mouse_down(event.position, event.modifiers.shift);
195                        cx.notify();
196                    });
197                },
198            )
199            .on_mouse_down(
200                MouseButton::Right,
201                move |event: &MouseDownEvent, _window, cx| {
202                    state_right.update(cx, |s, cx| {
203                        let pos = event.position;
204                        let hit = s.hit_test(pos);
205                        match hit {
206                            HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
207                                s.open_context_menu(col, pos);
208                            }
209                            _ => {
210                                s.context_menu = None;
211                                s.filter_prompt = None;
212                            }
213                        }
214                        cx.notify();
215                    });
216                },
217            )
218            .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
219                state_move.update(cx, |s, cx| {
220                    s.handle_mouse_move(event.position, event.pressed_button);
221                    cx.notify();
222                });
223            })
224            .on_mouse_up(
225                MouseButton::Left,
226                move |_event: &MouseUpEvent, _window, cx| {
227                    state_up.update(cx, |s, cx| {
228                        s.handle_mouse_up();
229                        cx.notify();
230                    });
231                },
232            )
233            .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
234                state_scroll.update(cx, |s, cx| {
235                    let line_h = px(s.row_height);
236                    let delta = event.delta.pixel_delta(line_h);
237                    let scroll = s.scroll_handle.offset();
238                    let (mx, my) = s.max_scroll();
239                    let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
240                    let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
241                    s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
242                    if s.drag_start.is_some() {
243                        s.handle_scroll_drag();
244                    }
245                    cx.notify();
246                });
247            })
248            .on_key_down(move |event: &KeyDownEvent, _window, cx| {
249                let ks = &event.keystroke;
250                if ks.modifiers.platform && ks.key == "q" {
251                    cx.quit();
252                    return;
253                }
254                state_key.update(cx, |s, cx| {
255                    let kb = &s.config.key_bindings;
256                    if kb.select_all.matches(ks) {
257                        s.select_all();
258                    } else if kb.copy.matches(ks) {
259                        s.copy_selection(false, cx);
260                    } else if kb.copy_with_headers.matches(ks) {
261                        s.copy_selection(true, cx);
262                    } else if kb.page_up.matches(ks) {
263                        s.page_up();
264                    } else if kb.page_down.matches(ks) {
265                        s.page_down();
266                    } else {
267                        s.handle_key(ks);
268                    }
269                    cx.notify();
270                });
271            })
272    }
273}