1use 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
15const REACH: f32 = 28.;
17const ROW_GRIP_X: f32 = 8.;
20
21#[derive(Clone, Debug, PartialEq)]
23pub(crate) struct TableMenu {
24 pub(crate) table: BlockId,
25 pub(crate) line: TableLine,
26 pub(crate) at: Point<Pixels>,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq)]
32pub(crate) enum MenuItem {
33 Edit(TableEdit),
34}
35
36impl BlockEditorState {
37 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 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 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 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 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 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 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 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 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 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 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 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 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
235pub(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
259pub(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}