1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum MenuAction {
29 ChangeType,
31 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
43pub 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
61pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ContextMenu {
83 pub at: Position,
84 pub selected: usize,
85 pub items: Vec<MenuItem>,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum MenuKey {
91 Moved,
93 Run(KeyEvent),
95 Do(MenuAction),
97 Close,
99 Other,
101}
102
103impl ContextMenu {
104 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 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 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 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 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 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 #[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 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 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 #[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}