Skip to main content

sqlly_datatable/grid/
widget.rs

1//! The `SqllyDataTable` GPUI widget and its builder. Owns one
2//! `Entity<GridState>` and wires GPUI's mouse / keyboard / scroll events to
3//! its methods. A bunch of `state.clone()` clones exist because each closure
4//! needs its own owned reference to the GPUI entity handle.
5
6use crate::config::GridConfig;
7use crate::data::GridData;
8use crate::filter::{ColumnFilter, FilterPredicate};
9use crate::grid::context_menu::{
10    ContextMenuProvider, ContextMenuProviderHandle, PendingCustomContextMenuAction,
11};
12use crate::grid::paint::{paint_grid, paint_status_bar, PaintData, StatusBarData};
13use crate::grid::state::state_inner;
14use crate::grid::state::{FilterInput, GridState, EDGE_SCROLL_TICK_MS};
15use crate::grid::theme::GridTheme;
16use crate::grid::{menu, HitResult, MenuItem, SortDirection};
17use crate::pivot::config::PivotConfig;
18use crate::pivot::context_menu::{PivotContextMenuProvider, PivotContextMenuProviderHandle};
19use crate::pivot::sidebar::PivotSidebar;
20use crate::pivot::state::PivotState;
21use crate::pivot::widget::PivotGrid;
22
23use gpui::{
24    anchored, canvas, deferred, div, hsla, point, pulsating_between, px, relative, Animation,
25    AnimationExt, App, AppContext, Context, Corner, Div, Entity, FocusHandle, Focusable,
26    InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
27    MouseUpEvent, ParentElement, Render, ScrollWheelEvent, StatefulInteractiveElement, Styled,
28    Window,
29};
30use std::sync::Arc;
31
32/// Draw order for the context-menu overlay. Deliberately far above any
33/// ordinary application UI so the menu — and, crucially, its event hitbox —
34/// sits on top of everything, even content painted outside the grid widget's
35/// own layout bounds (e.g. a host header above the grid). Deferred draws
36/// register their hitbox in a later pass, so this also fixes hover/click
37/// routing for menu items that visually overflow the grid area.
38const CONTEXT_MENU_PRIORITY: usize = 1_000_000;
39
40/// Which view of the data is active when the pivot tab is enabled.
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42pub enum GridTab {
43    /// The flat data grid.
44    #[default]
45    Grid,
46    /// The pivot view (sidebar + pivot grid).
47    Pivot,
48}
49
50/// The entities backing the pivot tab. Created when pivoting is enabled.
51pub(crate) struct PivotParts {
52    pub(crate) state: Entity<PivotState>,
53    grid: Entity<PivotGrid>,
54    sidebar: Entity<PivotSidebar>,
55}
56
57/// Top-level GPUI widget.
58pub struct SqllyDataTable {
59    pub state: Entity<GridState>,
60    /// Present when the pivot tab is enabled (via
61    /// [`SqllyDataTableBuilder::pivot`] or [`SqllyDataTable::enable_pivot`]).
62    pub(crate) pivot: Option<PivotParts>,
63    /// The active tab. Only meaningful while the pivot tab is enabled.
64    active_tab: GridTab,
65    /// When `true`, the grid swaps between the built-in light/dark
66    /// [`GridTheme`] palettes to follow the OS window appearance. Disabled
67    /// automatically when the caller supplies an explicit theme override.
68    follow_system_appearance: bool,
69    /// Retained appearance-observer subscription. Registered lazily on the
70    /// first render (that is where a `Window` is available); dropping it would
71    /// unregister the observer, so it is stored for the widget's lifetime.
72    appearance_subscription: Option<gpui::Subscription>,
73}
74
75impl SqllyDataTable {
76    /// Wrap an existing `Entity<GridState>`.
77    #[must_use]
78    pub fn new(state: Entity<GridState>) -> Self {
79        Self {
80            state,
81            pivot: None,
82            active_tab: GridTab::Grid,
83            follow_system_appearance: true,
84            appearance_subscription: None,
85        }
86    }
87
88    /// Construct from `GridData` using the default [`GridConfig`].
89    #[must_use]
90    pub fn builder(data: GridData) -> SqllyDataTableBuilder {
91        SqllyDataTableBuilder {
92            data,
93            config: GridConfig::default(),
94            context_menu_provider: None,
95            theme: None,
96            debug_bar: false,
97            pivot: None,
98            pivot_context_menu_provider: None,
99        }
100    }
101
102    /// The pivot state entity, when the pivot tab is enabled. Read it for
103    /// the current [`PivotConfig`]; update it to reconfigure
104    /// programmatically.
105    #[must_use]
106    pub fn pivot_state(&self) -> Option<&Entity<PivotState>> {
107        self.pivot.as_ref().map(|p| &p.state)
108    }
109
110    /// The currently active tab.
111    #[must_use]
112    pub fn active_tab(&self) -> GridTab {
113        self.active_tab
114    }
115
116    /// Switch between the flat grid and the pivot view. Switching to the
117    /// pivot re-syncs its source snapshot if the grid's data changed (e.g.
118    /// rows were appended). No-op when the pivot tab is not enabled and
119    /// `Pivot` is requested.
120    pub fn set_active_tab(&mut self, tab: GridTab, cx: &mut App) {
121        if tab == GridTab::Pivot {
122            if self.pivot.is_none() {
123                return;
124            }
125            self.sync_pivot_source(cx);
126        }
127        self.active_tab = tab;
128    }
129
130    /// Enable the pivot tab at runtime with the given configuration. If
131    /// already enabled, the existing pivot state is reconfigured instead
132    /// (collapse/sort/filter state is preserved).
133    pub fn enable_pivot(&mut self, config: PivotConfig, cx: &mut App) {
134        if let Some(parts) = &self.pivot {
135            parts.state.update(cx, |s, cx| {
136                s.config = config;
137                s.recompute();
138                cx.notify();
139            });
140            return;
141        }
142        self.pivot = Some(build_pivot_parts(&self.state, config, None, cx));
143    }
144
145    /// Remove the pivot tab and return to the flat grid.
146    pub fn disable_pivot(&mut self) {
147        self.pivot = None;
148        self.active_tab = GridTab::Grid;
149    }
150
151    /// Register (or replace) the pivot's right-click menu provider at
152    /// runtime. No-op when the pivot tab is not enabled — enable it first
153    /// (or register via
154    /// [`SqllyDataTableBuilder::pivot_context_menu_provider`]).
155    pub fn set_pivot_context_menu_provider(
156        &mut self,
157        provider: impl PivotContextMenuProvider + 'static,
158        cx: &mut App,
159    ) {
160        if let Some(parts) = &self.pivot {
161            parts.state.update(cx, |s, _cx| {
162                s.set_context_menu_provider(provider);
163            });
164        }
165    }
166
167    /// Push the grid's current data snapshot into the pivot state when it
168    /// changed (O(1) compare via Arc identity).
169    fn sync_pivot_source(&self, cx: &mut App) {
170        let Some(parts) = &self.pivot else {
171            return;
172        };
173        let (columns, rows) = {
174            let s = self.state.read(cx);
175            (s.data.columns.clone(), Arc::clone(&s.data_rows))
176        };
177        parts.state.update(cx, |ps, cx| {
178            if ps.source_differs(&rows) {
179                ps.set_source(columns, rows);
180                cx.notify();
181            }
182        });
183    }
184}
185
186/// Create the pivot entities over the grid's current data snapshot.
187fn build_pivot_parts(
188    grid_state: &Entity<GridState>,
189    config: PivotConfig,
190    menu_provider: Option<PivotContextMenuProviderHandle>,
191    cx: &mut App,
192) -> PivotParts {
193    let (columns, rows, formats, key_bindings, theme) = {
194        let s = grid_state.read(cx);
195        (
196            s.data.columns.clone(),
197            Arc::clone(&s.data_rows),
198            s.resolved_formats.clone(),
199            s.config.key_bindings.clone(),
200            s.theme.clone(),
201        )
202    };
203    let focus = cx.focus_handle();
204    let state = cx.new(|_| {
205        let mut ps = PivotState::new(columns, rows, formats, config, key_bindings, focus);
206        ps.theme = theme;
207        ps.context_menu_provider = menu_provider;
208        ps
209    });
210    let grid = cx.new(|_| PivotGrid::new(state.clone()));
211    let sidebar = cx.new(|_| PivotSidebar::new(state.clone()));
212    PivotParts {
213        state,
214        grid,
215        sidebar,
216    }
217}
218
219/// Builder for `SqllyDataTable`.
220pub struct SqllyDataTableBuilder {
221    data: GridData,
222    config: GridConfig,
223    context_menu_provider: Option<ContextMenuProviderHandle>,
224    theme: Option<GridTheme>,
225    debug_bar: bool,
226    pivot: Option<PivotConfig>,
227    pivot_context_menu_provider: Option<PivotContextMenuProviderHandle>,
228}
229
230impl SqllyDataTableBuilder {
231    /// Override the entire [`GridConfig`].
232    #[must_use]
233    pub fn config(mut self, config: GridConfig) -> Self {
234        self.config = config;
235        self
236    }
237
238    /// Override the [`GridTheme`]. Supplying an explicit theme opts out of the
239    /// automatic OS light/dark following; the grid uses exactly this theme.
240    #[must_use]
241    pub fn theme(mut self, theme: GridTheme) -> Self {
242        self.theme = Some(theme);
243        self
244    }
245
246    /// Register a custom right-click menu provider. When registered, the
247    /// provider fully controls the right-click menu for all targets (cells,
248    /// row headers, column headers). The built-in column-header menu is
249    /// suppressed; use
250    /// [`crate::grid::context_menu::ContextMenuItem::standard_column_header_items`]
251    /// to compose built-in actions.
252    #[must_use]
253    pub fn context_menu_provider(mut self, provider: impl ContextMenuProvider + 'static) -> Self {
254        self.context_menu_provider = Some(ContextMenuProviderHandle::new(provider));
255        self
256    }
257
258    /// Enable or disable the debug status bar. When enabled, a bar is painted
259    /// at the bottom of the grid showing click position, scroll offset, and
260    /// hovered cell coordinates. Off by default.
261    #[must_use]
262    pub fn debug_bar(mut self, enabled: bool) -> Self {
263        self.debug_bar = enabled;
264        self
265    }
266
267    /// Enable the pivot tab, preconfigured with `config`. The widget renders
268    /// a "Grid" / "Pivot" tab bar; the pivot tab shows the drag-and-drop
269    /// field sidebar next to the pivot grid. Pass
270    /// [`PivotConfig::default()`] for an unconfigured pivot the user builds
271    /// interactively.
272    #[must_use]
273    pub fn pivot(mut self, config: PivotConfig) -> Self {
274        self.pivot = Some(config);
275        self
276    }
277
278    /// Register a custom right-click menu provider for the pivot view. When
279    /// registered, the provider fully controls the pivot's context menu (the
280    /// built-in `pivot.*` action ids remain handled by the pivot; compose
281    /// them via [`crate::pivot::PivotMenuItem::standard_items`]). Only takes
282    /// effect together with [`SqllyDataTableBuilder::pivot`].
283    #[must_use]
284    pub fn pivot_context_menu_provider(
285        mut self,
286        provider: impl PivotContextMenuProvider + 'static,
287    ) -> Self {
288        self.pivot_context_menu_provider = Some(PivotContextMenuProviderHandle::new(provider));
289        self
290    }
291
292    /// Build the widget inside the supplied [`gpui::App`].
293    pub fn build(self, cx: &mut App) -> SqllyDataTable {
294        let focus = cx.focus_handle();
295        let provider = self.context_menu_provider;
296        let theme_override = self.theme;
297        let debug_bar = self.debug_bar;
298        let pivot_config = self.pivot;
299        let follow_system_appearance = theme_override.is_none();
300        let state = cx.new(|cx| {
301            let mut s = GridState::new(self.data, self.config, focus.clone());
302            s.context_menu_provider = provider;
303            s.debug_bar_enabled = debug_bar;
304            s.self_weak = Some(cx.weak_entity());
305            if let Some(theme) = theme_override {
306                s.theme = theme;
307            }
308            s
309        });
310        let pivot_menu_provider = self.pivot_context_menu_provider;
311        let pivot = pivot_config.map(|cfg| build_pivot_parts(&state, cfg, pivot_menu_provider, cx));
312        SqllyDataTable {
313            state,
314            pivot,
315            active_tab: GridTab::Grid,
316            follow_system_appearance,
317            appearance_subscription: None,
318        }
319    }
320}
321
322impl Focusable for SqllyDataTable {
323    fn focus_handle(&self, cx: &App) -> FocusHandle {
324        self.state.read(cx).focus_handle.clone()
325    }
326}
327
328impl Render for SqllyDataTable {
329    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
330        // Follow the OS light/dark appearance: set the initial theme from the
331        // current window appearance and register a one-time observer that
332        // swaps the grid theme whenever the system appearance changes. Skipped
333        // when the caller supplied an explicit theme override.
334        if self.follow_system_appearance && self.appearance_subscription.is_none() {
335            let initial = GridTheme::for_appearance(window.appearance());
336            self.state.update(cx, |s, _cx| s.theme = initial);
337            let state_appearance = self.state.clone();
338            self.appearance_subscription =
339                Some(window.observe_window_appearance(move |window, cx| {
340                    let theme = GridTheme::for_appearance(window.appearance());
341                    state_appearance.update(cx, |s, cx| {
342                        s.theme = theme;
343                        cx.notify();
344                    });
345                }));
346        }
347
348        // Keep the pivot's theme in lockstep with the grid theme (which may
349        // have just changed via the appearance observer).
350        if let Some(parts) = &self.pivot {
351            let grid_theme = self.state.read(cx).theme.clone();
352            parts.state.update(cx, |s, cx| {
353                if s.theme != grid_theme {
354                    s.theme = grid_theme;
355                    cx.notify();
356                }
357            });
358        }
359
360        // Drill-through: a double-click on a pivot cell (or the built-in
361        // "Show source rows" menu action) queued per-column value filters.
362        // Apply them to the flat grid and switch to the Grid tab so the user
363        // lands on exactly the rows that drive the clicked cell.
364        if let Some(parts) = &self.pivot {
365            let drill = parts.state.update(cx, |s, _cx| s.take_pending_drill_down());
366            if let Some(filter_sets) = drill {
367                self.state.update(cx, |g, cx| {
368                    // Filters are unsupported in windowed-row mode; the
369                    // drill-through is skipped rather than presenting a
370                    // filter that silently covers only resident rows.
371                    if g.window.is_none() {
372                        for filter in &mut g.filters {
373                            *filter = ColumnFilter::default();
374                        }
375                        for (field, values) in filter_sets {
376                            if let Some(slot) = g.filters.get_mut(field) {
377                                *slot = ColumnFilter {
378                                    predicate: FilterPredicate::None,
379                                    values: Some(values),
380                                };
381                            }
382                        }
383                        g.recompute();
384                    }
385                    cx.notify();
386                });
387                self.active_tab = GridTab::Grid;
388                let focus = self.state.read(cx).focus_handle.clone();
389                window.focus(&focus);
390                cx.notify();
391            }
392        }
393
394        let grid_view = self.render_grid_view(cx);
395
396        let Some(parts) = &self.pivot else {
397            return div().size_full().child(grid_view);
398        };
399
400        let theme = self.state.read(cx).theme.clone();
401        let tab = |label: &'static str, this_tab: GridTab, active: bool, cx: &mut Context<Self>| {
402            div()
403                .px(px(14.0))
404                .py(px(5.0))
405                .text_size(px(13.0))
406                .cursor_pointer()
407                .bg(if active { theme.bg } else { theme.header_bg })
408                .text_color(if active {
409                    theme.header_fg
410                } else {
411                    theme.muted_text
412                })
413                .border_b_2()
414                .border_color(if active {
415                    theme.sort_indicator
416                } else {
417                    theme.header_bg
418                })
419                .child(label)
420                .on_mouse_down(
421                    MouseButton::Left,
422                    cx.listener(move |this, _e: &MouseDownEvent, window, cx| {
423                        if this.active_tab == this_tab {
424                            return;
425                        }
426                        this.set_active_tab(this_tab, cx);
427                        // Route keyboard input to the now-visible view.
428                        match this_tab {
429                            GridTab::Grid => {
430                                let focus = this.state.read(cx).focus_handle.clone();
431                                window.focus(&focus);
432                            }
433                            GridTab::Pivot => {
434                                if let Some(p) = &this.pivot {
435                                    let focus = p.state.read(cx).focus_handle.clone();
436                                    window.focus(&focus);
437                                }
438                            }
439                        }
440                        cx.notify();
441                    }),
442                )
443        };
444
445        let tab_bar = div()
446            .flex()
447            .flex_row()
448            .flex_none()
449            .bg(theme.header_bg)
450            .border_b_1()
451            .border_color(theme.grid_line)
452            .child(tab(
453                "Grid",
454                GridTab::Grid,
455                self.active_tab == GridTab::Grid,
456                cx,
457            ))
458            .child(tab(
459                "Pivot",
460                GridTab::Pivot,
461                self.active_tab == GridTab::Pivot,
462                cx,
463            ));
464
465        let content: gpui::AnyElement = match self.active_tab {
466            GridTab::Grid => grid_view.into_any_element(),
467            GridTab::Pivot => div()
468                .flex()
469                .flex_row()
470                .size_full()
471                .child(parts.sidebar.clone())
472                .child(div().flex_1().min_w_0().child(parts.grid.clone()))
473                .into_any_element(),
474        };
475
476        div()
477            .flex()
478            .flex_col()
479            .size_full()
480            .child(tab_bar)
481            .child(div().flex_1().min_h_0().child(content))
482    }
483}
484
485impl SqllyDataTable {
486    /// The flat-grid element tree (canvas, overlays, input handlers). Shared
487    /// by the tabless render path and the "Grid" tab.
488    fn render_grid_view(&mut self, cx: &mut Context<Self>) -> Div {
489        let state_canvas = self.state.clone();
490        let state_status = self.state.clone();
491        let state_mouse = self.state.clone();
492        let state_move = self.state.clone();
493        let state_up = self.state.clone();
494        let state_scroll = self.state.clone();
495        let state_key = self.state.clone();
496        let state_right = self.state.clone();
497        let bg = self.state.read(cx).theme.bg;
498        let focus_handle = self.state.read(cx).focus_handle.clone();
499        let focus_left = focus_handle.clone();
500        let focus_right = focus_handle.clone();
501        let debug_bar = self.state.read(cx).debug_bar_enabled;
502        let status_h = self.state.read(cx).status_bar_height;
503
504        // Process any pending menu action from a previous mouse-down on a
505        // menu item (needs App access for clipboard).
506        if let Some((action, col)) = self.state.read(cx).pending_action {
507            self.state.update(cx, |s, cx| {
508                s.execute_action(action, col, cx);
509                s.pending_action = None;
510            });
511        }
512
513        // Process any pending custom context-menu action.
514        if let Some(pending) = self
515            .state
516            .read(cx)
517            .pending_custom_context_menu_action
518            .clone()
519        {
520            self.state.update(cx, |s, cx| {
521                s.pending_custom_context_menu_action = None;
522                s.execute_custom_context_menu_action(pending, cx);
523            });
524        }
525
526        // Spawn an edge-scroll timer **only while a drag is in progress**, and
527        // **only one at a time**. Without the `edge_scroll_active` guard,
528        // `render` would spawn a fresh 16 ms loop on every frame/notify during
529        // a drag — each successful tick calls `cx.notify()`, which re-renders
530        // and spawned yet another task, stacking concurrent loops that each
531        // apply a scroll delta per tick and multiply the effective speed
532        // without bound. The task clears the flag when it exits.
533        if self.state.read(cx).is_dragging && !self.state.read(cx).edge_scroll_active {
534            self.state.update(cx, |s, _cx| s.edge_scroll_active = true);
535            let state_edge = self.state.clone();
536            cx.spawn(async move |_weak, cx| {
537                loop {
538                    gpui::Timer::after(std::time::Duration::from_millis(EDGE_SCROLL_TICK_MS)).await;
539                    let res = cx.update(|cx| state_edge.update(cx, |s, _cx| s.apply_edge_scroll()));
540                    if let Ok(true) = res {
541                        let _ = state_edge.update(cx, |_s, cx| cx.notify());
542                    }
543                    let dragging_res = cx.update(|cx| state_edge.read(cx).is_dragging);
544                    if !matches!(dragging_res, Ok(true)) {
545                        break;
546                    }
547                }
548                let _ =
549                    cx.update(|cx| state_edge.update(cx, |s, _cx| s.edge_scroll_active = false));
550            })
551            .detach();
552        }
553
554        div()
555            .flex()
556            .flex_col()
557            .size_full()
558            .relative()
559            .track_focus(&focus_handle)
560            .bg(bg)
561            .child(
562                canvas(
563                    move |bounds, window, cx| -> PaintData {
564                        let viewport = window.viewport_size();
565                        state_canvas.update(cx, |s, cx| {
566                            let mut dirty = false;
567                            if s.bounds != bounds {
568                                s.bounds = bounds;
569                                s.clamp_scroll_to_bounds();
570                                dirty = true;
571                            }
572                            if s.window_viewport != viewport {
573                                s.window_viewport = viewport;
574                            }
575                            if dirty {
576                                cx.notify();
577                            }
578                        });
579                        let s = state_canvas.read(cx);
580                        PaintData::from_state(s)
581                    },
582                    move |bounds, data, window, cx| {
583                        paint_grid(&data, window, cx, bounds);
584                    },
585                )
586                .flex_1(),
587            )
588            .children(debug_bar.then(|| {
589                canvas(
590                    move |_bounds, _window, cx| -> StatusBarData {
591                        let s = state_status.read(cx);
592                        StatusBarData::from_state(s)
593                    },
594                    move |bounds, data, window, cx| {
595                        paint_status_bar(&data, window, cx, bounds);
596                    },
597                )
598                .h(px(status_h))
599            }))
600            .children(render_context_menu_overlay(&self.state, cx))
601            .children(render_filter_panel_overlay(&self.state, cx))
602            .children(render_busy_overlay(&self.state, cx))
603            .on_mouse_down(
604                MouseButton::Left,
605                move |event: &MouseDownEvent, window, cx| {
606                    window.focus(&focus_left);
607                    state_mouse.update(cx, |s, cx| {
608                        // Ignore grid input while a background task is running;
609                        // the busy overlay is shown and occludes interaction.
610                        if s.busy.is_some() {
611                            return;
612                        }
613                        // Normalize the absolute window pointer into the grid's
614                        // own frame. Menu hit-testing is handled by the deferred
615                        // overlay's own item handlers, so a left-click that
616                        // reaches the grid means the pointer was NOT on the menu;
617                        // dismiss any open menu and proceed with grid selection.
618                        let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
619                        if s.context_menu.is_some() || s.filter_panel.is_some() {
620                            s.context_menu = None;
621                            s.filter_panel = None;
622                        }
623                        s.handle_mouse_down_with_modifiers(
624                            rel,
625                            event.modifiers.shift,
626                            event.modifiers.platform,
627                        );
628                        cx.notify();
629                    });
630                },
631            )
632            .on_mouse_down(
633                MouseButton::Right,
634                move |event: &MouseDownEvent, window, cx| {
635                    window.focus(&focus_right);
636                    state_right.update(cx, |s, cx| {
637                        if s.busy.is_some() {
638                            return;
639                        }
640                        let pos = state_inner::to_grid_relative(event.position, s.bounds.origin);
641                        let hit = s.hit_test(pos);
642
643                        // No provider — existing built-in behavior.
644                        if s.context_menu_provider.is_none() {
645                            match hit {
646                                HitResult::ColumnHeader(col) | HitResult::SortButton(col) => {
647                                    s.open_context_menu(col, pos);
648                                }
649                                _ => {
650                                    s.context_menu = None;
651                                    s.filter_panel = None;
652                                }
653                            }
654                            cx.notify();
655                            return;
656                        }
657
658                        // Provider exists — build custom menu.
659                        let Some(target) = s.context_menu_target_from_hit(hit) else {
660                            s.context_menu = None;
661                            s.filter_panel = None;
662                            cx.notify();
663                            return;
664                        };
665
666                        let effective = s.effective_selection_for_context_target(&target);
667                        if effective != s.selection {
668                            s.selection = effective.clone();
669                        }
670
671                        let request = s.build_context_menu_request(target, &effective);
672                        let col = request.target.column_index().unwrap_or(0);
673
674                        let Some(provider) = s.context_menu_provider.clone() else {
675                            return;
676                        };
677                        let public_items = provider.menu_items(&request);
678                        let items = GridState::convert_context_menu_items(public_items);
679
680                        if items.is_empty() {
681                            s.context_menu = None;
682                        } else {
683                            s.context_menu =
684                                Some(menu::ContextMenu::custom(col, pos, items, request));
685                        }
686                        s.filter_panel = None;
687                        cx.notify();
688                    });
689                },
690            )
691            .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
692                state_move.update(cx, |s, cx| {
693                    let rel = state_inner::to_grid_relative(event.position, s.bounds.origin);
694                    s.handle_mouse_move(rel, event.pressed_button);
695                    cx.notify();
696                });
697            })
698            .on_mouse_up(
699                MouseButton::Left,
700                move |_event: &MouseUpEvent, _window, cx| {
701                    state_up.update(cx, |s, cx| {
702                        s.handle_mouse_up();
703                        cx.notify();
704                    });
705                },
706            )
707            .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
708                state_scroll.update(cx, |s, cx| {
709                    let line_h = px(s.row_height);
710                    let delta = event.delta.pixel_delta(line_h);
711                    let scroll = s.scroll_handle.offset();
712                    let (mx, my) = s.max_scroll();
713                    let new_y = (f32::from(scroll.y) - f32::from(delta.y)).clamp(0.0, my);
714                    let new_x = (f32::from(scroll.x) - f32::from(delta.x)).clamp(0.0, mx);
715                    s.scroll_handle.set_offset(point(px(new_x), px(new_y)));
716                    if s.drag_start.is_some() {
717                        s.handle_scroll_drag();
718                    }
719                    cx.notify();
720                });
721            })
722            .on_key_down(move |event: &KeyDownEvent, _window, cx| {
723                let ks = &event.keystroke;
724                if ks.modifiers.platform && ks.key == "q" {
725                    cx.quit();
726                    return;
727                }
728                state_key.update(cx, |s, cx| {
729                    let kb = &s.config.key_bindings;
730                    if kb.select_all.matches(ks) {
731                        s.select_all();
732                    } else if kb.copy.matches(ks) {
733                        s.copy_selection(false, cx);
734                    } else if kb.copy_with_headers.matches(ks) {
735                        s.copy_selection(true, cx);
736                    } else if kb.page_up.matches(ks) {
737                        s.page_up();
738                    } else if kb.page_down.matches(ks) {
739                        s.page_down();
740                    } else {
741                        s.handle_key(ks);
742                    }
743                    cx.notify();
744                });
745            })
746    }
747}
748
749/// Build the context-menu overlay as a `deferred` + `anchored` element so it
750/// paints — and receives mouse events — on top of everything, including
751/// regions outside the grid widget's own layout bounds. Returns `None` when no
752/// menu is open.
753///
754/// Positioning reuses [`menu::ContextMenu::resolved_position`] (window-viewport
755/// aware: flips up when there's no room below, shifts left at the right edge),
756/// then converts to absolute window coordinates for `anchored().position(..)`.
757/// Each selectable row carries its own `on_mouse_down` (dispatch) and
758/// `on_mouse_move` (hover highlight) handlers; a full-screen backdrop behind
759/// the menu dismisses it on any outside click.
760fn render_context_menu_overlay(
761    state: &Entity<GridState>,
762    cx: &mut Context<SqllyDataTable>,
763) -> Option<impl IntoElement> {
764    let s = state.read(cx);
765    let menu = s.context_menu.clone()?;
766    let theme = s.theme.clone();
767    let cw = s.char_width;
768    let grid_ox = f32::from(s.bounds.origin.x);
769    let grid_oy = f32::from(s.bounds.origin.y);
770    let viewport = s.window_viewport;
771    let vw = f32::from(viewport.width);
772    let vh = f32::from(viewport.height);
773
774    let resolved = menu.resolved_position(grid_ox, grid_oy, vw, vh, cw);
775    let abs_x = grid_ox + f32::from(resolved.x);
776    let abs_y = grid_oy + f32::from(resolved.y);
777    let menu_w = menu.width_for(cw);
778
779    // Build one row per item. `selectable_idx` counts only Action/Custom items
780    // so it matches the `hovered` index convention used elsewhere.
781    let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(menu.items.len());
782    let mut selectable_idx = 0usize;
783    for item in &menu.items {
784        match item {
785            MenuItem::Separator => {
786                rows.push(
787                    div()
788                        .h(px(menu::MENU_ITEM_HEIGHT))
789                        .flex()
790                        .items_center()
791                        .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
792                        .into_any_element(),
793                );
794            }
795            MenuItem::Action(_) | MenuItem::Custom { .. } => {
796                let this_idx = selectable_idx;
797                selectable_idx += 1;
798                let label = item.label().unwrap_or("").to_owned();
799                let hovered = menu.hovered == Some(this_idx);
800
801                // Dispatch: set the pending action and close the menu. The
802                // pending fields are drained at the top of `render` (they need
803                // App access for clipboard).
804                let action = match item {
805                    MenuItem::Action(a) => MenuDispatch::Builtin(*a, menu.col),
806                    MenuItem::Custom { id, .. } => {
807                        MenuDispatch::Custom(id.clone(), menu.request.clone())
808                    }
809                    MenuItem::Separator => unreachable!(),
810                };
811
812                let state_click = state.clone();
813                let state_hover = state.clone();
814                let mut row = div()
815                    .h(px(menu::MENU_ITEM_HEIGHT))
816                    .px(px(menu::MENU_PADDING_X))
817                    .flex()
818                    .items_center()
819                    .text_color(theme.menu_fg)
820                    .text_size(px(menu::MENU_FONT_SIZE))
821                    .child(label)
822                    .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
823                        state_hover.update(cx, |s, cx| {
824                            if let Some(m) = s.context_menu.as_mut() {
825                                if m.hovered != Some(this_idx) {
826                                    m.hovered = Some(this_idx);
827                                    cx.notify();
828                                }
829                            }
830                        });
831                    })
832                    .on_mouse_down(
833                        MouseButton::Left,
834                        move |_e: &MouseDownEvent, _window, cx| {
835                            state_click.update(cx, |s, cx| {
836                                match &action {
837                                    MenuDispatch::Builtin(a, col) => {
838                                        s.pending_action = Some((*a, *col));
839                                    }
840                                    MenuDispatch::Custom(id, request) => {
841                                        if let Some(request) = request {
842                                            s.pending_custom_context_menu_action =
843                                                Some(PendingCustomContextMenuAction {
844                                                    id: id.clone(),
845                                                    request: request.clone(),
846                                                });
847                                        }
848                                    }
849                                }
850                                s.context_menu = None;
851                                cx.notify();
852                            });
853                        },
854                    );
855                if hovered {
856                    row = row.bg(theme.menu_hover_bg);
857                }
858                rows.push(row.into_any_element());
859            }
860        }
861    }
862
863    let menu_body = div()
864        .flex()
865        .flex_col()
866        .w(px(menu_w))
867        .py(px(menu::MENU_INNER_PAD))
868        .bg(theme.menu_bg)
869        .border_1()
870        .border_color(theme.grid_line)
871        .children(rows);
872
873    // Full-window transparent backdrop: catches clicks outside the menu to
874    // dismiss it. Placed behind the menu within the same anchored overlay.
875    let state_backdrop = state.clone();
876    let overlay = deferred(anchored().position(point(px(abs_x), px(abs_y))).child(
877        div().occlude().child(menu_body).on_mouse_down_out(
878            move |_e: &MouseDownEvent, _window, cx| {
879                state_backdrop.update(cx, |s, cx| {
880                    if s.context_menu.is_some() {
881                        s.context_menu = None;
882                        s.filter_panel = None;
883                        cx.notify();
884                    }
885                });
886            },
887        ),
888    ))
889    .with_priority(CONTEXT_MENU_PRIORITY);
890
891    Some(overlay)
892}
893
894/// Fixed width of the filter popover, in pixels.
895const FILTER_PANEL_WIDTH: f32 = 300.0;
896/// Max number of distinct value rows rendered at once (search narrows the set).
897const FILTER_PANEL_MAX_ROWS: usize = 200;
898
899/// Build the Numbers-style per-column filter popover as a `deferred` +
900/// `anchored` overlay, using the exact mechanism as
901/// [`render_context_menu_overlay`] so it paints and receives events outside the
902/// grid's own layout bounds. Returns `None` when no panel is open.
903#[allow(clippy::too_many_lines)]
904fn render_filter_panel_overlay(
905    state: &Entity<GridState>,
906    cx: &mut Context<SqllyDataTable>,
907) -> Option<impl IntoElement> {
908    let s = state.read(cx);
909    let panel = s.filter_panel.clone()?;
910    let theme = s.theme.clone();
911    let col = panel.col;
912    let current_sort = s.sort;
913    let filter_active = s.filters.get(col).is_some_and(|f| f.is_active());
914    let grid_ox = f32::from(s.bounds.origin.x);
915    let grid_oy = f32::from(s.bounds.origin.y);
916
917    // Anchor (grid-relative) -> absolute window coords. The default
918    // `SwitchAnchor` fit mode on `anchored()` handles viewport-edge flipping
919    // automatically using the actual rendered height, so we don't need a
920    // manual estimate or flip calculation here.
921    let abs_x = grid_ox + f32::from(panel.anchor.x);
922    let abs_y = grid_oy + f32::from(panel.anchor.y);
923
924    // Palette (all `Hsla` are `Copy`, so they move freely into closures).
925    let c_bg = theme.menu_bg;
926    let c_line = theme.grid_line;
927    let c_fg = theme.menu_fg;
928    let c_accent = theme.sort_indicator;
929    let c_hover = theme.menu_hover_bg;
930    let c_muted = theme.muted_text;
931
932    let checkbox = move |checked: bool| {
933        let mut b = div()
934            .w(px(14.0))
935            .h(px(14.0))
936            .border_1()
937            .border_color(c_line)
938            .bg(c_bg)
939            .flex()
940            .items_center()
941            .justify_center();
942        if checked {
943            b = b.child(div().w(px(8.0)).h(px(8.0)).bg(c_accent));
944        }
945        b
946    };
947
948    // --- Sort row -----------------------------------------------------------
949    let (asc_active, desc_active) = match current_sort {
950        Some((c, SortDirection::Ascending)) if c == col => (true, false),
951        Some((c, SortDirection::Descending)) if c == col => (false, true),
952        _ => (false, false),
953    };
954    let st_asc = state.clone();
955    let st_desc = state.clone();
956    let sort_row = div()
957        .flex()
958        .gap(px(6.0))
959        .child(
960            div()
961                .flex_1()
962                .h(px(26.0))
963                .flex()
964                .items_center()
965                .justify_center()
966                .border_1()
967                .border_color(c_line)
968                .bg(if asc_active { c_accent } else { c_hover })
969                .cursor_pointer()
970                .child("Ascending")
971                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
972                    st_asc.update(cx, |s, cx| {
973                        s.set_panel_sort(SortDirection::Ascending);
974                        cx.notify();
975                    });
976                }),
977        )
978        .child(
979            div()
980                .flex_1()
981                .h(px(26.0))
982                .flex()
983                .items_center()
984                .justify_center()
985                .border_1()
986                .border_color(c_line)
987                .bg(if desc_active { c_accent } else { c_hover })
988                .cursor_pointer()
989                .child("Descending")
990                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
991                    st_desc.update(cx, |s, cx| {
992                        s.set_panel_sort(SortDirection::Descending);
993                        cx.notify();
994                    });
995                }),
996        );
997
998    // --- Operator dropdown --------------------------------------------------
999    let st_op_toggle = state.clone();
1000    let op_button = div()
1001        .h(px(26.0))
1002        .px(px(8.0))
1003        .flex()
1004        .items_center()
1005        .border_1()
1006        .border_color(c_line)
1007        .bg(c_bg)
1008        .cursor_pointer()
1009        .child(panel.current_op_label())
1010        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1011            st_op_toggle.update(cx, |s, cx| {
1012                s.toggle_filter_op_menu();
1013                cx.notify();
1014            });
1015        });
1016
1017    let op_menu = panel.op_menu_open.then(|| {
1018        let mut items: Vec<gpui::AnyElement> = Vec::new();
1019        for (i, label) in panel.op_labels().iter().enumerate() {
1020            let selected = i == panel.op_index;
1021            let st_pick = state.clone();
1022            items.push(
1023                div()
1024                    .h(px(24.0))
1025                    .px(px(8.0))
1026                    .flex()
1027                    .items_center()
1028                    .bg(if selected { c_accent } else { c_bg })
1029                    .cursor_pointer()
1030                    .child(*label)
1031                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1032                        st_pick.update(cx, |s, cx| {
1033                            s.set_filter_operator(i);
1034                            cx.notify();
1035                        });
1036                    })
1037                    .into_any_element(),
1038            );
1039        }
1040        div()
1041            .flex()
1042            .flex_col()
1043            .border_1()
1044            .border_color(c_line)
1045            .bg(c_bg)
1046            .children(items)
1047    });
1048
1049    // --- Operand field(s) ---------------------------------------------------
1050    let operand_field = |value: &str, focused: bool, placeholder: &str, input: FilterInput| {
1051        let st_focus = state.clone();
1052        let (text, is_placeholder) = if value.is_empty() {
1053            (placeholder.to_owned(), true)
1054        } else {
1055            (value.to_owned(), false)
1056        };
1057        div()
1058            .h(px(26.0))
1059            .px(px(6.0))
1060            .flex()
1061            .items_center()
1062            .gap(px(2.0))
1063            .border_1()
1064            .border_color(if focused { c_accent } else { c_line })
1065            .bg(c_bg)
1066            .cursor_pointer()
1067            .child(
1068                div()
1069                    .text_color(if is_placeholder { c_muted } else { c_fg })
1070                    .child(text),
1071            )
1072            .children(focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1073            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1074                st_focus.update(cx, |s, cx| {
1075                    s.set_filter_focus(input);
1076                    cx.notify();
1077                });
1078            })
1079    };
1080
1081    let operand_placeholder = if panel.kind == crate::data::ColumnKind::Date {
1082        "YYYY-MM-DD"
1083    } else if crate::filter::uses_number_ops(panel.kind) {
1084        "value"
1085    } else if panel.op_index == 7 {
1086        // Text "matches" operator.
1087        "regex"
1088    } else {
1089        "value"
1090    };
1091    let operands = panel.needs_operand().then(|| {
1092        let mut row = div().flex().flex_col().gap(px(4.0)).child(operand_field(
1093            &panel.operand_a.value,
1094            panel.focus == FilterInput::OperandA,
1095            operand_placeholder,
1096            FilterInput::OperandA,
1097        ));
1098        if panel.needs_second_operand() {
1099            row = row
1100                .child(div().text_color(c_muted).text_size(px(11.0)).child("and"))
1101                .child(operand_field(
1102                    &panel.operand_b.value,
1103                    panel.focus == FilterInput::OperandB,
1104                    operand_placeholder,
1105                    FilterInput::OperandB,
1106                ));
1107        }
1108        row
1109    });
1110
1111    // --- Search box ---------------------------------------------------------
1112    let st_search = state.clone();
1113    let search_focused = panel.focus == FilterInput::Search;
1114    let (search_text, search_is_ph) = if panel.search.value.is_empty() {
1115        ("Search".to_owned(), true)
1116    } else {
1117        (panel.search.value.clone(), false)
1118    };
1119    let search_box = div()
1120        .h(px(26.0))
1121        .px(px(6.0))
1122        .flex()
1123        .items_center()
1124        .gap(px(2.0))
1125        .border_1()
1126        .border_color(if search_focused { c_accent } else { c_line })
1127        .bg(c_bg)
1128        .cursor_pointer()
1129        .child(
1130            div()
1131                .text_color(if search_is_ph { c_muted } else { c_fg })
1132                .child(search_text),
1133        )
1134        .children(search_focused.then(|| div().w(px(1.0)).h(px(14.0)).bg(c_accent)))
1135        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1136            st_search.update(cx, |s, cx| {
1137                s.set_filter_focus(FilterInput::Search);
1138                cx.notify();
1139            });
1140        });
1141
1142    // --- (Select All) + value checklist ------------------------------------
1143    let st_all = state.clone();
1144    let select_all_row = div()
1145        .h(px(24.0))
1146        .flex()
1147        .items_center()
1148        .gap(px(6.0))
1149        .cursor_pointer()
1150        .child(checkbox(panel.all_checked()))
1151        .child("(Select All)")
1152        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1153            st_all.update(cx, |s, cx| {
1154                s.toggle_filter_select_all();
1155                cx.notify();
1156            });
1157        });
1158
1159    let visible = panel.visible_indices();
1160    let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
1161    for &idx in visible.iter().take(FILTER_PANEL_MAX_ROWS) {
1162        let row = &panel.distinct[idx];
1163        let st_val = state.clone();
1164        value_rows.push(
1165            div()
1166                .h(px(22.0))
1167                .flex()
1168                .items_center()
1169                .gap(px(6.0))
1170                .cursor_pointer()
1171                .child(checkbox(row.checked))
1172                .child(div().text_color(c_fg).child(row.label.clone()))
1173                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1174                    st_val.update(cx, |s, cx| {
1175                        s.toggle_filter_value(idx);
1176                        cx.notify();
1177                    });
1178                })
1179                .into_any_element(),
1180        );
1181    }
1182    let truncated = visible.len() > FILTER_PANEL_MAX_ROWS;
1183    let value_list = div()
1184        .id("filter-value-list")
1185        .flex()
1186        .flex_col()
1187        .max_h(px(180.0))
1188        .overflow_y_scroll()
1189        .children(value_rows)
1190        .children(truncated.then(|| {
1191            div()
1192                .text_color(c_muted)
1193                .text_size(px(11.0))
1194                .child("Refine search to see more…")
1195        }));
1196
1197    // --- Clear (left, disabled when no active filter) + Close (right) -----
1198    let st_clear = state.clone();
1199    let st_close = state.clone();
1200    let clear_bg = if filter_active { c_hover } else { c_bg };
1201    let clear_fg = if filter_active { c_fg } else { c_muted };
1202    let clear_border = if filter_active { c_line } else { c_muted };
1203    let buttons_row = div()
1204        .flex()
1205        .gap(px(6.0))
1206        .child(
1207            div()
1208                .flex_1()
1209                .h(px(28.0))
1210                .flex()
1211                .items_center()
1212                .justify_center()
1213                .border_1()
1214                .border_color(clear_border)
1215                .bg(clear_bg)
1216                .text_color(clear_fg)
1217                .cursor_pointer()
1218                .child("Clear Filter")
1219                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1220                    if !filter_active {
1221                        return;
1222                    }
1223                    st_clear.update(cx, |s, cx| {
1224                        s.clear_filter_panel();
1225                        cx.notify();
1226                    });
1227                }),
1228        )
1229        .child(
1230            div()
1231                .flex_1()
1232                .h(px(28.0))
1233                .flex()
1234                .items_center()
1235                .justify_center()
1236                .border_1()
1237                .border_color(c_line)
1238                .bg(c_hover)
1239                .cursor_pointer()
1240                .child("Close")
1241                .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
1242                    st_close.update(cx, |s, cx| {
1243                        s.filter_panel = None;
1244                        cx.notify();
1245                    });
1246                }),
1247        );
1248
1249    let panel_body = div()
1250        .flex()
1251        .flex_col()
1252        .w(px(FILTER_PANEL_WIDTH))
1253        .p(px(10.0))
1254        .gap(px(8.0))
1255        .bg(c_bg)
1256        .border_1()
1257        .border_color(c_line)
1258        .text_color(c_fg)
1259        .text_size(px(13.0))
1260        .child(div().text_color(c_muted).text_size(px(11.0)).child("Sort"))
1261        .child(sort_row)
1262        .child(
1263            div()
1264                .text_color(c_muted)
1265                .text_size(px(11.0))
1266                .child("Filter"),
1267        )
1268        .child(op_button)
1269        .children(op_menu)
1270        .children(operands)
1271        .child(search_box)
1272        .child(select_all_row)
1273        .child(value_list)
1274        .child(buttons_row);
1275
1276    let st_backdrop = state.clone();
1277    let overlay = deferred(
1278        anchored()
1279            .anchor(Corner::BottomLeft)
1280            .position(point(px(abs_x), px(abs_y)))
1281            .child(div().occlude().child(panel_body).on_mouse_down_out(
1282                move |_e: &MouseDownEvent, _window, cx| {
1283                    st_backdrop.update(cx, |s, cx| {
1284                        if s.filter_panel.is_some() {
1285                            s.filter_panel = None;
1286                            cx.notify();
1287                        }
1288                    });
1289                },
1290            )),
1291    )
1292    .with_priority(CONTEXT_MENU_PRIORITY);
1293
1294    Some(overlay)
1295}
1296
1297/// Renders the loading overlay while a background task runs. Returns `None`
1298/// when the grid is not busy. Painted as an absolute, input-occluding scrim
1299/// over the whole grid area with a centered card showing the task label and a
1300/// progress bar (determinate when progress is known, otherwise an animated
1301/// indeterminate bar).
1302fn render_busy_overlay(
1303    state: &Entity<GridState>,
1304    cx: &mut Context<SqllyDataTable>,
1305) -> Option<impl IntoElement> {
1306    let s = state.read(cx);
1307    let busy = s.busy.clone()?;
1308    let theme = s.theme.clone();
1309    let track = theme.grid_line;
1310    let accent = theme.sort_indicator;
1311
1312    let bar: gpui::AnyElement = if let Some(p) = busy.progress {
1313        let p = p.clamp(0.0, 1.0);
1314        div()
1315            .h_full()
1316            .w(relative(p))
1317            .rounded(px(3.0))
1318            .bg(accent)
1319            .into_any_element()
1320    } else {
1321        div()
1322            .h_full()
1323            .w(relative(0.3))
1324            .rounded(px(3.0))
1325            .bg(accent)
1326            .with_animation(
1327                "busy-indeterminate",
1328                Animation::new(std::time::Duration::from_millis(900))
1329                    .repeat()
1330                    .with_easing(pulsating_between(0.15, 0.85)),
1331                |el, delta| el.w(relative(delta)),
1332            )
1333            .into_any_element()
1334    };
1335
1336    let card = div()
1337        .flex()
1338        .flex_col()
1339        .gap(px(10.0))
1340        .p(px(16.0))
1341        .min_w(px(220.0))
1342        .rounded(px(8.0))
1343        .bg(theme.menu_bg)
1344        .border_1()
1345        .border_color(theme.grid_line)
1346        .child(
1347            div()
1348                .text_color(theme.menu_fg)
1349                .text_size(px(14.0))
1350                .child(busy.label.clone()),
1351        )
1352        .child(
1353            div()
1354                .w_full()
1355                .h(px(6.0))
1356                .rounded(px(3.0))
1357                .bg(track)
1358                .child(bar),
1359        );
1360
1361    let overlay = div()
1362        .absolute()
1363        .top_0()
1364        .left_0()
1365        .size_full()
1366        .occlude()
1367        .flex()
1368        .items_center()
1369        .justify_center()
1370        .bg(hsla(0.0, 0.0, 0.0, 0.35))
1371        .child(card);
1372
1373    Some(overlay)
1374}
1375
1376/// What a menu row dispatches when clicked. Captured per-row so the click
1377/// handler owns its data without borrowing the menu snapshot.
1378enum MenuDispatch {
1379    Builtin(menu::MenuAction, usize),
1380    Custom(
1381        String,
1382        Option<crate::grid::context_menu::ContextMenuRequest>,
1383    ),
1384}