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 anchored, canvas, deferred, div, point, px, App, AppContext, Context, Entity, FocusHandle,
19 Focusable, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
20 MouseMoveEvent, MouseUpEvent, ParentElement, Render, ScrollWheelEvent, Styled, Window,
21};
22
23const CONTEXT_MENU_PRIORITY: usize = 1_000_000;
30
31pub struct SqllyDataTable {
33 pub state: Entity<GridState>,
34}
35
36impl SqllyDataTable {
37 #[must_use]
39 pub fn new(state: Entity<GridState>) -> Self {
40 Self { state }
41 }
42
43 #[must_use]
45 pub fn builder(data: GridData) -> SqllyDataTableBuilder {
46 SqllyDataTableBuilder {
47 data,
48 config: GridConfig::default(),
49 context_menu_provider: None,
50 }
51 }
52}
53
54pub struct SqllyDataTableBuilder {
56 data: GridData,
57 config: GridConfig,
58 context_menu_provider: Option<ContextMenuProviderHandle>,
59}
60
61impl SqllyDataTableBuilder {
62 #[must_use]
64 pub fn config(mut self, config: GridConfig) -> Self {
65 self.config = config;
66 self
67 }
68
69 #[must_use]
71 pub fn theme(self, theme: GridTheme) -> Self {
72 let _ = theme;
73 self
74 }
75
76 #[must_use]
83 pub fn context_menu_provider(mut self, provider: impl ContextMenuProvider + 'static) -> Self {
84 self.context_menu_provider = Some(ContextMenuProviderHandle::new(provider));
85 self
86 }
87
88 pub fn build(self, cx: &mut App) -> SqllyDataTable {
90 let focus = cx.focus_handle();
91 let provider = self.context_menu_provider;
92 let state = cx.new(|_cx| {
93 let mut s = GridState::new(self.data, self.config, focus.clone());
94 s.context_menu_provider = provider;
95 s
96 });
97 SqllyDataTable { state }
98 }
99}
100
101impl Focusable for SqllyDataTable {
102 fn focus_handle(&self, cx: &App) -> FocusHandle {
103 self.state.read(cx).focus_handle.clone()
104 }
105}
106
107impl Render for SqllyDataTable {
108 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
109 let state_canvas = self.state.clone();
110 let state_status = self.state.clone();
111 let state_mouse = self.state.clone();
112 let state_move = self.state.clone();
113 let state_up = self.state.clone();
114 let state_scroll = self.state.clone();
115 let state_key = self.state.clone();
116 let state_right = self.state.clone();
117 let bg = self.state.read(cx).theme.bg;
118 let focus_handle = self.state.read(cx).focus_handle.clone();
119 let focus_left = focus_handle.clone();
120 let focus_right = focus_handle.clone();
121 let status_h = self.state.read(cx).status_bar_height;
122
123 if let Some((action, col)) = self.state.read(cx).pending_action {
126 self.state.update(cx, |s, cx| {
127 s.execute_action(action, col, cx);
128 s.pending_action = None;
129 });
130 }
131
132 if let Some(pending) = self
134 .state
135 .read(cx)
136 .pending_custom_context_menu_action
137 .clone()
138 {
139 self.state.update(cx, |s, cx| {
140 s.pending_custom_context_menu_action = None;
141 s.execute_custom_context_menu_action(pending, cx);
142 });
143 }
144
145 if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
153 self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
154 let state_edge = self.state.clone();
155 cx.spawn(async move |_weak, cx| {
156 loop {
157 gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
158 let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
159 if let Ok(true) = res {
160 let _ = state_edge.update(cx, |_s, cx| cx.notify());
161 }
162 let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
163 if !matches!(dragging_res, Ok(true)) {
164 break;
165 }
166 }
167 let _ =
168 cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
169 })
170 .detach();
171 }
172
173 div()
174 .flex()
175 .flex_col()
176 .size_full()
177 .track_focus(&focus_handle)
178 .bg(bg)
179 .child(
180 canvas(
181 move |bounds, window, cx| -> PaintData {
182 let viewport = window.viewport_size();
183 state_canvas.update(cx, |s, cx| {
184 let mut dirty = false;
185 if s.bounds != bounds {
186 s.bounds = bounds;
187 dirty = true;
188 }
189 if s.window_viewport != viewport {
190 s.window_viewport = viewport;
191 }
192 if dirty {
193 cx.notify();
194 }
195 });
196 let s = state_canvas.read(cx);
197 PaintData::from_state(s)
198 },
199 move |bounds, data, window, cx| {
200 paint_grid(&data, window, cx, bounds);
201 },
202 )
203 .flex_1(),
204 )
205 .child(
206 canvas(
207 move |_bounds, _window, cx| -> StatusBarData {
208 let s = state_status.read(cx);
209 StatusBarData::from_state(s)
210 },
211 move |bounds, data, window, cx| {
212 paint_status_bar(&data, window, cx, bounds);
213 },
214 )
215 .h(px(status_h)),
216 )
217 .children(render_context_menu_overlay(&self.state, cx))
218 .on_mouse_down(
219 MouseButton::Left,
220 move |event: &MouseDownEvent, window, cx| {
221 window.focus(&focus_left);
222 state_mouse.update(cx, |s, cx| {
223 let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
229 if s.context_menu.is_some() {
230 s.context_menu = None;
231 s.filter_prompt = None;
232 }
233 s.handle_mouse_down(rel, event.modifiers.shift);
234 cx.notify();
235 });
236 },
237 )
238 .on_mouse_down(
239 MouseButton::Right,
240 move |event: &MouseDownEvent, window, cx| {
241 window.focus(&focus_right);
242 state_right.update(cx, |s, cx| {
243 let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
244 let hit = s.hit_test(pos);
245
246 if s.context_menu_provider.is_none() {
248 match hit {
249 HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
250 s.open_context_menu(col, pos);
251 }
252 _ => {
253 s.context_menu = None;
254 s.filter_prompt = None;
255 }
256 }
257 cx.notify();
258 return;
259 }
260
261 let Some(target) = s.context_menu_target_from_hit(hit) else {
263 s.context_menu = None;
264 s.filter_prompt = None;
265 cx.notify();
266 return;
267 };
268
269 let effective = s.effective_selection_for_context_target(&target);
270 if effective != s.selection {
271 s.selection = effective.clone();
272 }
273
274 let request = s.build_context_menu_request(target, &effective);
275 let col = request.target.column_index().unwrap_or(0);
276
277 let Some(provider) = s.context_menu_provider.clone() else {
278 return;
279 };
280 let public_items = provider.menu_items(&request);
281 let items = GridState::convert_context_menu_items(public_items);
282
283 if items.is_empty() {
284 s.context_menu = None;
285 } else {
286 s.context_menu =
287 Some(menu::ContextMenu::custom(col, pos, items, request));
288 }
289 s.filter_prompt = None;
290 cx.notify();
291 });
292 },
293 )
294 .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
295 state_move.update(cx, |s, cx| {
296 let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
297 s.handle_mouse_move(rel, event.pressed_button);
298 cx.notify();
299 });
300 })
301 .on_mouse_up(
302 MouseButton::Left,
303 move |_event: &MouseUpEvent, _window, cx| {
304 state_up.update(cx, |s, cx| {
305 s.handle_mouse_up();
306 cx.notify();
307 });
308 },
309 )
310 .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
311 state_scroll.update(cx, |s, cx| {
312 let line_h = px(s.row_height);
313 let delta = event.delta.pixel_delta(line_h);
314 let scroll = s.scroll_handle.offset();
315 let (mx, my) = s.max_scroll();
316 let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
317 let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
318 s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
319 if s.drag_start.is_some() {
320 s.handle_scroll_drag();
321 }
322 cx.notify();
323 });
324 })
325 .on_key_down(move |event: &KeyDownEvent, _window, cx| {
326 let ks = &event.keystroke;
327 if ks.modifiers.platform && ks.key == "q" {
328 cx.quit();
329 return;
330 }
331 state_key.update(cx, |s, cx| {
332 let kb = &s.config.key_bindings;
333 if kb.select_all.matches(ks) {
334 s.select_all();
335 } else if kb.copy.matches(ks) {
336 s.copy_selection(false, cx);
337 } else if kb.copy_with_headers.matches(ks) {
338 s.copy_selection(true, cx);
339 } else if kb.page_up.matches(ks) {
340 s.page_up();
341 } else if kb.page_down.matches(ks) {
342 s.page_down();
343 } else {
344 s.handle_key(ks);
345 }
346 cx.notify();
347 });
348 })
349 }
350}
351
352fn render_context_menu_overlay(
364 state: &Entity<GridState>,
365 cx: &mut Context<SqllyDataTable>,
366) -> Option<impl IntoElement> {
367 let s = state.read(cx);
368 let menu = s.context_menu.clone()?;
369 let theme = s.theme.clone();
370 let cw = s.char_width;
371 let grid_ox = f32::from(s.bounds.origin.x);
372 let grid_oy = f32::from(s.bounds.origin.y);
373 let viewport = s.window_viewport;
374 let vw = f32::from(viewport.width);
375 let vh = f32::from(viewport.height);
376
377 let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
378 let abs_x = grid_ox + f32::from(resolved.x);
379 let abs_y = grid_oy + f32::from(resolved.y);
380 let menu_w = menu.width_for(cw);
381
382 let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
385 let mut selectable_idx = 0usize;
386 for item in &menu.items {
387 match item {
388 MenuItem::Separator => {
389 rows.push(
390 div()
391 .h(px(menu::MENU_ITEM_HEIGHT))
392 .flex()
393 .items_center()
394 .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
395 .into_any_element(),
396 );
397 }
398 MenuItem::Action(_) | MenuItem::Custom { .. } => {
399 let this_idx = selectable_idx;
400 selectable_idx += 1;
401 let label = item.label().unwrap_or("").to_owned();
402 let hovered = menu.hovered == Some(this_idx);
403
404 let action = match item {
408 MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
409 MenuItem::Custom { id, .. } => {
410 MenuDispatch::Custom(id.clone(), menu.request.clone())
411 }
412 MenuItem::Separator => unreachable!(),
413 };
414
415 let state_click = state.clone();
416 let state_hover = state.clone();
417 let mut row = div()
418 .h(px(menu::MENU_ITEM_HEIGHT))
419 .px(px(menu::MENU_PADDING_X))
420 .flex()
421 .items_center()
422 .text_color(theme.menu_fg)
423 .text_size(px(menu::MENU_FONT_SIZE))
424 .child(label)
425 .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
426 state_hover.update(cx, |s, cx| {
427 if let Some(m) = s.context_menu.as_mut() {
428 if m.hovered != Some(this_idx) {
429 m.hovered = Some(this_idx);
430 cx.notify();
431 }
432 }
433 });
434 })
435 .on_mouse_down(
436 MouseButton::Left,
437 move |_e: &MouseDownEvent, _window, cx| {
438 state_click.update(cx, |s, cx| {
439 match &action {
440 MenuDispatch::Builtin(a, col) => {
441 s.pending_action = Some((*a, *col));
442 }
443 MenuDispatch::Custom(id, request) => {
444 if let Some(request) = request {
445 s.pending_custom_context_menu_action =
446 Some(PendingCustomContextMenuAction {
447 id: id.clone(),
448 request: request.clone(),
449 });
450 }
451 }
452 }
453 s.context_menu = None;
454 cx.notify();
455 });
456 },
457 );
458 if hovered {
459 row = row.bg(theme.menu_hover_bg);
460 }
461 rows.push(row.into_any_element());
462 }
463 }
464 }
465
466 let menu_body = div()
467 .absolute()
468 .flex()
469 .flex_col()
470 .w(px(menu_w))
471 .py(px(menu::MENU_INNER_PAD))
472 .bg(theme.menu_bg)
473 .border_1()
474 .border_color(theme.grid_line)
475 .children(rows);
476
477 let state_backdrop = state.clone();
480 let overlay = deferred(anchored().position(point(px(abs_x), px(abs_y))).child(
481 div().occlude().child(menu_body).on_mouse_down_out(
482 move |_e: &MouseDownEvent, _window, cx| {
483 state_backdrop.update(cx, |s, cx| {
484 if s.context_menu.is_some() {
485 s.context_menu = None;
486 s.filter_prompt = None;
487 cx.notify();
488 }
489 });
490 },
491 ),
492 ))
493 .with_priority(CONTEXT_MENU_PRIORITY);
494
495 Some(overlay)
496}
497
498enum MenuDispatch {
501 Builtin(menu::MenuAction, usize),
502 Custom(
503 String,
504 Option<crate::grid::context_menu::ContextMenuRequest>,
505 ),
506}