返回 CodeWhale
context_menu.rs
根目录 / crates / tui / src / tui / context_menu.rs
1 //! Right-click context menu for mouse-captured TUI sessions.
2 //!
3 //! v0.9.1: elevated, lightly rounded surface with leading glyphs, section
4 //! grouping, right-aligned key-hint chips, hover-follow, and a primary action
5 //! focused by default. Reduced motion opens instantly (no appear frames).
6
7 use std::cell::Cell;
8 use std::time::Instant;
9
10 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
11 use ratatui::{
12 buffer::Buffer,
13 layout::Rect,
14 style::{Modifier, Style},
15 text::{Line, Span},
16 widgets::{Clear, Paragraph, Widget},
17 };
18 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
19
20 use crate::tui::menu_style;
21 use crate::tui::ocean;
22 use crate::tui::views::{ContextMenuAction, ModalKind, ModalView, ViewAction, ViewEvent};
23 use codewhale_palette as palette;
24
25 #[derive(Debug, Clone)]
26 pub struct ContextMenuEntry {
27 pub label: String,
28 pub description: String,
29 pub action: ContextMenuAction,
30 /// Leading glyph / icon (e.g. "⎘", "↗", "⌥").
31 pub glyph: String,
32 /// Right-aligned key that runs this entry (e.g. "y", "1"). Every hint
33 /// shown is live: `handle_key` runs the entry whose hint matches.
34 pub hint: String,
35 /// When true, starts a new visual section above this entry.
36 pub section_start: bool,
37 /// Primary (most likely) action — focused by default and accent-styled.
38 pub primary: bool,
39 /// Destructive entries are confirmed in place: the first activation arms
40 /// the row and swaps its label for this text, the second runs it.
41 pub confirm_label: Option<String>,
42 }
43
44 impl ContextMenuEntry {
45 pub fn new(
46 label: impl Into<String>,
47 description: impl Into<String>,
48 action: ContextMenuAction,
49 ) -> Self {
50 Self {
51 label: label.into(),
52 description: description.into(),
53 action,
54 glyph: String::new(),
55 hint: String::new(),
56 section_start: false,
57 primary: false,
58 confirm_label: None,
59 }
60 }
61
62 #[must_use]
63 pub fn with_glyph(mut self, glyph: impl Into<String>) -> Self {
64 self.glyph = glyph.into();
65 self
66 }
67
68 #[must_use]
69 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
70 self.hint = hint.into();
71 self
72 }
73
74 #[must_use]
75 pub fn section_start(mut self) -> Self {
76 self.section_start = true;
77 self
78 }
79
80 #[must_use]
81 pub fn primary(mut self) -> Self {
82 self.primary = true;
83 self
84 }
85
86 /// Require a second activation. `armed_label` replaces the row label
87 /// while the entry waits for it, and says what confirms it ("Stop agent:
88 /// Enter or click again to confirm").
89 #[must_use]
90 /// A `{label}` in `armed_label` becomes this row's label (without a
91 /// trailing `…`), so the armed row still names what it will do.
92 pub fn confirm(mut self, armed_label: impl Into<String>) -> Self {
93 let label = self.label.trim_end_matches('…').trim_end();
94 self.confirm_label = Some(armed_label.into().replace("{label}", label));
95 self
96 }
97 }
98
99 /// Keys the menu itself uses; never handed out as entry hints.
100 const RESERVED_KEYS: [char; 3] = ['j', 'k', 'q'];
101 /// Letters handed to entries past the ninth, after digits run out.
102 const BACKFILL_LETTERS: &str = "abcdfghilmnorstuvwxz";
103 /// A second click on an armed row sooner than this is the tail of a
104 /// double-click, not a decision, so it does not confirm. Same window the
105 /// composer uses to tell a double-click from two clicks.
106 const CONFIRM_CLICK_GUARD: std::time::Duration =
107 std::time::Duration::from_millis(crate::tui::mouse_ui::DOUBLE_CLICK_MS);
108
109 /// One painted row of the menu body, top to bottom.
110 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
111 enum MenuRow {
112 Title,
113 Divider,
114 Entry(usize),
115 MoreAbove(usize),
116 MoreBelow(usize),
117 Detail,
118 }
119
120 pub struct ContextMenuView {
121 entries: Vec<ContextMenuEntry>,
122 selected: usize,
123 /// Entry waiting for its confirming second activation.
124 armed: Option<usize>,
125 /// When `armed` was set, so a double-click cannot arm and confirm a
126 /// destructive row in one gesture.
127 armed_at: Option<Instant>,
128 /// First entry drawn when the menu is taller than the screen. Kept in a
129 /// `Cell` because render (`&self`) is where the height is known.
130 scroll: Cell<usize>,
131 column: u16,
132 row: u16,
133 last_rect: Cell<Option<Rect>>,
134 title: String,
135 opened_at: Instant,
136 reduced_motion: bool,
137 }
138
139 impl ContextMenuView {
140 pub fn new_with_motion(
141 entries: Vec<ContextMenuEntry>,
142 column: u16,
143 row: u16,
144 title: String,
145 reduced_motion: bool,
146 ) -> Self {
147 // Focus the primary action by default when present.
148 let selected = entries.iter().position(|e| e.primary).unwrap_or(0);
149 let mut entries = entries;
150 assign_hints(&mut entries);
151 for entry in &mut entries {
152 if entry.glyph.is_empty() {
153 entry.glyph = default_glyph_for(&entry.action);
154 }
155 }
156 Self {
157 entries,
158 selected,
159 armed: None,
160 armed_at: None,
161 scroll: Cell::new(0),
162 column,
163 row,
164 last_rect: Cell::new(None),
165 title,
166 opened_at: Instant::now(),
167 reduced_motion,
168 }
169 }
170
171 fn move_selection(&mut self, delta: isize) {
172 self.armed = None;
173 self.selected = crate::tui::list_nav::wrap_index(self.selected, self.entries.len(), delta);
174 }
175
176 /// Run entry `idx`, or arm it when it needs confirming. `confirm` is false
177 /// for a hint key: a letter arms a destructive row but never confirms it.
178 fn activate(&mut self, idx: usize, confirm: bool) -> ViewAction {
179 let Some(entry) = self.entries.get(idx) else {
180 return ViewAction::None;
181 };
182 if self.selected != idx {
183 self.armed = None;
184 }
185 self.selected = idx;
186 if entry.confirm_label.is_some() && !(confirm && self.armed == Some(idx)) {
187 if self.armed != Some(idx) {
188 self.armed_at = Some(Instant::now());
189 }
190 self.armed = Some(idx);
191 return ViewAction::None;
192 }
193 ViewAction::EmitAndClose(ViewEvent::ContextMenuSelected {
194 action: entry.action.clone(),
195 })
196 }
197
198 fn label_for(&self, idx: usize) -> &str {
199 let entry = &self.entries[idx];
200 match (&entry.confirm_label, self.armed == Some(idx)) {
201 (Some(armed), true) => armed.as_str(),
202 _ => entry.label.as_str(),
203 }
204 }
205
206 fn menu_width(&self, area_width: u16) -> u16 {
207 let widest = self
208 .entries
209 .iter()
210 .map(|entry| {
211 let label_width = UnicodeWidthStr::width(entry.label.as_str()).max(
212 entry
213 .confirm_label
214 .as_deref()
215 .map_or(0, UnicodeWidthStr::width),
216 );
217 let action_width = UnicodeWidthStr::width(entry.glyph.as_str())
218 .saturating_add(label_width)
219 .saturating_add(UnicodeWidthStr::width(entry.hint.as_str()))
220 .saturating_add(7);
221 let detail_width =
222 UnicodeWidthStr::width(entry.description.as_str()).saturating_add(4);
223 action_width.max(detail_width)
224 })
225 .max()
226 .unwrap_or(20)
227 .max(UnicodeWidthStr::width(self.title.as_str()).saturating_add(4));
228 let width = u16::try_from(widest.clamp(22, 56)).unwrap_or(56);
229 width.min(area_width.max(1))
230 }
231
232 fn has_detail(&self) -> bool {
233 self.entries.iter().any(|e| !e.description.is_empty())
234 }
235
236 fn visual_row_count(&self) -> usize {
237 // title + entries + section dividers + a stable selected-detail row
238 let dividers = self
239 .entries
240 .iter()
241 .enumerate()
242 .filter(|(idx, e)| e.section_start && *idx > 0)
243 .count();
244 self.entries
245 .len()
246 .saturating_add(usize::from(!self.title.is_empty()))
247 .saturating_add(dividers)
248 .saturating_add(usize::from(self.has_detail()) * 2)
249 }
250
251 fn menu_rect(&self, area: Rect) -> Rect {
252 let width = self.menu_width(area.width);
253 // +2: the top rail and one row of bottom padding.
254 let desired_height =
255 u16::try_from(self.visual_row_count().saturating_add(2)).unwrap_or(u16::MAX);
256 let height = desired_height.min(area.height.max(1));
257 let max_x = area.right().saturating_sub(width).max(area.x);
258 let max_y = area.bottom().saturating_sub(height).max(area.y);
259 let x = self.column.max(area.x).min(max_x);
260 let y = self.row.max(area.y).min(max_y);
261 Rect {
262 x,
263 y,
264 width,
265 height,
266 }
267 }
268
269 /// The body rows for a menu drawn in `rect`, starting one row below the
270 /// top rail. When the entries do not fit, a window around the selection
271 /// is shown with `↑ n more` / `↓ n more` rows, so every entry stays
272 /// reachable and the keyboard selection is always on screen.
273 fn layout(&self, rect: Rect) -> Vec<MenuRow> {
274 let body = usize::from(rect.height.saturating_sub(2));
275 let fixed = usize::from(!self.title.is_empty()) + usize::from(self.has_detail()) * 2;
276 let budget = body.saturating_sub(fixed).max(1);
277 let (start, end) = self.window(budget);
278
279 let mut rows = Vec::new();
280 if !self.title.is_empty() {
281 rows.push(MenuRow::Title);
282 }
283 if start > 0 {
284 rows.push(MenuRow::MoreAbove(start));
285 }
286 for idx in start..end {
287 if idx > start && self.entries[idx].section_start {
288 rows.push(MenuRow::Divider);
289 }
290 rows.push(MenuRow::Entry(idx));
291 }
292 if end < self.entries.len() {
293 rows.push(MenuRow::MoreBelow(self.entries.len() - end));
294 }
295 if self.has_detail() {
296 rows.push(MenuRow::Divider);
297 rows.push(MenuRow::Detail);
298 }
299 rows
300 }
301
302 /// `[start, end)` of the entries that fit in `budget` rows with the
303 /// selection visible.
304 fn window(&self, budget: usize) -> (usize, usize) {
305 let len = self.entries.len();
306 if len == 0 {
307 return (0, 0);
308 }
309 let fill = |start: usize| {
310 let mut used = usize::from(start > 0);
311 let mut idx = start;
312 while idx < len {
313 let cost = 1 + usize::from(idx > start && self.entries[idx].section_start);
314 let more_below = usize::from(idx + 1 < len);
315 if used + cost + more_below > budget {
316 break;
317 }
318 used += cost;
319 idx += 1;
320 }
321 idx.max(start + 1).min(len)
322 };
323 let mut start = self.scroll.get().min(self.selected);
324 loop {
325 let end = fill(start);
326 if self.selected < end || start >= self.selected {
327 self.scroll.set(start);
328 return (start, end);
329 }
330 start += 1;
331 }
332 }
333
334 /// Map a mouse row to what is painted there.
335 fn row_at(&self, mouse_row: u16, rect: Rect) -> Option<MenuRow> {
336 let first = rect.y.saturating_add(1);
337 if mouse_row < first || mouse_row >= rect.bottom() {
338 return None;
339 }
340 self.layout(rect)
341 .get(usize::from(mouse_row - first))
342 .copied()
343 }
344
345 /// Map a mouse row to an entry index. Title, dividers, overflow markers
346 /// and the detail row are not entries.
347 fn entry_at_row(&self, mouse_row: u16, rect: Rect) -> Option<usize> {
348 match self.row_at(mouse_row, rect)? {
349 MenuRow::Entry(idx) => Some(idx),
350 _ => None,
351 }
352 }
353
354 fn inside(&self, mouse: MouseEvent) -> Option<Rect> {
355 let rect = self.last_rect.get()?;
356 (mouse.column >= rect.x
357 && mouse.column < rect.right()
358 && mouse.row >= rect.y
359 && mouse.row < rect.bottom())
360 .then_some(rect)
361 }
362
363 fn clicked_entry(&self, mouse: MouseEvent) -> Option<usize> {
364 let rect = self.inside(mouse)?;
365 self.entry_at_row(mouse.row, rect)
366 }
367
368 fn appear_progress(&self) -> f32 {
369 if self.reduced_motion {
370 return 1.0;
371 }
372 let ms = self.opened_at.elapsed().as_millis() as f32;
373 // Two-frame (~80 ms) soft open.
374 (ms / 80.0).clamp(0.0, 1.0)
375 }
376 }
377
378 /// Give every entry a working key. Explicit hints are kept unless an earlier
379 /// entry already owns the key (two menus merged into one can both ask for
380 /// `y`); entries without one get `1`–`9` by position, then a spare letter.
381 fn assign_hints(entries: &mut [ContextMenuEntry]) {
382 let mut taken: Vec<String> = RESERVED_KEYS.iter().map(char::to_string).collect();
383 for entry in entries.iter_mut() {
384 if entry.hint.is_empty() {
385 continue;
386 }
387 if taken.contains(&entry.hint) {
388 entry.hint.clear();
389 } else {
390 taken.push(entry.hint.clone());
391 }
392 }
393 let mut spare = BACKFILL_LETTERS.chars().map(|c| c.to_string());
394 for (idx, entry) in entries.iter_mut().enumerate() {
395 if !entry.hint.is_empty() {
396 continue;
397 }
398 // Digits keep meaning "the Nth row" where that row is free to take it.
399 let digit = (idx < 9).then(|| (idx + 1).to_string());
400 let key = match digit {
401 Some(digit) if !taken.contains(&digit) => digit,
402 _ => match spare.find(|key| !taken.contains(key)) {
403 Some(key) => key,
404 None => continue,
405 },
406 };
407 taken.push(key.clone());
408 entry.hint = key;
409 }
410 }
411
412 impl ModalView for ContextMenuView {
413 fn kind(&self) -> ModalKind {
414 ModalKind::ContextMenu
415 }
416
417 /// The context menu is a small anchored popup, not a full-screen modal:
418 /// scope the central backdrop to the menu itself so opening it does not
419 /// blank the transcript behind it (#3868).
420 fn occupied_region(&self, area: Rect) -> Rect {
421 self.menu_rect(area)
422 }
423
424 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
425 self
426 }
427
428 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
429 match key.code {
430 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
431 KeyCode::Up | KeyCode::Char('k') => {
432 self.move_selection(-1);
433 ViewAction::None
434 }
435 KeyCode::Down | KeyCode::Char('j') => {
436 self.move_selection(1);
437 ViewAction::None
438 }
439 KeyCode::Enter => self.activate(self.selected, true),
440 // A hint is the bare letter: Ctrl+C or Alt+Y must not run the
441 // row that happens to carry `c` or `y`.
442 KeyCode::Char(c)
443 if !key.modifiers.intersects(
444 KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
445 ) =>
446 {
447 let key = c.to_string();
448 match self.entries.iter().position(|entry| entry.hint == key) {
449 Some(idx) => self.activate(idx, false),
450 None => ViewAction::None,
451 }
452 }
453 _ => ViewAction::None,
454 }
455 }
456
457 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
458 match mouse.kind {
459 MouseEventKind::Moved => {
460 // Hover-follow: selection tracks the pointer over rows.
461 if let Some(idx) = self.clicked_entry(mouse)
462 && self.selected != idx
463 {
464 self.armed = None;
465 self.selected = idx;
466 }
467 ViewAction::None
468 }
469 MouseEventKind::Down(MouseButton::Left) => {
470 if let Some(idx) = self.clicked_entry(mouse) {
471 // The second half of a double-click lands on the row the
472 // first half just armed; it keeps the row armed and
473 // waits for a deliberate click or Enter.
474 let double_click_tail = self.armed == Some(idx)
475 && self
476 .armed_at
477 .is_some_and(|at| at.elapsed() < CONFIRM_CLICK_GUARD);
478 if double_click_tail {
479 return ViewAction::None;
480 }
481 return self.activate(idx, true);
482 }
483 // The title, dividers, overflow markers and detail row are
484 // part of the menu, not a way out of it. Only a click
485 // outside dismisses.
486 if self.inside(mouse).is_some() {
487 ViewAction::None
488 } else {
489 ViewAction::Close
490 }
491 }
492 MouseEventKind::Down(MouseButton::Right) => ViewAction::Close,
493 MouseEventKind::ScrollUp => {
494 self.move_selection(-1);
495 ViewAction::None
496 }
497 MouseEventKind::ScrollDown => {
498 self.move_selection(1);
499 ViewAction::None
500 }
501 _ => ViewAction::None,
502 }
503 }
504
505 fn render(&self, area: Rect, buf: &mut Buffer) {
506 let menu_area = self.menu_rect(area);
507 self.last_rect.set(Some(menu_area));
508 Clear.render(menu_area, buf);
509
510 let progress = self.appear_progress();
511 let elevated = palette::SURFACE_ELEVATED;
512 let shadow = ocean::mix_colors(elevated, palette::WHALE_BG, 0.35);
513 let accent = palette::WHALE_ACTION;
514 let soft_accent = ocean::mix_colors(accent, elevated, 0.72);
515
516 // Soft depth: paint a one-cell shadow offset below/right when space allows.
517 if progress >= 1.0 && menu_area.right() < area.right() {
518 for y in menu_area.y..menu_area.bottom() {
519 let cell = &mut buf[(menu_area.right(), y)];
520 if cell.symbol() == " " || cell.symbol().is_empty() {
521 cell.set_bg(shadow);
522 }
523 }
524 }
525 if progress >= 1.0 && menu_area.bottom() < area.bottom() {
526 for x in menu_area.x..menu_area.right() {
527 let cell = &mut buf[(x, menu_area.bottom())];
528 if cell.symbol() == " " || cell.symbol().is_empty() {
529 cell.set_bg(shadow);
530 }
531 }
532 }
533
534 // Fill elevated surface (borderless form).
535 for y in menu_area.y..menu_area.bottom() {
536 for x in menu_area.x..menu_area.right() {
537 buf[(x, y)].set_bg(elevated);
538 }
539 }
540
541 // Soft top accent rail (1 cell) instead of a heavy border.
542 for x in menu_area.x..menu_area.right() {
543 buf[(x, menu_area.y)].set_bg(soft_accent);
544 }
545
546 let inner_width = menu_area.width.saturating_sub(2) as usize;
547 let muted = Style::default().fg(palette::TEXT_HINT).bg(elevated);
548 let divider = Line::from(Span::styled(
549 format!(" {}", "─".repeat(inner_width.min(48))),
550 Style::default().fg(palette::BORDER_COLOR).bg(elevated),
551 ));
552 let mut lines: Vec<Line<'static>> = Vec::new();
553
554 for row in self.layout(menu_area) {
555 match row {
556 MenuRow::Title => {
557 let title = trim_to_width(&self.title, inner_width);
558 lines.push(Line::from(Span::styled(
559 format!(" {title}"),
560 muted.add_modifier(Modifier::BOLD),
561 )));
562 }
563 MenuRow::Divider => lines.push(divider.clone()),
564 MenuRow::MoreAbove(count) => {
565 lines.push(Line::from(Span::styled(format!(" ↑ {count} more"), muted)));
566 }
567 MenuRow::MoreBelow(count) => {
568 lines.push(Line::from(Span::styled(format!(" ↓ {count} more"), muted)));
569 }
570 MenuRow::Detail => {
571 let description = self
572 .entries
573 .get(self.selected)
574 .map(|entry| {
575 trim_to_width(&entry.description, inner_width.saturating_sub(2))
576 })
577 .unwrap_or_default();
578 lines.push(Line::from(Span::styled(format!(" {description}"), muted)));
579 }
580 MenuRow::Entry(idx) => {
581 lines.push(self.entry_line(idx, inner_width, elevated, accent));
582 }
583 }
584 }
585
586 let body = Rect {
587 x: menu_area.x,
588 y: menu_area.y.saturating_add(1),
589 width: menu_area.width,
590 height: menu_area.height.saturating_sub(1),
591 };
592 Paragraph::new(lines).render(body, buf);
593 }
594 }
595
596 impl ContextMenuView {
597 fn entry_line(
598 &self,
599 idx: usize,
600 inner_width: usize,
601 elevated: ratatui::style::Color,
602 accent: ratatui::style::Color,
603 ) -> Line<'static> {
604 let entry = &self.entries[idx];
605 let selected = idx == self.selected;
606 let row_style = if selected {
607 menu_style::selected_row_style()
608 } else {
609 let label_fg = if entry.primary {
610 accent
611 } else {
612 palette::TEXT_SOFT
613 };
614 Style::default().fg(label_fg).bg(elevated)
615 };
616 let glyph = if entry.glyph.is_empty() {
617 "·"
618 } else {
619 entry.glyph.as_str()
620 };
621 let hint = entry.hint.as_str();
622 let glyph_width = UnicodeWidthStr::width(glyph);
623 // Fixed glyph slot of two display columns: full-width icons (📌,
624 // 2 cols) fit as-is, narrow ones (? / ↩, 1 col) get a trailing
625 // space so every label starts at the same column.
626 let glyph_slot = if glyph_width < 2 {
627 format!("{glyph} ")
628 } else {
629 glyph.to_string()
630 };
631 let label_budget = inner_width
632 .saturating_sub(glyph_width.max(2))
633 .saturating_sub(UnicodeWidthStr::width(hint))
634 .saturating_sub(4);
635 let label = trim_to_width(self.label_for(idx), label_budget);
636 let pad = label_budget.saturating_sub(UnicodeWidthStr::width(label.as_str()));
637 let text = format!(" {glyph_slot} {label}{} {hint} ", " ".repeat(pad));
638 let style = if self.armed == Some(idx) {
639 row_style
640 .fg(palette::STATUS_WARNING)
641 .add_modifier(Modifier::BOLD)
642 } else if !selected && entry.primary {
643 row_style.add_modifier(Modifier::BOLD)
644 } else {
645 row_style
646 };
647 Line::from(Span::styled(text, style))
648 }
649 }
650
651 fn default_glyph_for(action: &ContextMenuAction) -> String {
652 // Best-effort icons from the action discriminant name.
653 let name = format!("{action:?}");
654 if name.contains("Copy") {
655 "⎘".to_string()
656 } else if name.contains("Paste") {
657 "📋".to_string()
658 } else if name.contains("Open") || name.contains("Edit") {
659 "↗".to_string()
660 } else if name.contains("Diff") || name.contains("Git") {
661 "⌥".to_string()
662 } else if name.contains("Select") {
663 "▣".to_string()
664 } else {
665 "·".to_string()
666 }
667 }
668
669 fn trim_to_width(text: &str, max_width: usize) -> String {
670 if UnicodeWidthStr::width(text) <= max_width {
671 return text.to_string();
672 }
673 if max_width <= 3 {
674 let mut out = String::new();
675 let mut width = 0usize;
676 for ch in text.chars() {
677 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
678 if width + ch_width > max_width {
679 break;
680 }
681 out.push(ch);
682 width += ch_width;
683 }
684 return out;
685 }
686
687 let limit = max_width.saturating_sub(3);
688 let mut out = String::new();
689 let mut width = 0usize;
690 for ch in text.chars() {
691 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
692 if width + ch_width > limit {
693 break;
694 }
695 out.push(ch);
696 width += ch_width;
697 }
698 out.push_str("...");
699 out
700 }
701
702 #[cfg(test)]
703 mod tests {
704 use super::*;
705
706 #[test]
707 fn primary_entry_is_selected_by_default() {
708 let entries = vec![
709 ContextMenuEntry::new("Copy", "", ContextMenuAction::CopySelection),
710 ContextMenuEntry::new("Paste", "", ContextMenuAction::Paste).primary(),
711 ];
712 let view = ContextMenuView::new_with_motion(entries, 0, 0, "menu".into(), false);
713 assert_eq!(view.selected, 1);
714 }
715
716 #[test]
717 fn reduced_motion_opens_instantly() {
718 let view = ContextMenuView::new_with_motion(
719 vec![ContextMenuEntry::new(
720 "Copy",
721 "",
722 ContextMenuAction::CopySelection,
723 )],
724 0,
725 0,
726 "menu".into(),
727 true,
728 );
729 assert!((view.appear_progress() - 1.0).abs() < f32::EPSILON);
730 }
731
732 #[test]
733 fn hover_rows_match_titled_menu_entries_and_dividers() {
734 let entries = vec![
735 ContextMenuEntry::new("Copy", "", ContextMenuAction::CopySelection),
736 ContextMenuEntry::new("Paste", "", ContextMenuAction::Paste).section_start(),
737 ContextMenuEntry::new("Help", "", ContextMenuAction::OpenHelp).primary(),
738 ];
739 let mut view = ContextMenuView::new_with_motion(entries, 0, 0, "Actions".into(), false);
740 let rect = Rect::new(10, 5, 30, 10);
741 view.last_rect.set(Some(rect));
742 let moved = |row| MouseEvent {
743 kind: MouseEventKind::Moved,
744 column: rect.x.saturating_add(1),
745 row,
746 modifiers: crossterm::event::KeyModifiers::NONE,
747 };
748
749 // The title and section divider are not selectable.
750 view.handle_mouse(moved(rect.y.saturating_add(1)));
751 assert_eq!(view.selected, 2);
752 view.handle_mouse(moved(rect.y.saturating_add(2)));
753 assert_eq!(view.selected, 0);
754 view.handle_mouse(moved(rect.y.saturating_add(3)));
755 assert_eq!(view.selected, 0);
756
757 view.handle_mouse(moved(rect.y.saturating_add(4)));
758 assert_eq!(view.selected, 1);
759 view.handle_mouse(moved(rect.y.saturating_add(5)));
760 assert_eq!(view.selected, 2);
761 }
762
763 #[test]
764 fn untitled_menu_entries_start_on_first_body_row() {
765 let view = ContextMenuView::new_with_motion(
766 vec![ContextMenuEntry::new(
767 "Copy",
768 "",
769 ContextMenuAction::CopySelection,
770 )],
771 0,
772 0,
773 String::new(),
774 false,
775 );
776 let rect = Rect::new(10, 5, 30, 5);
777
778 assert_eq!(view.entry_at_row(rect.y.saturating_add(1), rect), Some(0));
779 }
780
781 fn key(code: KeyCode) -> KeyEvent {
782 KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
783 }
784
785 fn click(column: u16, row: u16) -> MouseEvent {
786 MouseEvent {
787 kind: MouseEventKind::Down(MouseButton::Left),
788 column,
789 row,
790 modifiers: crossterm::event::KeyModifiers::NONE,
791 }
792 }
793
794 fn emitted(action: ViewAction) -> Option<ContextMenuAction> {
795 match action {
796 ViewAction::EmitAndClose(ViewEvent::ContextMenuSelected { action }) => Some(action),
797 _ => None,
798 }
799 }
800
801 fn cell(index: usize) -> ContextMenuAction {
802 ContextMenuAction::HideCell { cell_index: index }
803 }
804
805 /// T3: the letters drawn in the menu used to be decoration — only digits,
806 /// Enter, j/k, q and Esc were handled. Every hint shown now runs its row.
807 #[test]
808 fn letter_hints_run_their_entry() {
809 let entries = vec![
810 ContextMenuEntry::new("Paste", "", ContextMenuAction::Paste).with_hint("p"),
811 ContextMenuEntry::new("Copy", "", ContextMenuAction::CopySelection).with_hint("y"),
812 ContextMenuEntry::new("Help", "", ContextMenuAction::OpenHelp),
813 ];
814 let mut view = ContextMenuView::new_with_motion(entries, 0, 0, String::new(), true);
815
816 assert_eq!(
817 emitted(view.handle_key(key(KeyCode::Char('y')))),
818 Some(ContextMenuAction::CopySelection)
819 );
820 assert_eq!(
821 emitted(view.handle_key(key(KeyCode::Char('p')))),
822 Some(ContextMenuAction::Paste)
823 );
824 // The third row has no letter of its own and gets its position.
825 assert_eq!(view.entries[2].hint, "3");
826 assert_eq!(
827 emitted(view.handle_key(key(KeyCode::Char('3')))),
828 Some(ContextMenuAction::OpenHelp)
829 );
830 assert!(matches!(
831 view.handle_key(key(KeyCode::Char('x'))),
832 ViewAction::None
833 ));
834 // A modified letter is a different shortcut, not the row's hint.
835 for modifiers in [KeyModifiers::CONTROL, KeyModifiers::ALT] {
836 assert!(matches!(
837 view.handle_key(KeyEvent::new(KeyCode::Char('y'), modifiers)),
838 ViewAction::None
839 ));
840 }
841 }
842
843 /// Entries past the ninth had no key at all. Each now gets a spare
844 /// letter that is shown and works; j, k and q stay navigation.
845 #[test]
846 fn entries_past_nine_get_a_working_key() {
847 let entries = (0..12)
848 .map(|index| ContextMenuEntry::new(format!("row {index}"), "", cell(index)))
849 .collect();
850 let mut view = ContextMenuView::new_with_motion(entries, 0, 0, String::new(), true);
851
852 let hints: Vec<&str> = view.entries.iter().map(|e| e.hint.as_str()).collect();
853 assert_eq!(&hints[..9], ["1", "2", "3", "4", "5", "6", "7", "8", "9"]);
854 for hint in &hints[9..] {
855 assert!(!hint.is_empty(), "every row has a key: {hints:?}");
856 assert!(!["j", "k", "q"].contains(hint), "{hints:?}");
857 }
858 let tenth = view.entries[9].hint.chars().next().expect("tenth hint");
859 assert_eq!(
860 emitted(view.handle_key(key(KeyCode::Char(tenth)))),
861 Some(cell(9))
862 );
863 }
864
865 /// Two builders asking for the same letter (a row's Copy and Copy
866 /// selection both want `y`) must not leave a hint that runs the wrong row.
867 #[test]
868 fn a_duplicate_hint_goes_to_the_first_entry_only() {
869 let entries = vec![
870 ContextMenuEntry::new("Copy row", "", ContextMenuAction::Paste).with_hint("y"),
871 ContextMenuEntry::new("Copy selection", "", ContextMenuAction::CopySelection)
872 .with_hint("y"),
873 ];
874 let view = ContextMenuView::new_with_motion(entries, 0, 0, String::new(), true);
875 assert_eq!(view.entries[0].hint, "y");
876 assert_eq!(view.entries[1].hint, "2");
877 }
878
879 /// T9: the title, dividers and detail row are part of the menu. Clicking
880 /// them used to close it; only a click outside the menu dismisses now.
881 #[test]
882 fn clicks_on_title_divider_and_detail_are_inert() {
883 let entries = vec![
884 ContextMenuEntry::new("Copy", "copy it", ContextMenuAction::CopySelection),
885 ContextMenuEntry::new("Paste", "paste it", ContextMenuAction::Paste).section_start(),
886 ];
887 let mut view = ContextMenuView::new_with_motion(entries, 0, 0, "Title".into(), true);
888 let rect = Rect::new(10, 5, 30, 10);
889 view.last_rect.set(Some(rect));
890 let rows = view.layout(rect);
891 assert_eq!(
892 rows,
893 vec![
894 MenuRow::Title,
895 MenuRow::Entry(0),
896 MenuRow::Divider,
897 MenuRow::Entry(1),
898 MenuRow::Divider,
899 MenuRow::Detail,
900 ]
901 );
902 for (offset, row) in rows.iter().enumerate() {
903 if matches!(row, MenuRow::Entry(_)) {
904 continue;
905 }
906 let y = rect.y + 1 + u16::try_from(offset).unwrap();
907 assert!(
908 matches!(view.handle_mouse(click(rect.x + 2, y)), ViewAction::None),
909 "{row:?} must not close the menu"
910 );
911 }
912 // The rail and the padding row are inside the menu too.
913 assert!(matches!(
914 view.handle_mouse(click(rect.x + 2, rect.y)),
915 ViewAction::None
916 ));
917 assert!(matches!(
918 view.handle_mouse(click(rect.x + 2, rect.bottom() - 1)),
919 ViewAction::None
920 ));
921 assert!(matches!(
922 view.handle_mouse(click(rect.right() + 1, rect.y)),
923 ViewAction::Close
924 ));
925 assert_eq!(
926 emitted(view.handle_mouse(click(rect.x + 2, rect.y + 4))),
927 Some(ContextMenuAction::Paste)
928 );
929 }
930
931 /// Destructive rows are confirmed in place: the first Enter or click arms
932 /// the row, the second runs it, and moving away disarms. A letter can
933 /// arm a row but never confirms it.
934 #[test]
935 fn a_confirm_entry_needs_a_second_activation() {
936 let entries = vec![
937 ContextMenuEntry::new("Copy", "", ContextMenuAction::CopySelection),
938 ContextMenuEntry::new("Stop agent…", "", ContextMenuAction::Paste)
939 .confirm("{label}: Enter or click again to confirm")
940 .with_hint("s"),
941 ];
942 let mut view = ContextMenuView::new_with_motion(entries, 0, 0, String::new(), true);
943
944 assert!(matches!(
945 view.handle_key(key(KeyCode::Char('s'))),
946 ViewAction::None
947 ));
948 assert_eq!(view.armed, Some(1));
949 assert_eq!(
950 view.label_for(1),
951 "Stop agent: Enter or click again to confirm",
952 "the armed row names its action and what confirms it"
953 );
954 assert!(
955 matches!(view.handle_key(key(KeyCode::Char('s'))), ViewAction::None),
956 "a letter never confirms"
957 );
958 assert_eq!(
959 emitted(view.handle_key(key(KeyCode::Enter))),
960 Some(ContextMenuAction::Paste)
961 );
962
963 let entries = vec![
964 ContextMenuEntry::new("Copy", "", ContextMenuAction::CopySelection),
965 ContextMenuEntry::new("Stop", "", ContextMenuAction::Paste).confirm("again"),
966 ];
967 let mut view = ContextMenuView::new_with_motion(entries, 0, 0, String::new(), true);
968 view.handle_key(key(KeyCode::Down));
969 assert!(matches!(
970 view.handle_key(key(KeyCode::Enter)),
971 ViewAction::None
972 ));
973 view.handle_key(key(KeyCode::Up));
974 view.handle_key(key(KeyCode::Down));
975 assert_eq!(view.armed, None, "moving the selection disarms");
976 assert_eq!(view.label_for(1), "Stop");
977 assert!(matches!(
978 view.handle_key(key(KeyCode::Enter)),
979 ViewAction::None
980 ));
981 }
982
983 /// A double-click on a destructive row used to arm it and confirm it in
984 /// one gesture. The second click of a double-click is now ignored; a
985 /// later, separate click (or Enter) still confirms.
986 #[test]
987 fn a_double_click_does_not_confirm_a_destructive_row() {
988 let entries = vec![
989 ContextMenuEntry::new("Copy", "", ContextMenuAction::CopySelection),
990 ContextMenuEntry::new("Stop agent…", "", ContextMenuAction::Paste)
991 .confirm("{label}: Enter or click again to confirm"),
992 ];
993 let mut view = ContextMenuView::new_with_motion(entries, 0, 0, String::new(), true);
994 let rect = Rect::new(0, 0, 40, 6);
995 view.last_rect.set(Some(rect));
996 let stop_row = rect.y + 2;
997 assert_eq!(view.entry_at_row(stop_row, rect), Some(1));
998
999 assert!(matches!(
1000 view.handle_mouse(click(2, stop_row)),
1001 ViewAction::None
1002 ));
1003 assert_eq!(view.armed, Some(1));
1004 assert!(
1005 matches!(view.handle_mouse(click(2, stop_row)), ViewAction::None),
1006 "the second click of a double-click must not confirm"
1007 );
1008 assert_eq!(view.armed, Some(1), "the row stays armed");
1009
1010 // A deliberate click after the double-click window confirms.
1011 view.armed_at = Instant::now().checked_sub(CONFIRM_CLICK_GUARD * 2);
1012 assert_eq!(
1013 emitted(view.handle_mouse(click(2, stop_row))),
1014 Some(ContextMenuAction::Paste)
1015 );
1016
1017 // Enter confirms at once: a key press is never a double-click.
1018 let entries = vec![
1019 ContextMenuEntry::new("Stop", "", ContextMenuAction::Paste)
1020 .confirm("{label}: Enter or click again to confirm"),
1021 ];
1022 let mut view = ContextMenuView::new_with_motion(entries, 0, 0, String::new(), true);
1023 view.last_rect.set(Some(rect));
1024 view.handle_mouse(click(2, rect.y + 1));
1025 assert_eq!(view.armed, Some(0));
1026 assert_eq!(
1027 emitted(view.handle_key(key(KeyCode::Enter))),
1028 Some(ContextMenuAction::Paste)
1029 );
1030 }
1031
1032 /// T9: a menu taller than the screen used to be cut off. It now shows a
1033 /// window with `n more` markers that follows the keyboard selection.
1034 #[test]
1035 fn a_tall_menu_scrolls_to_keep_the_selection_visible() {
1036 let entries = (0..20)
1037 .map(|index| ContextMenuEntry::new(format!("row {index}"), "", cell(index)))
1038 .collect();
1039 let mut view = ContextMenuView::new_with_motion(entries, 0, 0, String::new(), true);
1040 let rect = Rect::new(0, 0, 30, 8);
1041 let rows = view.layout(rect);
1042 assert_eq!(rows.first(), Some(&MenuRow::Entry(0)));
1043 assert!(
1044 matches!(rows.last(), Some(MenuRow::MoreBelow(_))),
1045 "{rows:?}"
1046 );
1047 assert!(rows.len() <= 6, "the body fits the rect: {rows:?}");
1048
1049 // Up from the first row wraps to the last one, which must be drawn.
1050 view.handle_key(key(KeyCode::Up));
1051 let rows = view.layout(rect);
1052 assert!(rows.contains(&MenuRow::Entry(19)), "{rows:?}");
1053 assert!(
1054 matches!(rows.first(), Some(MenuRow::MoreAbove(_))),
1055 "{rows:?}"
1056 );
1057 let y = 1 + u16::try_from(
1058 rows.iter()
1059 .position(|row| *row == MenuRow::Entry(19))
1060 .unwrap(),
1061 )
1062 .unwrap();
1063 assert_eq!(view.entry_at_row(y, rect), Some(19));
1064 }
1065 }
1066
1066 lines RUST