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::{FilterInput, GridState, EDGE_SCROLL_TICK_MS};
14use crate::grid::theme::GridTheme;
15use crate::grid::{menu, HitResult, MenuItem, SortDirection};
16
17use gpui::{
18 anchored, canvas, deferred, div, point, px, App, AppContext, Context, Corner, Entity,
19 FocusHandle, Focusable, InteractiveElement, IntoElement, KeyDownEvent, MouseButton,
20 MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Render, ScrollWheelEvent,
21 StatefulInteractiveElement, Styled, Window,
22};
23
24const CONTEXT_MENU_PRIORITY: usize = 1_000_000;
31
32pub struct SqllyDataTable {
34 pub state: Entity<GridState>,
35 follow_system_appearance: bool,
39 appearance_subscription: Option<gpui::Subscription>,
43}
44
45impl SqllyDataTable {
46 #[must_use]
48 pub fn new(state: Entity<GridState>) -> Self {
49 Self {
50 state,
51 follow_system_appearance: true,
52 appearance_subscription: None,
53 }
54 }
55
56 #[must_use]
58 pub fn builder(data: GridData) -> SqllyDataTableBuilder {
59 SqllyDataTableBuilder {
60 data,
61 config: GridConfig::default(),
62 context_menu_provider: None,
63 theme: None,
64 debug_bar: false,
65 }
66 }
67}
68
69pub struct SqllyDataTableBuilder {
71 data: GridData,
72 config: GridConfig,
73 context_menu_provider: Option<ContextMenuProviderHandle>,
74 theme: Option<GridTheme>,
75 debug_bar: bool,
76}
77
78impl SqllyDataTableBuilder {
79 #[must_use]
81 pub fn config(mut self, config: GridConfig) -> Self {
82 self.config = config;
83 self
84 }
85
86 #[must_use]
89 pub fn theme(mut self, theme: GridTheme) -> Self {
90 self.theme = Some(theme);
91 self
92 }
93
94 #[must_use]
101 pub fn context_menu_provider(mut self, provider: impl ContextMenuProvider + 'static) -> Self {
102 self.context_menu_provider = Some(ContextMenuProviderHandle::new(provider));
103 self
104 }
105
106 #[must_use]
110 pub fn debug_bar(mut self, enabled: bool) -> Self {
111 self.debug_bar = enabled;
112 self
113 }
114
115 pub fn build(self, cx: &mut App) -> SqllyDataTable {
117 let focus = cx.focus_handle();
118 let provider = self.context_menu_provider;
119 let theme_override = self.theme;
120 let debug_bar = self.debug_bar;
121 let follow_system_appearance = theme_override.is_none();
122 let state = cx.new(|_cx| {
123 let mut s = GridState::new(self.data, self.config, focus.clone());
124 s.context_menu_provider = provider;
125 s.debug_bar_enabled = debug_bar;
126 if let Some(theme) = theme_override {
127 s.theme = theme;
128 }
129 s
130 });
131 SqllyDataTable {
132 state,
133 follow_system_appearance,
134 appearance_subscription: None,
135 }
136 }
137}
138
139impl Focusable for SqllyDataTable {
140 fn focus_handle(&self, cx: &App) -> FocusHandle {
141 self.state.read(cx).focus_handle.clone()
142 }
143}
144
145impl Render for SqllyDataTable {
146 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
147 if self.follow_system_appearance && self.appearance_subscription.is_none() {
152 let initial = GridTheme::for_appearance(window.appearance());
153 self.state.update(cx, |s, _cx| s.theme = initial);
154 let state_appearance = self.state.clone();
155 self.appearance_subscription =
156 Some(window.observe_window_appearance(move |window, cx| {
157 let theme = GridTheme::for_appearance(window.appearance());
158 state_appearance.update(cx, |s, cx| {
159 s.theme = theme;
160 cx.notify();
161 });
162 }));
163 }
164
165 let state_canvas = self.state.clone();
166 let state_status = self.state.clone();
167 let state_mouse = self.state.clone();
168 let state_move = self.state.clone();
169 let state_up = self.state.clone();
170 let state_scroll = self.state.clone();
171 let state_key = self.state.clone();
172 let state_right = self.state.clone();
173 let bg = self.state.read(cx).theme.bg;
174 let focus_handle = self.state.read(cx).focus_handle.clone();
175 let focus_left = focus_handle.clone();
176 let focus_right = focus_handle.clone();
177 let debug_bar = self.state.read(cx).debug_bar_enabled;
178 let status_h = self.state.read(cx).status_bar_height;
179
180 if let Some((action, col)) = self.state.read(cx).pending_action {
183 self.state.update(cx, |s, cx| {
184 s.execute_action(action, col, cx);
185 s.pending_action = None;
186 });
187 }
188
189 if let Some(pending) = self
191 .state
192 .read(cx)
193 .pending_custom_context_menu_action
194 .clone()
195 {
196 self.state.update(cx, |s, cx| {
197 s.pending_custom_context_menu_action = None;
198 s.execute_custom_context_menu_action(pending, cx);
199 });
200 }
201
202 if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
210 self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
211 let state_edge = self.state.clone();
212 cx.spawn(async move |_weak, cx| {
213 loop {
214 gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
215 let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
216 if let Ok(true) = res {
217 let _ = state_edge.update(cx, |_s, cx| cx.notify());
218 }
219 let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
220 if !matches!(dragging_res, Ok(true)) {
221 break;
222 }
223 }
224 let _ =
225 cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
226 })
227 .detach();
228 }
229
230 div()
231 .flex()
232 .flex_col()
233 .size_full()
234 .track_focus(&focus_handle)
235 .bg(bg)
236 .child(
237 canvas(
238 move |bounds, window, cx| -> PaintData {
239 let viewport = window.viewport_size();
240 state_canvas.update(cx, |s, cx| {
241 let mut dirty = false;
242 if s.bounds != bounds {
243 s.bounds = bounds;
244 dirty = true;
245 }
246 if s.window_viewport != viewport {
247 s.window_viewport = viewport;
248 }
249 if dirty {
250 cx.notify();
251 }
252 });
253 let s = state_canvas.read(cx);
254 PaintData::from_state(s)
255 },
256 move |bounds, data, window, cx| {
257 paint_grid(&data, window, cx, bounds);
258 },
259 )
260 .flex_1(),
261 )
262 .children(debug_bar.then(|| {
263 canvas(
264 move |_bounds, _window, cx| -> StatusBarData {
265 let s = state_status.read(cx);
266 StatusBarData::from_state(s)
267 },
268 move |bounds, data, window, cx| {
269 paint_status_bar(&data, window, cx, bounds);
270 },
271 )
272 .h(px(status_h))
273 }))
274 .children(render_context_menu_overlay(&self.state, cx))
275 .children(render_filter_panel_overlay(&self.state, cx))
276 .on_mouse_down(
277 MouseButton::Left,
278 move |event: &MouseDownEvent, window, cx| {
279 window.focus(&focus_left);
280 state_mouse.update(cx, |s, cx| {
281 let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
287 if s.context_menu.is_some() || s.filter_panel.is_some() {
288 s.context_menu = None;
289 s.filter_panel = None;
290 }
291 s.handle_mouse_down(rel, event.modifiers.shift);
292 cx.notify();
293 });
294 },
295 )
296 .on_mouse_down(
297 MouseButton::Right,
298 move |event: &MouseDownEvent, window, cx| {
299 window.focus(&focus_right);
300 state_right.update(cx, |s, cx| {
301 let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
302 let hit = s.hit_test(pos);
303
304 if s.context_menu_provider.is_none() {
306 match hit {
307 HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
308 s.open_context_menu(col, pos);
309 }
310 _ => {
311 s.context_menu = None;
312 s.filter_panel = None;
313 }
314 }
315 cx.notify();
316 return;
317 }
318
319 let Some(target) = s.context_menu_target_from_hit(hit) else {
321 s.context_menu = None;
322 s.filter_panel = None;
323 cx.notify();
324 return;
325 };
326
327 let effective = s.effective_selection_for_context_target(&target);
328 if effective != s.selection {
329 s.selection = effective.clone();
330 }
331
332 let request = s.build_context_menu_request(target, &effective);
333 let col = request.target.column_index().unwrap_or(0);
334
335 let Some(provider) = s.context_menu_provider.clone() else {
336 return;
337 };
338 let public_items = provider.menu_items(&request);
339 let items = GridState::convert_context_menu_items(public_items);
340
341 if items.is_empty() {
342 s.context_menu = None;
343 } else {
344 s.context_menu =
345 Some(menu::ContextMenu::custom(col, pos, items, request));
346 }
347 s.filter_panel = None;
348 cx.notify();
349 });
350 },
351 )
352 .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
353 state_move.update(cx, |s, cx| {
354 let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
355 s.handle_mouse_move(rel, event.pressed_button);
356 cx.notify();
357 });
358 })
359 .on_mouse_up(
360 MouseButton::Left,
361 move |_event: &MouseUpEvent, _window, cx| {
362 state_up.update(cx, |s, cx| {
363 s.handle_mouse_up();
364 cx.notify();
365 });
366 },
367 )
368 .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
369 state_scroll.update(cx, |s, cx| {
370 let line_h = px(s.row_height);
371 let delta = event.delta.pixel_delta(line_h);
372 let scroll = s.scroll_handle.offset();
373 let (mx, my) = s.max_scroll();
374 let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
375 let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
376 s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
377 if s.drag_start.is_some() {
378 s.handle_scroll_drag();
379 }
380 cx.notify();
381 });
382 })
383 .on_key_down(move |event: &KeyDownEvent, _window, cx| {
384 let ks = &event.keystroke;
385 if ks.modifiers.platform && ks.key == "q" {
386 cx.quit();
387 return;
388 }
389 state_key.update(cx, |s, cx| {
390 let kb = &s.config.key_bindings;
391 if kb.select_all.matches(ks) {
392 s.select_all();
393 } else if kb.copy.matches(ks) {
394 s.copy_selection(false, cx);
395 } else if kb.copy_with_headers.matches(ks) {
396 s.copy_selection(true, cx);
397 } else if kb.page_up.matches(ks) {
398 s.page_up();
399 } else if kb.page_down.matches(ks) {
400 s.page_down();
401 } else {
402 s.handle_key(ks);
403 }
404 cx.notify();
405 });
406 })
407 }
408}
409
410fn render_context_menu_overlay(
422 state: &Entity<GridState>,
423 cx: &mut Context<SqllyDataTable>,
424) -> Option<impl IntoElement> {
425 let s = state.read(cx);
426 let menu = s.context_menu.clone()?;
427 let theme = s.theme.clone();
428 let cw = s.char_width;
429 let grid_ox = f32::from(s.bounds.origin.x);
430 let grid_oy = f32::from(s.bounds.origin.y);
431 let viewport = s.window_viewport;
432 let vw = f32::from(viewport.width);
433 let vh = f32::from(viewport.height);
434
435 let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
436 let abs_x = grid_ox + f32::from(resolved.x);
437 let abs_y = grid_oy + f32::from(resolved.y);
438 let menu_w = menu.width_for(cw);
439
440 let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
443 let mut selectable_idx = 0usize;
444 for item in &menu.items {
445 match item {
446 MenuItem::Separator => {
447 rows.push(
448 div()
449 .h(px(menu::MENU_ITEM_HEIGHT))
450 .flex()
451 .items_center()
452 .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
453 .into_any_element(),
454 );
455 }
456 MenuItem::Action(_) | MenuItem::Custom { .. } => {
457 let this_idx = selectable_idx;
458 selectable_idx += 1;
459 let label = item.label().unwrap_or("").to_owned();
460 let hovered = menu.hovered == Some(this_idx);
461
462 let action = match item {
466 MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
467 MenuItem::Custom { id, .. } => {
468 MenuDispatch::Custom(id.clone(), menu.request.clone())
469 }
470 MenuItem::Separator => unreachable!(),
471 };
472
473 let state_click = state.clone();
474 let state_hover = state.clone();
475 let mut row = div()
476 .h(px(menu::MENU_ITEM_HEIGHT))
477 .px(px(menu::MENU_PADDING_X))
478 .flex()
479 .items_center()
480 .text_color(theme.menu_fg)
481 .text_size(px(menu::MENU_FONT_SIZE))
482 .child(label)
483 .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
484 state_hover.update(cx, |s, cx| {
485 if let Some(m) = s.context_menu.as_mut() {
486 if m.hovered != Some(this_idx) {
487 m.hovered = Some(this_idx);
488 cx.notify();
489 }
490 }
491 });
492 })
493 .on_mouse_down(
494 MouseButton::Left,
495 move |_e: &MouseDownEvent, _window, cx| {
496 state_click.update(cx, |s, cx| {
497 match &action {
498 MenuDispatch::Builtin(a, col) => {
499 s.pending_action = Some((*a, *col));
500 }
501 MenuDispatch::Custom(id, request) => {
502 if let Some(request) = request {
503 s.pending_custom_context_menu_action =
504 Some(PendingCustomContextMenuAction {
505 id: id.clone(),
506 request: request.clone(),
507 });
508 }
509 }
510 }
511 s.context_menu = None;
512 cx.notify();
513 });
514 },
515 );
516 if hovered {
517 row = row.bg(theme.menu_hover_bg);
518 }
519 rows.push(row.into_any_element());
520 }
521 }
522 }
523
524 let menu_body = div()
525 .flex()
526 .flex_col()
527 .w(px(menu_w))
528 .py(px(menu::MENU_INNER_PAD))
529 .bg(theme.menu_bg)
530 .border_1()
531 .border_color(theme.grid_line)
532 .children(rows);
533
534 let state_backdrop = state.clone();
537 let overlay = deferred(anchored().position(point(px(abs_x), px(abs_y))).child(
538 div().occlude().child(menu_body).on_mouse_down_out(
539 move |_e: &MouseDownEvent, _window, cx| {
540 state_backdrop.update(cx, |s, cx| {
541 if s.context_menu.is_some() {
542 s.context_menu = None;
543 s.filter_panel = None;
544 cx.notify();
545 }
546 });
547 },
548 ),
549 ))
550 .with_priority(CONTEXT_MENU_PRIORITY);
551
552 Some(overlay)
553}
554
555const FILTER_PANEL_WIDTH: f32 = 300.0;
557const FILTER_PANEL_MAX_ROWS: usize = 200;
559
560#[allow(clippy::too_many_lines)]
565fn render_filter_panel_overlay(
566 state: &Entity<GridState>,
567 cx: &mut Context<SqllyDataTable>,
568) -> Option<impl IntoElement> {
569 let s = state.read(cx);
570 let panel = s.filter_panel.clone()?;
571 let theme = s.theme.clone();
572 let col = panel.col;
573 let current_sort = s.sort;
574 let filter_active = s.filters.get(col).is_some_and(|f| f.is_active());
575 let grid_ox = f32::from(s.bounds.origin.x);
576 let grid_oy = f32::from(s.bounds.origin.y);
577
578 let abs_x = grid_ox + f32::from(panel.anchor.x);
583 let abs_y = grid_oy + f32::from(panel.anchor.y);
584
585 let c_bg = theme.menu_bg;
587 let c_line = theme.grid_line;
588 let c_fg = theme.menu_fg;
589 let c_accent = theme.sort_indicator;
590 let c_hover = theme.menu_hover_bg;
591 let c_muted = theme.muted_text;
592
593 let checkbox = move |checked: bool| {
594 let mut b = div()
595 .w(px(14.0))
596 .h(px(14.0))
597 .border_1()
598 .border_color(c_line)
599 .bg(c_bg)
600 .flex()
601 .items_center()
602 .justify_center();
603 if checked {
604 b = b.child(div().w(px(8.0)).h(px(8.0)).bg(c_accent));
605 }
606 b
607 };
608
609 let (asc_active, desc_active) = match current_sort {
611 Some((c, SortDirection::Ascending)) if c == col => (true, false),
612 Some((c, SortDirection::Descending)) if c == col => (false, true),
613 _ => (false, false),
614 };
615 let st_asc = state.clone();
616 let st_desc = state.clone();
617 let sort_row = div()
618 .flex()
619 .gap(px(6.0))
620 .child(
621 div()
622 .flex_1()
623 .h(px(26.0))
624 .flex()
625 .items_center()
626 .justify_center()
627 .border_1()
628 .border_color(c_line)
629 .bg(if asc_active { c_accent } else { c_hover })
630 .cursor_pointer()
631 .child("Ascending")
632 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
633 st_asc.update(cx, |s, cx| {
634 s.set_panel_sort(SortDirection::Ascending);
635 cx.notify();
636 });
637 }),
638 )
639 .child(
640 div()
641 .flex_1()
642 .h(px(26.0))
643 .flex()
644 .items_center()
645 .justify_center()
646 .border_1()
647 .border_color(c_line)
648 .bg(if desc_active { c_accent } else { c_hover })
649 .cursor_pointer()
650 .child("Descending")
651 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
652 st_desc.update(cx, |s, cx| {
653 s.set_panel_sort(SortDirection::Descending);
654 cx.notify();
655 });
656 }),
657 );
658
659 let st_op_toggle = state.clone();
661 let op_button = div()
662 .h(px(26.0))
663 .px(px(8.0))
664 .flex()
665 .items_center()
666 .border_1()
667 .border_color(c_line)
668 .bg(c_bg)
669 .cursor_pointer()
670 .child(panel.current_op_label())
671 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
672 st_op_toggle.update(cx, |s, cx| {
673 s.toggle_filter_op_menu();
674 cx.notify();
675 });
676 });
677
678 let op_menu = panel.op_menu_open.then(|| {
679 let mut items: Vec<gpui::AnyElement> = Vec::new();
680 for (i, label) in panel.op_labels().iter().enumerate() {
681 let selected = i == panel.op_index;
682 let st_pick = state.clone();
683 items.push(
684 div()
685 .h(px(24.0))
686 .px(px(8.0))
687 .flex()
688 .items_center()
689 .bg(if selected { c_accent } else { c_bg })
690 .cursor_pointer()
691 .child(*label)
692 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
693 st_pick.update(cx, |s, cx| {
694 s.set_filter_operator(i);
695 cx.notify();
696 });
697 })
698 .into_any_element(),
699 );
700 }
701 div()
702 .flex()
703 .flex_col()
704 .border_1()
705 .border_color(c_line)
706 .bg(c_bg)
707 .children(items)
708 });
709
710 let operand_field = |value: &str, focused: bool, placeholder: &str, input: FilterInput| {
712 let st_focus = state.clone();
713 let (text, is_placeholder) = if value.is_empty() {
714 (placeholder.to_owned(), true)
715 } else {
716 (value.to_owned(), false)
717 };
718 div()
719 .h(px(26.0))
720 .px(px(6.0))
721 .flex()
722 .items_center()
723 .gap(px(2.0))
724 .border_1()
725 .border_color(if focused { c_accent } else { c_line })
726 .bg(c_bg)
727 .cursor_pointer()
728 .child(
729 div()
730 .text_color(if is_placeholder { c_muted } else { c_fg })
731 .child(text),
732 )
733 .children(focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
734 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
735 st_focus.update(cx, |s, cx| {
736 s.set_filter_focus(input);
737 cx.notify();
738 });
739 })
740 };
741
742 let operand_placeholder = if panel.kind == crate::data::ColumnKind::Date {
743 "YYYY-MM-DD"
744 } else if crate::filter::uses_number_ops(panel.kind) {
745 "value"
746 } else if panel.op_index == 7 {
747 "regex"
749 } else {
750 "value"
751 };
752 let operands = panel.needs_operand().then(|| {
753 let mut row = div().flex().flex_col().gap(px(4.0)).child(operand_field(
754 &panel.operand_a.value,
755 panel.focus == FilterInput::OperandA,
756 operand_placeholder,
757 FilterInput::OperandA,
758 ));
759 if panel.needs_second_operand() {
760 row = row
761 .child(div().text_color(c_muted).text_size(px(11.0)).child("and"))
762 .child(operand_field(
763 &panel.operand_b.value,
764 panel.focus == FilterInput::OperandB,
765 operand_placeholder,
766 FilterInput::OperandB,
767 ));
768 }
769 row
770 });
771
772 let st_search = state.clone();
774 let search_focused = panel.focus == FilterInput::Search;
775 let (search_text, search_is_ph) = if panel.search.value.is_empty() {
776 ("Search".to_owned(), true)
777 } else {
778 (panel.search.value.clone(), false)
779 };
780 let search_box = div()
781 .h(px(26.0))
782 .px(px(6.0))
783 .flex()
784 .items_center()
785 .gap(px(2.0))
786 .border_1()
787 .border_color(if search_focused { c_accent } else { c_line })
788 .bg(c_bg)
789 .cursor_pointer()
790 .child(
791 div()
792 .text_color(if search_is_ph { c_muted } else { c_fg })
793 .child(search_text),
794 )
795 .children(search_focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
796 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
797 st_search.update(cx, |s, cx| {
798 s.set_filter_focus(FilterInput::Search);
799 cx.notify();
800 });
801 });
802
803 let st_all = state.clone();
805 let select_all_row = div()
806 .h(px(24.0))
807 .flex()
808 .items_center()
809 .gap(px(6.0))
810 .cursor_pointer()
811 .child(checkbox(panel.all_checked()))
812 .child("(Select All)")
813 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
814 st_all.update(cx, |s, cx| {
815 s.toggle_filter_select_all();
816 cx.notify();
817 });
818 });
819
820 let visible = panel.visible_indices();
821 let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
822 for &idx in visible.iter().take(FILTER_PANEL_MAX_ROWS) {
823 let row = &panel.distinct[idx];
824 let st_val = state.clone();
825 value_rows.push(
826 div()
827 .h(px(22.0))
828 .flex()
829 .items_center()
830 .gap(px(6.0))
831 .cursor_pointer()
832 .child(checkbox(row.checked))
833 .child(div().text_color(c_fg).child(row.label.clone()))
834 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
835 st_val.update(cx, |s, cx| {
836 s.toggle_filter_value(idx);
837 cx.notify();
838 });
839 })
840 .into_any_element(),
841 );
842 }
843 let truncated = visible.len() > FILTER_PANEL_MAX_ROWS;
844 let value_list = div()
845 .id("filter-value-list")
846 .flex()
847 .flex_col()
848 .max_h(px(180.0))
849 .overflow_y_scroll()
850 .children(value_rows)
851 .children(truncated.then(|| {
852 div()
853 .text_color(c_muted)
854 .text_size(px(11.0))
855 .child("Refine search to see more…")
856 }));
857
858 let st_clear = state.clone();
860 let st_close = state.clone();
861 let clear_bg = if filter_active { c_hover } else { c_bg };
862 let clear_fg = if filter_active { c_fg } else { c_muted };
863 let clear_border = if filter_active { c_line } else { c_muted };
864 let buttons_row = div()
865 .flex()
866 .gap(px(6.0))
867 .child(
868 div()
869 .flex_1()
870 .h(px(28.0))
871 .flex()
872 .items_center()
873 .justify_center()
874 .border_1()
875 .border_color(clear_border)
876 .bg(clear_bg)
877 .text_color(clear_fg)
878 .cursor_pointer()
879 .child("Clear Filter")
880 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
881 if !filter_active {
882 return;
883 }
884 st_clear.update(cx, |s, cx| {
885 s.clear_filter_panel();
886 cx.notify();
887 });
888 }),
889 )
890 .child(
891 div()
892 .flex_1()
893 .h(px(28.0))
894 .flex()
895 .items_center()
896 .justify_center()
897 .border_1()
898 .border_color(c_line)
899 .bg(c_hover)
900 .cursor_pointer()
901 .child("Close")
902 .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
903 st_close.update(cx, |s, cx| {
904 s.filter_panel = None;
905 cx.notify();
906 });
907 }),
908 );
909
910 let panel_body = div()
911 .flex()
912 .flex_col()
913 .w(px(FILTER_PANEL_WIDTH))
914 .p(px(10.0))
915 .gap(px(8.0))
916 .bg(c_bg)
917 .border_1()
918 .border_color(c_line)
919 .text_color(c_fg)
920 .text_size(px(13.0))
921 .child(div().text_color(c_muted).text_size(px(11.0)).child("Sort"))
922 .child(sort_row)
923 .child(
924 div()
925 .text_color(c_muted)
926 .text_size(px(11.0))
927 .child("Filter"),
928 )
929 .child(op_button)
930 .children(op_menu)
931 .children(operands)
932 .child(search_box)
933 .child(select_all_row)
934 .child(value_list)
935 .child(buttons_row);
936
937 let st_backdrop = state.clone();
938 let overlay = deferred(
939 anchored()
940 .anchor(Corner::BottomLeft)
941 .position(point(px(abs_x), px(abs_y)))
942 .child(div().occlude().child(panel_body).on_mouse_down_out(
943 move |_e: &MouseDownEvent, _window, cx| {
944 st_backdrop.update(cx, |s, cx| {
945 if s.filter_panel.is_some() {
946 s.filter_panel = None;
947 cx.notify();
948 }
949 });
950 },
951 )),
952 )
953 .with_priority(CONTEXT_MENU_PRIORITY);
954
955 Some(overlay)
956}
957
958enum MenuDispatch {
961 Builtin(menu::MenuAction, usize),
962 Custom(
963 String,
964 Option<crate::grid::context_menu::ContextMenuRequest>,
965 ),
966}