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