1use 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
23pub struct SqllyDataTable {
25 pub state: Entity<GridState>,
26}
27
28impl SqllyDataTable {
29 #[must_use]
31 pub fn new(state: Entity<GridState>) -> Self {
32 Self { state }
33 }
34
35 #[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
46pub struct SqllyDataTableBuilder {
48 data: GridData,
49 config: GridConfig,
50 context_menu_provider: Option<ContextMenuProviderHandle>,
51}
52
53impl SqllyDataTableBuilder {
54 #[must_use]
56 pub fn config(mut self, config: GridConfig) -> Self {
57 self.config = config;
58 self
59 }
60
61 #[must_use]
63 pub fn theme(self, theme: GridTheme) -> Self {
64 let _ = theme;
65 self
66 }
67
68 #[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 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 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 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 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 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 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 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 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}