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