Skip to main content

markdown_editor/
table_ui.rs

1//! Hover-only table grips and their menu (D20). A grip shows above the
2//! hovered column's header cell and at the left edge of the hovered row;
3//! clicking it selects that column / row as `BlockSelection::Cells` and
4//! opens a small menu of table commands.
5
6use gpui::{Bounds, Context, Pixels, Point, point, px, size};
7
8use super::{
9    id::BlockId,
10    state::BlockEditorState,
11    table::{TableEdit, TableLine},
12};
13use gpui_kit::base::text::editor_host::ColumnAlign;
14
15/// Extra reach around a table where its grips still show.
16const REACH: f32 = 28.;
17/// Row grips sit this far left of the table frame, left of the strip that
18/// node-selects the table (`block_handle_at`, the 16px left of the cell text).
19const ROW_GRIP_X: f32 = 8.;
20
21/// The open grip menu.
22#[derive(Clone, Debug, PartialEq)]
23pub(crate) struct TableMenu {
24    pub(crate) table: BlockId,
25    pub(crate) line: TableLine,
26    /// Window point the menu hangs from (just below / right of the grip).
27    pub(crate) at: Point<Pixels>,
28}
29
30/// One menu entry.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub(crate) enum MenuItem {
33    Edit(TableEdit),
34}
35
36impl BlockEditorState {
37    /// The grips to show for the pointer at `at`: the hovered column's grip
38    /// above its header cell and the hovered row's grip at its left edge.
39    /// Column and row boxes come from the frame's rebuilt table grid
40    /// (`FrameLayout::layout_tables`), so they match the drawn cells for
41    /// every alignment, after scrolling and resizing.
42    pub fn table_grips_at(&self, at: Point<Pixels>) -> Vec<(BlockId, TableLine, Bounds<Pixels>)> {
43        let frame = self.frame.borrow();
44        let mut out = Vec::new();
45        for g in &frame.grids {
46            let b = g.bounds;
47            let reach = px(REACH);
48            if at.x < b.left() - reach
49                || at.x >= b.right() + px(8.)
50                || at.y < b.top() - reach
51                || at.y >= b.bottom() + px(8.)
52            {
53                continue;
54            }
55            let x = at.x.clamp(b.left(), b.right() - px(1.));
56            let y = at.y.clamp(b.top(), b.bottom() - px(1.));
57            let col = g
58                .cols
59                .iter()
60                .position(|(l, r)| x >= *l && x < *r)
61                .or_else(|| (x >= b.right() - px(2.)).then(|| g.cols.len() - 1))
62                .or_else(|| (x < g.cols[0].0).then_some(0));
63            if let Some(c) = col {
64                let (l, r) = g.cols[c];
65                let mid = (l + r) / 2.;
66                out.push((
67                    g.table,
68                    TableLine::Column(c),
69                    Bounds::new(point(mid - px(8.), b.top() - px(8.)), size(px(16.), px(4.))),
70                ));
71            }
72            let row = g
73                .rows
74                .iter()
75                .position(|(t, bt)| y >= *t && y < *bt)
76                .or_else(|| (y >= b.bottom() - px(2.)).then(|| g.rows.len() - 1))
77                .or_else(|| (y < g.rows[0].0).then_some(0));
78            if let Some(r) = row {
79                let (t, bt) = g.rows[r];
80                let h = px(16.).min(bt - t);
81                let mid = (t + bt) / 2.;
82                out.push((
83                    g.table,
84                    TableLine::Row(r),
85                    Bounds::new(
86                        point(b.left() - px(ROW_GRIP_X), mid - h / 2.),
87                        size(px(4.), h),
88                    ),
89                ));
90            }
91            break;
92        }
93        out
94    }
95
96    /// The grip under `at`, if the pointer is on one (a little slack).
97    pub(crate) fn table_grip_hit(
98        &self,
99        at: Point<Pixels>,
100    ) -> Option<(BlockId, TableLine, Bounds<Pixels>)> {
101        self.table_grips_at(at).into_iter().find(|(_, line, b)| {
102            // Row grips get vertical slack only, so the strip right of
103            // them still node-selects the table.
104            let slack = match line {
105                TableLine::Row(_) => Bounds::new(
106                    point(b.left() - px(4.), b.top() - px(3.)),
107                    size(b.size.width + px(6.), b.size.height + px(6.)),
108                ),
109                TableLine::Column(_) => b.dilate(px(5.)),
110            };
111            slack.contains(&at)
112        })
113    }
114
115    /// Click on a grip: select its row / column and open the menu.
116    pub(crate) fn click_table_grip(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) -> bool {
117        let Some((table, line, grip)) = self.table_grip_hit(at) else {
118            return false;
119        };
120        self.open_table_menu(table, line, grip, cx);
121        true
122    }
123
124    /// Select `line` of `table` and open its menu below / beside `grip`.
125    pub fn open_table_menu(
126        &mut self,
127        table: BlockId,
128        line: TableLine,
129        grip: Bounds<Pixels>,
130        cx: &mut Context<Self>,
131    ) {
132        if self.model.select_table_line(table, line).unwrap_or(false) {
133            let at = match line {
134                TableLine::Column(_) => point(grip.left(), grip.bottom() + px(6.)),
135                TableLine::Row(_) => point(grip.right() + px(6.), grip.top()),
136            };
137            self.table_menu = Some(TableMenu { table, line, at });
138            cx.notify();
139        }
140    }
141
142    /// Open the menu of `line` of `table` (the grip position from the last
143    /// frame). For tests and hosts that drive the menu themselves.
144    pub fn open_table_menu_for(&mut self, table: BlockId, line: TableLine, cx: &mut Context<Self>) {
145        let grip = self
146            .frame
147            .borrow()
148            .tables
149            .iter()
150            .find(|(t, _)| *t == table)
151            .map(|(_, b)| *b)
152            .unwrap_or_default();
153        self.open_table_menu(table, line, grip, cx);
154    }
155
156    /// Where the open menu hangs from (window coordinates).
157    pub fn table_menu_anchor(&self) -> Option<Point<Pixels>> {
158        self.table_menu.as_ref().map(|m| m.at)
159    }
160
161    pub fn table_menu_open(&self) -> bool {
162        self.table_menu.is_some()
163    }
164
165    pub(crate) fn close_table_menu(&mut self, cx: &mut Context<Self>) {
166        if self.table_menu.take().is_some() {
167            cx.notify();
168        }
169    }
170
171    /// Whether a menu entry applies; `Err` carries why it is disabled.
172    pub(crate) fn table_menu_item_state(&self, item: MenuItem) -> Result<(), &'static str> {
173        let Some(menu) = &self.table_menu else {
174            return Err("no menu");
175        };
176        match (menu.line, item) {
177            (TableLine::Row(0), MenuItem::Edit(TableEdit::RemoveRow)) => {
178                Err("The header row can't be deleted")
179            }
180            _ => Ok(()),
181        }
182    }
183
184    /// The current alignment of the menu's column.
185    pub(crate) fn table_menu_align(&self) -> Option<ColumnAlign> {
186        let menu = self.table_menu.as_ref()?;
187        let TableLine::Column(c) = menu.line else {
188            return None;
189        };
190        match self.model.doc.kind(menu.table) {
191            Some(super::kind::BlockKindData::Table { aligns }) => {
192                Some(aligns.get(c).copied().unwrap_or_default())
193            }
194            _ => None,
195        }
196    }
197
198    /// Run a menu entry on the menu's row / column (one undo step) and close
199    /// the menu, except for alignment, which keeps it open to show the change.
200    pub fn table_menu_action(&mut self, edit: TableEdit, cx: &mut Context<Self>) {
201        let Some(menu) = self.table_menu.clone() else {
202            return;
203        };
204        if self.table_menu_item_state(MenuItem::Edit(edit)).is_err() {
205            return;
206        }
207        // The selection may have moved since the menu opened.
208        let _ = self.model.select_table_line(menu.table, menu.line);
209        self.model.break_coalescing();
210        let _ = self.run(super::command::EditorCommand::EditTable(edit), cx);
211        self.model.break_coalescing();
212        if !matches!(edit, TableEdit::SetColumnAlign(_)) {
213            self.table_menu = None;
214        }
215        cx.notify();
216    }
217
218    /// Track which grips the pointer shows; repaint when that changes.
219    pub(crate) fn hover_table(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
220        let hovered: Vec<(BlockId, TableLine)> = self
221            .table_grips_at(at)
222            .into_iter()
223            .map(|(t, l, _)| (t, l))
224            .collect();
225        // Table grips, and the drag handles of tables and code blocks (D22).
226        let on_grip = self.table_grip_hit(at).is_some() || self.drag_handle_at(at).is_some();
227        if hovered != self.table_hover || on_grip != self.grip_hover {
228            self.table_hover = hovered;
229            self.grip_hover = on_grip;
230            cx.notify();
231        }
232    }
233}
234
235/// Shortcut text for a menu entry (Cmd on macOS, Ctrl elsewhere).
236pub(crate) fn shortcut(edit: TableEdit) -> Option<String> {
237    let (m, alt) = if cfg!(target_os = "macos") {
238        ("⌘", "⌥")
239    } else {
240        ("Ctrl+", "Alt+")
241    };
242    let key = match edit {
243        TableEdit::AddColumnLeft => "←",
244        TableEdit::AddColumnRight => "→",
245        TableEdit::AddRowAbove => "↑",
246        TableEdit::AddRowBelow => "↓",
247        TableEdit::RemoveColumn | TableEdit::RemoveRow => {
248            if cfg!(target_os = "macos") {
249                "⌫"
250            } else {
251                "Backspace"
252            }
253        }
254        TableEdit::SetColumnAlign(_) => return None,
255    };
256    Some(format!("{m}{alt}{key}"))
257}
258
259/// The open grip menu as a deferred, window-anchored popover.
260pub(crate) fn render_menu(
261    state: &gpui::Entity<BlockEditorState>,
262    cx: &gpui::App,
263) -> Option<gpui::AnyElement> {
264    use gpui::prelude::FluentBuilder as _;
265    use gpui::{
266        InteractiveElement as _, IntoElement as _, ParentElement as _, Styled as _, anchored,
267        deferred, div,
268    };
269    use gpui_kit::component::ActiveTheme as _;
270
271    let s = state.read(cx);
272    let menu = s.table_menu.clone()?;
273    let theme = cx.theme();
274    let (fg, muted, hover, border, bg, accent) = (
275        theme.popover_foreground,
276        theme.muted_foreground,
277        theme.list_hover,
278        theme.border,
279        theme.popover,
280        theme.accent,
281    );
282    let item = |id: &'static str, label: &'static str, edit: TableEdit| {
283        let enabled = s.table_menu_item_state(MenuItem::Edit(edit));
284        let entity = state.clone();
285        div()
286            .id(id)
287            .flex()
288            .flex_col()
289            .px_2()
290            .py_1()
291            .rounded(px(4.))
292            .child(
293                div()
294                    .flex()
295                    .justify_between()
296                    .gap_4()
297                    .child(div().child(label))
298                    .when_some(shortcut(edit), |this, keys| {
299                        this.child(div().text_color(muted).child(keys))
300                    }),
301            )
302            .map(|this| match enabled {
303                Ok(()) => this
304                    .text_color(fg)
305                    .hover(move |s| s.bg(hover))
306                    .on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| {
307                        cx.stop_propagation();
308                        entity.update(cx, |state, cx| state.table_menu_action(edit, cx));
309                    }),
310                Err(why) => this.text_color(muted).child(div().text_xs().child(why)),
311            })
312    };
313    let items: Vec<gpui::AnyElement> = match menu.line {
314        TableLine::Column(_) => vec![
315            item(
316                "tm-col-left",
317                "Insert column left",
318                TableEdit::AddColumnLeft,
319            )
320            .into_any_element(),
321            item(
322                "tm-col-right",
323                "Insert column right",
324                TableEdit::AddColumnRight,
325            )
326            .into_any_element(),
327            item("tm-col-delete", "Delete column", TableEdit::RemoveColumn).into_any_element(),
328        ],
329        TableLine::Row(_) => vec![
330            item("tm-row-above", "Insert row above", TableEdit::AddRowAbove).into_any_element(),
331            item("tm-row-below", "Insert row below", TableEdit::AddRowBelow).into_any_element(),
332            item("tm-row-delete", "Delete row", TableEdit::RemoveRow).into_any_element(),
333        ],
334    };
335    let current = s.table_menu_align();
336    let align_row = current.map(|current| {
337        let choice = |id: &'static str, label: &'static str, align: ColumnAlign| {
338            let entity = state.clone();
339            let on = current == align;
340            div()
341                .id(id)
342                .px_2()
343                .py_0p5()
344                .rounded(px(4.))
345                .border_1()
346                .border_color(if on { accent } else { border })
347                .when(on, |this| this.bg(hover))
348                .text_color(fg)
349                .hover(move |s| s.bg(hover))
350                .child(label)
351                .on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| {
352                    cx.stop_propagation();
353                    entity.update(cx, |state, cx| {
354                        state.table_menu_action(TableEdit::SetColumnAlign(align), cx)
355                    });
356                })
357        };
358        div()
359            .flex()
360            .flex_col()
361            .gap_1()
362            .px_2()
363            .pt_1()
364            .mt_1()
365            .border_t_1()
366            .border_color(border)
367            .child(div().text_color(muted).text_xs().child("Alignment"))
368            .child(
369                div()
370                    .flex()
371                    .gap_1()
372                    .child(choice("tm-align-left", "Left", ColumnAlign::Left))
373                    .child(choice("tm-align-center", "Centre", ColumnAlign::Center))
374                    .child(choice("tm-align-right", "Right", ColumnAlign::Right))
375                    .child(choice("tm-align-none", "None", ColumnAlign::None)),
376            )
377    });
378    let entity = state.clone();
379    Some(
380        deferred(
381            anchored()
382                .position(menu.at)
383                .position_mode(gpui::AnchoredPositionMode::Window)
384                .snap_to_window_with_margin(px(8.))
385                .child(
386                    div()
387                        .id("block-editor-table-menu")
388                        .occlude()
389                        .min_w(px(220.))
390                        .p_1()
391                        .bg(bg)
392                        .text_color(fg)
393                        .text_sm()
394                        .border_1()
395                        .border_color(border)
396                        .rounded(px(6.))
397                        .shadow_md()
398                        .on_mouse_down_out(move |_, _, cx| {
399                            entity.update(cx, |state, cx| state.close_table_menu(cx));
400                        })
401                        .children(items)
402                        .children(align_row),
403                ),
404        )
405        .with_priority(1)
406        .into_any_element(),
407    )
408}