Skip to main content

sqlly_datatable/pivot/
widget.rs

1//! The `PivotGrid` GPUI widget: a canvas-rendered pivot table wired to an
2//! [`Entity<PivotState>`]. Mirrors `SqllyDataTable`'s structure — the widget
3//! owns no data of its own, it only routes events into the state and paints
4//! a `PivotPaintData` snapshot.
5
6use crate::grid::menu::{MENU_FONT_SIZE, MENU_INNER_PAD, MENU_ITEM_HEIGHT, MENU_PADDING_X};
7use crate::grid::selection::to_grid_relative;
8use crate::pivot::context_menu::PivotMenuItem;
9use crate::pivot::paint::{paint_pivot_grid, PivotPaintData};
10use crate::pivot::state::{PivotHitResult, PivotState};
11
12use gpui::{
13    anchored, canvas, deferred, div, point, px, App, Context, Entity, FocusHandle, Focusable,
14    InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
15    MouseUpEvent, ParentElement, Render, ScrollWheelEvent, Styled, Window,
16};
17
18/// Draw order for the pivot's context-menu overlay; matches the flat grid's
19/// menu priority so it always paints and receives events on top.
20const PIVOT_MENU_PRIORITY: usize = 1_000_000;
21
22/// Canvas widget rendering one [`PivotState`].
23pub struct PivotGrid {
24    /// The shared pivot state. The sidebar mutates the same entity.
25    pub state: Entity<PivotState>,
26}
27
28impl PivotGrid {
29    /// Wrap an existing pivot state entity.
30    #[must_use]
31    pub fn new(state: Entity<PivotState>) -> Self {
32        Self { state }
33    }
34}
35
36impl Focusable for PivotGrid {
37    fn focus_handle(&self, cx: &App) -> FocusHandle {
38        self.state.read(cx).focus_handle.clone()
39    }
40}
41
42impl Render for PivotGrid {
43    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
44        let state_canvas = self.state.clone();
45        let state_down = self.state.clone();
46        let state_move = self.state.clone();
47        let state_up = self.state.clone();
48        let state_scroll = self.state.clone();
49        let state_key = self.state.clone();
50        let state_right = self.state.clone();
51        let bg = self.state.read(cx).theme.bg;
52        let focus_handle = self.state.read(cx).focus_handle.clone();
53        let focus_down = focus_handle.clone();
54        let focus_right = focus_handle.clone();
55
56        div()
57            .size_full()
58            .relative()
59            .track_focus(&focus_handle)
60            .bg(bg)
61            .child(
62                canvas(
63                    move |bounds, window, cx| -> PivotPaintData {
64                        let viewport = window.viewport_size();
65                        state_canvas.update(cx, |s, cx| {
66                            let mut dirty = false;
67                            if s.bounds != bounds {
68                                s.bounds = bounds;
69                                s.clamp_scroll_to_bounds();
70                                dirty = true;
71                            }
72                            if s.window_viewport != viewport {
73                                s.window_viewport = viewport;
74                            }
75                            if dirty {
76                                cx.notify();
77                            }
78                        });
79                        let s = state_canvas.read(cx);
80                        PivotPaintData::from_state(s)
81                    },
82                    move |bounds, data, window, cx| {
83                        paint_pivot_grid(&data, window, cx, bounds);
84                    },
85                )
86                .size_full(),
87            )
88            .on_mouse_down(
89                MouseButton::Left,
90                move |event: &MouseDownEvent, window, cx| {
91                    window.focus(&focus_down);
92                    state_down.update(cx, |s, cx| {
93                        // A left-click reaching the grid means the pointer
94                        // was not on the menu overlay; dismiss it.
95                        s.menu = None;
96                        let rel = to_grid_relative(event.position, s.bounds.origin);
97                        // Double-click on a value cell drills through to the
98                        // flat grid, filtered to the cell's driving rows.
99                        if event.click_count >= 2 {
100                            if let PivotHitResult::Cell { row, col } = s.hit_test(rel) {
101                                s.request_drill_down(row, col);
102                                cx.notify();
103                                return;
104                            }
105                        }
106                        s.handle_mouse_down(rel, event.modifiers.shift);
107                        cx.notify();
108                    });
109                },
110            )
111            .on_mouse_down(
112                MouseButton::Right,
113                move |event: &MouseDownEvent, window, cx| {
114                    window.focus(&focus_right);
115                    state_right.update(cx, |s, cx| {
116                        let rel = to_grid_relative(event.position, s.bounds.origin);
117                        let hit = s.hit_test(rel);
118                        s.open_context_menu(hit, rel);
119                        cx.notify();
120                    });
121                },
122            )
123            .on_mouse_move(move |event: &MouseMoveEvent, _window, cx| {
124                state_move.update(cx, |s, cx| {
125                    let rel = to_grid_relative(event.position, s.bounds.origin);
126                    s.handle_mouse_move(rel, event.pressed_button == Some(MouseButton::Left));
127                    cx.notify();
128                });
129            })
130            .on_mouse_up(
131                MouseButton::Left,
132                move |_event: &MouseUpEvent, _window, cx| {
133                    state_up.update(cx, |s, cx| {
134                        s.handle_mouse_up();
135                        cx.notify();
136                    });
137                },
138            )
139            .on_scroll_wheel(move |event: &ScrollWheelEvent, _window, cx| {
140                state_scroll.update(cx, |s, cx| {
141                    let line_h = px(s.row_height);
142                    let delta = event.delta.pixel_delta(line_h);
143                    s.apply_scroll_delta(f32::from(delta.x), f32::from(delta.y));
144                    cx.notify();
145                });
146            })
147            .on_key_down(move |event: &KeyDownEvent, _window, cx| {
148                state_key.update(cx, |s, cx| {
149                    s.handle_key(&event.keystroke, cx);
150                    cx.notify();
151                });
152            })
153            .children(render_pivot_menu_overlay(&self.state, cx))
154    }
155}
156
157/// Build the pivot's right-click menu as a `deferred` + `anchored` overlay
158/// (same mechanism as the flat grid's menu, so it paints and receives events
159/// on top of everything). Returns `None` when no menu is open.
160fn render_pivot_menu_overlay(
161    state: &Entity<PivotState>,
162    cx: &mut Context<PivotGrid>,
163) -> Option<impl IntoElement + use<>> {
164    let s = state.read(cx);
165    let menu = s.menu.as_ref()?;
166    let theme = s.theme.clone();
167    let items = menu.items.clone();
168    let hovered = menu.hovered;
169    let cw = s.char_width;
170    let abs_x = f32::from(s.bounds.origin.x) + f32::from(menu.anchor.x);
171    let abs_y = f32::from(s.bounds.origin.y) + f32::from(menu.anchor.y);
172
173    let max_label_chars = items
174        .iter()
175        .filter_map(|item| match item {
176            PivotMenuItem::Action { label, .. } => Some(label.chars().count()),
177            PivotMenuItem::Separator => None,
178        })
179        .max()
180        .unwrap_or(0);
181    let menu_w = (max_label_chars as f32 * cw + MENU_PADDING_X * 2.0).max(160.0);
182
183    let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(items.len());
184    let mut action_idx = 0usize;
185    for item in &items {
186        match item {
187            PivotMenuItem::Separator => {
188                rows.push(
189                    div()
190                        .h(px(MENU_ITEM_HEIGHT))
191                        .flex()
192                        .items_center()
193                        .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
194                        .into_any_element(),
195                );
196            }
197            PivotMenuItem::Action { id, label } => {
198                let this_idx = action_idx;
199                action_idx += 1;
200                let id = id.clone();
201                let state_click = state.clone();
202                let state_hover = state.clone();
203                let mut row = div()
204                    .h(px(MENU_ITEM_HEIGHT))
205                    .px(px(MENU_PADDING_X))
206                    .flex()
207                    .items_center()
208                    .text_color(theme.menu_fg)
209                    .text_size(px(MENU_FONT_SIZE))
210                    .child(label.clone())
211                    .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
212                        state_hover.update(cx, |s, cx| {
213                            if let Some(m) = s.menu.as_mut() {
214                                if m.hovered != Some(this_idx) {
215                                    m.hovered = Some(this_idx);
216                                    cx.notify();
217                                }
218                            }
219                        });
220                    })
221                    .on_mouse_down(
222                        MouseButton::Left,
223                        move |_e: &MouseDownEvent, _window, cx| {
224                            state_click.update(cx, |s, cx| {
225                                if let Some(menu) = s.menu.take() {
226                                    s.execute_menu_action(&id, menu, cx);
227                                }
228                                cx.notify();
229                            });
230                        },
231                    );
232                if hovered == Some(this_idx) {
233                    row = row.bg(theme.menu_hover_bg);
234                }
235                rows.push(row.into_any_element());
236            }
237        }
238    }
239
240    let menu_body = div()
241        .flex()
242        .flex_col()
243        .w(px(menu_w))
244        .py(px(MENU_INNER_PAD))
245        .bg(theme.menu_bg)
246        .border_1()
247        .border_color(theme.grid_line)
248        .children(rows);
249
250    let state_backdrop = state.clone();
251    let overlay = deferred(anchored().position(point(px(abs_x), px(abs_y))).child(
252        div().occlude().child(menu_body).on_mouse_down_out(
253            move |_e: &MouseDownEvent, _window, cx| {
254                state_backdrop.update(cx, |s, cx| {
255                    if s.menu.is_some() {
256                        s.menu = None;
257                        cx.notify();
258                    }
259                });
260            },
261        ),
262    ))
263    .with_priority(PIVOT_MENU_PRIORITY);
264    Some(overlay)
265}