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 animations = s.animations;
168    let items = menu.items.clone();
169    let hovered = menu.hovered;
170    let cw = s.char_width;
171    let abs_x = f32::from(s.bounds.origin.x) + f32::from(menu.anchor.x);
172    let abs_y = f32::from(s.bounds.origin.y) + f32::from(menu.anchor.y);
173
174    let max_label_chars = items
175        .iter()
176        .filter_map(|item| match item {
177            PivotMenuItem::Action { label, .. } => Some(label.chars().count()),
178            PivotMenuItem::Separator => None,
179        })
180        .max()
181        .unwrap_or(0);
182    let menu_w = (max_label_chars as f32 * cw + MENU_PADDING_X * 2.0).max(160.0);
183
184    let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(items.len());
185    let mut action_idx = 0usize;
186    for item in &items {
187        match item {
188            PivotMenuItem::Separator => {
189                rows.push(
190                    div()
191                        .h(px(MENU_ITEM_HEIGHT))
192                        .flex()
193                        .items_center()
194                        .child(div().mx(px(4.0)).h(px(1.0)).w_full().bg(theme.grid_line))
195                        .into_any_element(),
196                );
197            }
198            PivotMenuItem::Action { id, label } => {
199                let this_idx = action_idx;
200                action_idx += 1;
201                let id = id.clone();
202                let state_click = state.clone();
203                let state_hover = state.clone();
204                let mut row = div()
205                    .h(px(MENU_ITEM_HEIGHT))
206                    .px(px(MENU_PADDING_X))
207                    .flex()
208                    .items_center()
209                    .text_color(theme.menu_fg)
210                    .text_size(px(MENU_FONT_SIZE))
211                    .child(label.clone())
212                    .on_mouse_move(move |_e: &MouseMoveEvent, _window, cx| {
213                        state_hover.update(cx, |s, cx| {
214                            if let Some(m) = s.menu.as_mut() {
215                                if m.hovered != Some(this_idx) {
216                                    m.hovered = Some(this_idx);
217                                    cx.notify();
218                                }
219                            }
220                        });
221                    })
222                    .on_mouse_down(
223                        MouseButton::Left,
224                        move |_e: &MouseDownEvent, _window, cx| {
225                            state_click.update(cx, |s, cx| {
226                                if let Some(menu) = s.menu.take() {
227                                    s.execute_menu_action(&id, menu, cx);
228                                }
229                                cx.notify();
230                            });
231                        },
232                    );
233                if hovered == Some(this_idx) {
234                    row = row.bg(theme.menu_hover_bg);
235                }
236                rows.push(row.into_any_element());
237            }
238        }
239    }
240
241    let menu_body = div()
242        .flex()
243        .flex_col()
244        .w(px(menu_w))
245        .py(px(MENU_INNER_PAD))
246        .bg(theme.menu_bg)
247        .border_1()
248        .border_color(theme.grid_line)
249        .children(rows);
250
251    let state_backdrop = state.clone();
252    let overlay = deferred(
253        anchored().position(point(px(abs_x), px(abs_y))).child(
254            div()
255                .occlude()
256                .child(crate::grid::motion::pop_in(
257                    menu_body,
258                    "pivot-context-menu",
259                    animations,
260                ))
261                .on_mouse_down_out(move |_e: &MouseDownEvent, _window, cx| {
262                    state_backdrop.update(cx, |s, cx| {
263                        if s.menu.is_some() {
264                            s.menu = None;
265                            cx.notify();
266                        }
267                    });
268                }),
269        ),
270    )
271    .with_priority(PIVOT_MENU_PRIORITY);
272    Some(overlay)
273}