Skip to main content

datui_lib/app/
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::app::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::app::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    pub fn with(at: Position, extra: Vec<MenuItem>) -> Self {
105        let mut items = ITEMS.to_vec();
106        items.extend(extra);
107        Self {
108            at,
109            selected: 0,
110            items,
111        }
112    }
113
114    /// What choosing line `i` does.
115    pub fn chosen(&self, i: usize) -> Option<MenuKey> {
116        let item = self.items.get(i)?;
117        Some(match item.action {
118            Some(action) => MenuKey::Do(action),
119            None => MenuKey::Run(item.key_event()),
120        })
121    }
122
123    /// ↑ / ↓ (k / j) move, wrapping; Enter runs the line; Esc closes. Any other key,
124    /// a line's own included, leaves the menu and acts as typed.
125    pub fn key(&mut self, event: &KeyEvent) -> MenuKey {
126        let n = self.items.len();
127        if !event.modifiers.is_empty() {
128            return MenuKey::Other;
129        }
130        match event.code {
131            KeyCode::Up | KeyCode::Char('k') => {
132                self.selected = (self.selected + n - 1) % n;
133                MenuKey::Moved
134            }
135            KeyCode::Down | KeyCode::Char('j') => {
136                self.selected = (self.selected + 1) % n;
137                MenuKey::Moved
138            }
139            KeyCode::Enter => self.chosen(self.selected).unwrap_or(MenuKey::Close),
140            KeyCode::Esc => MenuKey::Close,
141            _ => MenuKey::Other,
142        }
143    }
144
145    /// Where the menu draws on `screen`: just below and right of the point, moved
146    /// left or up as far as it takes to stay on screen.
147    pub fn area(&self, screen: Rect) -> Rect {
148        let key_w = key_width(&self.items);
149        let label_w = self
150            .items
151            .iter()
152            .map(|item| crate::glyphs::display_width(item.label))
153            .max()
154            .unwrap_or(0);
155        // Border and gutter each side, the rail, the key, two spaces, the label.
156        let width = (4 + 1 + key_w + 2 + label_w) as u16;
157        let height = self.items.len() as u16 + 2;
158        let width = width.min(screen.width);
159        let height = height.min(screen.height);
160        let x = self
161            .at
162            .x
163            .saturating_add(1)
164            .min(screen.right().saturating_sub(width))
165            .max(screen.x);
166        let below = self.at.y.saturating_add(1);
167        let y = if below.saturating_add(height) <= screen.bottom() {
168            below
169        } else {
170            screen.bottom().saturating_sub(height).max(screen.y)
171        };
172        Rect::new(x, y, width, height)
173    }
174
175    /// Draw the menu over `screen` and record its lines for a click.
176    pub fn render(&self, screen: Rect, buf: &mut Buffer, ctx: &RenderContext) {
177        let area = self.area(screen);
178        pointer::record(area, Hit::Menu);
179        let content = Surface::new("").render(area, buf, ctx);
180        let g = crate::glyphs::get();
181        let key_w = key_width(&self.items);
182        for (i, item) in self.items.iter().enumerate().take(content.height as usize) {
183            let row = Rect {
184                y: content.y + i as u16,
185                height: 1,
186                ..content
187            };
188            let selected = i == self.selected;
189            let key = format!(
190                "{}{}",
191                item.key,
192                " ".repeat(key_w.saturating_sub(crate::glyphs::display_width(item.key)))
193            );
194            let line = Line::from(vec![
195                Span::styled(
196                    if selected { g.rail } else { " " },
197                    Style::default().fg(ctx.accent),
198                ),
199                Span::styled(key, Style::default().fg(ctx.keybind_hints)),
200                Span::raw("  "),
201                Span::styled(item.label, Style::default().fg(ctx.text_primary)),
202            ]);
203            let mut paragraph = Paragraph::new(line);
204            if selected {
205                paragraph = paragraph.style(ctx.highlight_style());
206            }
207            paragraph.render(row, buf);
208            pointer::record(row, Hit::MenuItem(i));
209        }
210    }
211}
212
213fn key_width(items: &[MenuItem]) -> usize {
214    items
215        .iter()
216        .map(|item| crate::glyphs::display_width(item.key))
217        .max()
218        .unwrap_or(0)
219}
220
221#[cfg(test)]
222mod tests;