Skip to main content

datui_lib/
context_menu.rs

1//! The menu a right click on a table cell opens: the keys that act on a cell, each
2//! line naming its key. A line chosen closes the menu and presses its key, so the
3//! menu does exactly what typing the key does. A menu is not a dialog: a click
4//! anywhere else closes it.
5
6use crossterm::event::{KeyCode, KeyEvent};
7use ratatui::buffer::Buffer;
8use ratatui::layout::{Position, Rect};
9use ratatui::style::Style;
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Paragraph, Widget};
12
13use crate::pointer::{self, Hit};
14use crate::render::context::RenderContext;
15use crate::widgets::ui::Surface;
16
17/// One line: the key it presses, as the key registry spells it, and what it does
18/// to the cell. A line with no key (`""`) does its [`MenuAction`] instead.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct MenuItem {
21    pub key: &'static str,
22    pub label: &'static str,
23    pub action: Option<MenuAction>,
24}
25
26/// What a line with no key of its own does to the cursor's column.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum MenuAction {
29    /// The column's type: the same names and formats a spec's `type` takes.
30    ChangeType,
31    /// A datetime made from a date, a time and an offset, as a spec's `as` makes one.
32    CombineDatetime,
33}
34
35const fn keyed(key: &'static str, label: &'static str) -> MenuItem {
36    MenuItem {
37        key,
38        label,
39        action: None,
40    }
41}
42
43/// The lines for the cursor's column that no key reaches: its type, and, on a text,
44/// date or time column, a datetime made from it.
45pub fn column_items(combine: bool) -> Vec<MenuItem> {
46    let mut items = vec![MenuItem {
47        key: "",
48        label: "Change type...",
49        action: Some(MenuAction::ChangeType),
50    }];
51    if combine {
52        items.push(MenuItem {
53            key: "",
54            label: "Combine into datetime...",
55            action: Some(MenuAction::CombineDatetime),
56        });
57    }
58    items
59}
60
61/// The cell's keys, in the order the menu lists them.
62pub const ITEMS: &[MenuItem] = &[
63    keyed("+", "Filter to this value"),
64    keyed("-", "Filter out this value"),
65    keyed("F", "Value counts"),
66    keyed("[", "Sort ascending"),
67    keyed("]", "Sort descending"),
68    keyed("y", "Copy"),
69    keyed("Space", "Inspect row"),
70];
71
72impl MenuItem {
73    /// The key this line presses, as typed.
74    pub fn key_event(&self) -> KeyEvent {
75        let chord = datui_cli::keys::chord(self.key).expect("a menu key is one key");
76        crate::help::key_event(chord)
77    }
78}
79
80/// An open menu: where it was asked for, its lines and the one the cursor is on.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ContextMenu {
83    pub at: Position,
84    pub selected: usize,
85    pub items: Vec<MenuItem>,
86}
87
88/// What a key does in the open menu.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum MenuKey {
91    /// Moved the cursor.
92    Moved,
93    /// Close the menu and press this key.
94    Run(KeyEvent),
95    /// Close the menu and do this.
96    Do(MenuAction),
97    /// Esc: close it.
98    Close,
99    /// Not the menu's: close it, and the key acts as it would have.
100    Other,
101}
102
103impl ContextMenu {
104    /// The cell's keys, then `extra`.
105    pub fn new(at: Position) -> Self {
106        Self::with(at, Vec::new())
107    }
108
109    pub fn with(at: Position, extra: Vec<MenuItem>) -> Self {
110        let mut items = ITEMS.to_vec();
111        items.extend(extra);
112        Self {
113            at,
114            selected: 0,
115            items,
116        }
117    }
118
119    /// What choosing line `i` does.
120    pub fn chosen(&self, i: usize) -> Option<MenuKey> {
121        let item = self.items.get(i)?;
122        Some(match item.action {
123            Some(action) => MenuKey::Do(action),
124            None => MenuKey::Run(item.key_event()),
125        })
126    }
127
128    /// ↑ / ↓ (k / j) move, wrapping; Enter runs the line; Esc closes. Any other key,
129    /// a line's own included, leaves the menu and acts as typed.
130    pub fn key(&mut self, event: &KeyEvent) -> MenuKey {
131        let n = self.items.len();
132        if !event.modifiers.is_empty() {
133            return MenuKey::Other;
134        }
135        match event.code {
136            KeyCode::Up | KeyCode::Char('k') => {
137                self.selected = (self.selected + n - 1) % n;
138                MenuKey::Moved
139            }
140            KeyCode::Down | KeyCode::Char('j') => {
141                self.selected = (self.selected + 1) % n;
142                MenuKey::Moved
143            }
144            KeyCode::Enter => self.chosen(self.selected).unwrap_or(MenuKey::Close),
145            KeyCode::Esc => MenuKey::Close,
146            _ => MenuKey::Other,
147        }
148    }
149
150    /// Where the menu draws on `screen`: just below and right of the point, moved
151    /// left or up as far as it takes to stay on screen.
152    pub fn area(&self, screen: Rect) -> Rect {
153        let key_w = key_width(&self.items);
154        let label_w = self
155            .items
156            .iter()
157            .map(|item| crate::glyphs::display_width(item.label))
158            .max()
159            .unwrap_or(0);
160        // Border and gutter each side, the rail, the key, two spaces, the label.
161        let width = (4 + 1 + key_w + 2 + label_w) as u16;
162        let height = self.items.len() as u16 + 2;
163        let width = width.min(screen.width);
164        let height = height.min(screen.height);
165        let x = self
166            .at
167            .x
168            .saturating_add(1)
169            .min(screen.right().saturating_sub(width))
170            .max(screen.x);
171        let below = self.at.y.saturating_add(1);
172        let y = if below.saturating_add(height) <= screen.bottom() {
173            below
174        } else {
175            screen.bottom().saturating_sub(height).max(screen.y)
176        };
177        Rect::new(x, y, width, height)
178    }
179
180    /// Draw the menu over `screen` and record its lines for a click.
181    pub fn render(&self, screen: Rect, buf: &mut Buffer, ctx: &RenderContext) {
182        let area = self.area(screen);
183        pointer::record(area, Hit::Menu);
184        let content = Surface::new("").render(area, buf, ctx);
185        let g = crate::glyphs::get();
186        let key_w = key_width(&self.items);
187        for (i, item) in self.items.iter().enumerate().take(content.height as usize) {
188            let row = Rect {
189                y: content.y + i as u16,
190                height: 1,
191                ..content
192            };
193            let selected = i == self.selected;
194            let key = format!(
195                "{}{}",
196                item.key,
197                " ".repeat(key_w.saturating_sub(crate::glyphs::display_width(item.key)))
198            );
199            let line = Line::from(vec![
200                Span::styled(
201                    if selected { g.rail } else { " " },
202                    Style::default().fg(ctx.accent),
203                ),
204                Span::styled(key, Style::default().fg(ctx.keybind_hints)),
205                Span::raw("  "),
206                Span::styled(item.label, Style::default().fg(ctx.text_primary)),
207            ]);
208            let mut paragraph = Paragraph::new(line);
209            if selected {
210                paragraph = paragraph.style(ctx.highlight_style());
211            }
212            paragraph.render(row, buf);
213            pointer::record(row, Hit::MenuItem(i));
214        }
215    }
216}
217
218fn key_width(items: &[MenuItem]) -> usize {
219    items
220        .iter()
221        .map(|item| crate::glyphs::display_width(item.key))
222        .max()
223        .unwrap_or(0)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crossterm::event::KeyModifiers;
230
231    /// Every line's key is a key of the table's in the key registry, so the menu
232    /// cannot offer a key the table does not take.
233    #[test]
234    fn every_line_presses_a_table_key_from_the_registry() {
235        let table = datui_cli::keys::screen(datui_cli::keys::Context::Table);
236        let table_keys: Vec<_> = table
237            .groups
238            .iter()
239            .flat_map(|g| g.keys.iter())
240            .flat_map(|k| datui_cli::keys::chords(k.keys))
241            .collect();
242        for item in ITEMS {
243            assert!(item.action.is_none());
244            let chord = datui_cli::keys::chord(item.key).expect("one key");
245            assert!(
246                table_keys.contains(&chord),
247                "{} is not a table key in the registry",
248                item.key
249            );
250        }
251    }
252
253    #[test]
254    fn the_arrows_move_round_and_enter_runs_the_line() {
255        let mut menu = ContextMenu::new(Position { x: 0, y: 0 });
256        let press = |code| KeyEvent::new(code, KeyModifiers::NONE);
257        assert_eq!(menu.key(&press(KeyCode::Up)), MenuKey::Moved);
258        assert_eq!(menu.selected, ITEMS.len() - 1, "↑ from the top wraps");
259        assert_eq!(
260            menu.key(&press(KeyCode::Enter)),
261            MenuKey::Run(press(KeyCode::Char(' ')))
262        );
263        menu.key(&press(KeyCode::Char('j')));
264        assert_eq!(menu.selected, 0);
265        assert_eq!(
266            menu.key(&press(KeyCode::Enter)),
267            MenuKey::Run(press(KeyCode::Char('+')))
268        );
269        assert_eq!(menu.key(&press(KeyCode::Esc)), MenuKey::Close);
270        assert_eq!(menu.key(&press(KeyCode::Char('x'))), MenuKey::Other);
271        let shifted = KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT);
272        assert_eq!(menu.key(&shifted), MenuKey::Other, "Shift+↓ is the table's");
273    }
274
275    #[test]
276    fn the_menu_opens_at_the_point_and_stays_on_screen() {
277        let screen = Rect::new(0, 0, 80, 24);
278        let at = |x, y| ContextMenu::new(Position { x, y }).area(screen);
279        let menu = at(10, 5);
280        assert_eq!((menu.x, menu.y), (11, 6));
281        assert_eq!(menu.height, ITEMS.len() as u16 + 2);
282        // At the right and bottom edges it moves left and up, whole.
283        let corner = at(79, 23);
284        assert_eq!(corner.right(), 80);
285        assert_eq!(corner.bottom(), 24);
286        assert_eq!(corner.width, menu.width);
287        // A screen smaller than the menu: it fits as much as there is.
288        let tiny = ContextMenu::new(Position { x: 3, y: 3 }).area(Rect::new(0, 0, 12, 5));
289        assert_eq!(tiny, Rect::new(0, 0, 12, 5));
290    }
291
292    #[test]
293    fn each_line_shows_its_key_and_the_cursor_line_carries_the_rail() {
294        let ctx = RenderContext::for_test();
295        let screen = Rect::new(0, 0, 60, 20);
296        let mut buf = Buffer::empty(screen);
297        let menu = ContextMenu {
298            selected: 2,
299            ..ContextMenu::new(Position { x: 2, y: 2 })
300        };
301        menu.render(screen, &mut buf, &ctx);
302        let area = menu.area(screen);
303        let lines: Vec<String> = (area.y..area.bottom())
304            .map(|y| {
305                (area.x..area.right())
306                    .map(|x| buf[(x, y)].symbol())
307                    .collect()
308            })
309            .collect();
310        let g = crate::glyphs::get();
311        assert!(
312            lines[1].contains("+      Filter to this value"),
313            "{lines:?}"
314        );
315        assert!(lines[3].contains(&format!("{}F", g.rail)), "{lines:?}");
316        assert!(lines[7].contains("Space  Inspect row"), "{lines:?}");
317    }
318
319    /// The column's lines have no key: choosing one does its action.
320    #[test]
321    fn a_column_line_does_its_action() {
322        let mut menu = ContextMenu::with(Position { x: 0, y: 0 }, column_items(true));
323        assert_eq!(menu.items.len(), ITEMS.len() + 2);
324        menu.selected = ITEMS.len();
325        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
326        assert_eq!(menu.key(&enter), MenuKey::Do(MenuAction::ChangeType));
327        assert_eq!(
328            menu.chosen(ITEMS.len() + 1),
329            Some(MenuKey::Do(MenuAction::CombineDatetime))
330        );
331        assert_eq!(column_items(false).len(), 1, "no datetime from a number");
332    }
333}