返回 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, 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::palette;
21 use crate::tui::menu_style;
22 use crate::tui::ocean;
23 use crate::tui::views::{ContextMenuAction, ModalKind, ModalView, ViewAction, ViewEvent};
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 keyboard hint chip (e.g. "↵", "y", "1").
33 pub hint: String,
34 /// When true, starts a new visual section above this entry.
35 pub section_start: bool,
36 /// Primary (most likely) action — focused by default and accent-styled.
37 pub primary: bool,
38 }
39
40 impl ContextMenuEntry {
41 pub fn new(
42 label: impl Into<String>,
43 description: impl Into<String>,
44 action: ContextMenuAction,
45 ) -> Self {
46 Self {
47 label: label.into(),
48 description: description.into(),
49 action,
50 glyph: String::new(),
51 hint: String::new(),
52 section_start: false,
53 primary: false,
54 }
55 }
56
57 #[must_use]
58 pub fn with_glyph(mut self, glyph: impl Into<String>) -> Self {
59 self.glyph = glyph.into();
60 self
61 }
62
63 #[must_use]
64 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
65 self.hint = hint.into();
66 self
67 }
68
69 #[must_use]
70 pub fn section_start(mut self) -> Self {
71 self.section_start = true;
72 self
73 }
74
75 #[must_use]
76 pub fn primary(mut self) -> Self {
77 self.primary = true;
78 self
79 }
80 }
81
82 pub struct ContextMenuView {
83 entries: Vec<ContextMenuEntry>,
84 selected: usize,
85 column: u16,
86 row: u16,
87 last_rect: Cell<Option<Rect>>,
88 title: String,
89 opened_at: Instant,
90 reduced_motion: bool,
91 }
92
93 impl ContextMenuView {
94 pub fn new_with_motion(
95 entries: Vec<ContextMenuEntry>,
96 column: u16,
97 row: u16,
98 title: String,
99 reduced_motion: bool,
100 ) -> Self {
101 // Focus the primary action by default when present.
102 let selected = entries.iter().position(|e| e.primary).unwrap_or(0);
103 // Backfill digit hints for entries that lack one.
104 let mut entries = entries;
105 for (idx, entry) in entries.iter_mut().enumerate() {
106 if entry.hint.is_empty() && idx < 9 {
107 entry.hint = (idx + 1).to_string();
108 }
109 if entry.glyph.is_empty() {
110 entry.glyph = default_glyph_for(&entry.action);
111 }
112 }
113 Self {
114 entries,
115 selected,
116 column,
117 row,
118 last_rect: Cell::new(None),
119 title,
120 opened_at: Instant::now(),
121 reduced_motion,
122 }
123 }
124
125 fn selected_action(&self) -> Option<ContextMenuAction> {
126 self.entries
127 .get(self.selected)
128 .map(|entry| entry.action.clone())
129 }
130
131 fn move_selection(&mut self, delta: isize) {
132 self.selected = crate::tui::list_nav::wrap_index(self.selected, self.entries.len(), delta);
133 }
134
135 fn menu_width(&self, area_width: u16) -> u16 {
136 let widest = self
137 .entries
138 .iter()
139 .map(|entry| {
140 let action_width = UnicodeWidthStr::width(entry.glyph.as_str())
141 .saturating_add(UnicodeWidthStr::width(entry.label.as_str()))
142 .saturating_add(UnicodeWidthStr::width(entry.hint.as_str()))
143 .saturating_add(7);
144 let detail_width =
145 UnicodeWidthStr::width(entry.description.as_str()).saturating_add(4);
146 action_width.max(detail_width)
147 })
148 .max()
149 .unwrap_or(20)
150 .max(UnicodeWidthStr::width(self.title.as_str()).saturating_add(4));
151 let width = u16::try_from(widest.clamp(22, 56)).unwrap_or(56);
152 width.min(area_width.max(1))
153 }
154
155 fn visual_row_count(&self) -> usize {
156 // title + entries + optional section dividers + a stable selected-detail row
157 let dividers = self.entries.iter().filter(|e| e.section_start).count();
158 let detail_rows = usize::from(self.entries.iter().any(|e| !e.description.is_empty())) * 2;
159 self.entries
160 .len()
161 .saturating_add(1)
162 .saturating_add(dividers)
163 .saturating_add(detail_rows)
164 }
165
166 fn menu_rect(&self, area: Rect) -> Rect {
167 let width = self.menu_width(area.width);
168 let desired_height =
169 u16::try_from(self.visual_row_count().saturating_add(2)).unwrap_or(u16::MAX);
170 let height = desired_height.min(area.height.max(1));
171 let max_x = area.right().saturating_sub(width).max(area.x);
172 let max_y = area.bottom().saturating_sub(height).max(area.y);
173 let x = self.column.max(area.x).min(max_x);
174 let y = self.row.max(area.y).min(max_y);
175 Rect {
176 x,
177 y,
178 width,
179 height,
180 }
181 }
182
183 /// Map a mouse row to an entry index, accounting for section dividers and title.
184 fn entry_at_row(&self, mouse_row: u16, rect: Rect) -> Option<usize> {
185 if mouse_row <= rect.y || mouse_row >= rect.bottom().saturating_sub(1) {
186 return None;
187 }
188 // The paragraph starts below the accent rail, and a non-empty title
189 // consumes its first row before any entries are rendered.
190 let mut visual = rect
191 .y
192 .saturating_add(1)
193 .saturating_add(u16::from(!self.title.is_empty()));
194 for (idx, entry) in self.entries.iter().enumerate() {
195 if entry.section_start && idx > 0 {
196 visual = visual.saturating_add(1);
197 }
198 if mouse_row == visual {
199 return Some(idx);
200 }
201 visual = visual.saturating_add(1);
202 }
203 None
204 }
205
206 fn clicked_entry(&self, mouse: MouseEvent) -> Option<usize> {
207 let rect = self.last_rect.get()?;
208 if mouse.column < rect.x
209 || mouse.column >= rect.right()
210 || mouse.row < rect.y
211 || mouse.row >= rect.bottom()
212 {
213 return None;
214 }
215 self.entry_at_row(mouse.row, rect)
216 }
217
218 fn appear_progress(&self) -> f32 {
219 if self.reduced_motion {
220 return 1.0;
221 }
222 let ms = self.opened_at.elapsed().as_millis() as f32;
223 // Two-frame (~80 ms) soft open.
224 (ms / 80.0).clamp(0.0, 1.0)
225 }
226 }
227
228 impl ModalView for ContextMenuView {
229 fn kind(&self) -> ModalKind {
230 ModalKind::ContextMenu
231 }
232
233 /// The context menu is a small anchored popup, not a full-screen modal:
234 /// scope the central backdrop to the menu itself so opening it does not
235 /// blank the transcript behind it (#3868).
236 fn occupied_region(&self, area: Rect) -> Rect {
237 self.menu_rect(area)
238 }
239
240 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
241 self
242 }
243
244 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
245 match key.code {
246 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
247 KeyCode::Up | KeyCode::Char('k') => {
248 self.move_selection(-1);
249 ViewAction::None
250 }
251 KeyCode::Down | KeyCode::Char('j') => {
252 self.move_selection(1);
253 ViewAction::None
254 }
255 KeyCode::Enter => self.selected_action().map_or(ViewAction::Close, |action| {
256 ViewAction::EmitAndClose(ViewEvent::ContextMenuSelected { action })
257 }),
258 KeyCode::Char(c) if c.is_ascii_digit() => {
259 let idx = c.to_digit(10).and_then(|digit| {
260 let digit = usize::try_from(digit).ok()?;
261 digit.checked_sub(1)
262 });
263 if let Some(idx) = idx.filter(|idx| *idx < self.entries.len()) {
264 self.selected = idx;
265 return self.selected_action().map_or(ViewAction::Close, |action| {
266 ViewAction::EmitAndClose(ViewEvent::ContextMenuSelected { action })
267 });
268 }
269 ViewAction::None
270 }
271 _ => ViewAction::None,
272 }
273 }
274
275 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
276 match mouse.kind {
277 MouseEventKind::Moved => {
278 // Hover-follow: selection tracks the pointer over rows.
279 if let Some(idx) = self.clicked_entry(mouse)
280 && self.selected != idx
281 {
282 self.selected = idx;
283 }
284 ViewAction::None
285 }
286 MouseEventKind::Down(MouseButton::Left) => {
287 if let Some(idx) = self.clicked_entry(mouse) {
288 self.selected = idx;
289 return self.selected_action().map_or(ViewAction::Close, |action| {
290 ViewAction::EmitAndClose(ViewEvent::ContextMenuSelected { action })
291 });
292 }
293 // Outside click dismisses.
294 ViewAction::Close
295 }
296 MouseEventKind::Down(MouseButton::Right) => ViewAction::Close,
297 MouseEventKind::ScrollUp => {
298 self.move_selection(-1);
299 ViewAction::None
300 }
301 MouseEventKind::ScrollDown => {
302 self.move_selection(1);
303 ViewAction::None
304 }
305 _ => ViewAction::None,
306 }
307 }
308
309 fn render(&self, area: Rect, buf: &mut Buffer) {
310 let menu_area = self.menu_rect(area);
311 self.last_rect.set(Some(menu_area));
312 Clear.render(menu_area, buf);
313
314 let progress = self.appear_progress();
315 let elevated = palette::SURFACE_ELEVATED;
316 let shadow = ocean::mix_colors(elevated, palette::WHALE_BG, 0.35);
317 let accent = palette::WHALE_INFO;
318 let soft_accent = ocean::mix_colors(accent, elevated, 0.72);
319
320 // Soft depth: paint a one-cell shadow offset below/right when space allows.
321 if progress >= 1.0 && menu_area.right() < area.right() {
322 for y in menu_area.y..menu_area.bottom() {
323 let cell = &mut buf[(menu_area.right(), y)];
324 if cell.symbol() == " " || cell.symbol().is_empty() {
325 cell.set_bg(shadow);
326 }
327 }
328 }
329 if progress >= 1.0 && menu_area.bottom() < area.bottom() {
330 for x in menu_area.x..menu_area.right() {
331 let cell = &mut buf[(x, menu_area.bottom())];
332 if cell.symbol() == " " || cell.symbol().is_empty() {
333 cell.set_bg(shadow);
334 }
335 }
336 }
337
338 // Fill elevated surface (borderless form).
339 for y in menu_area.y..menu_area.bottom() {
340 for x in menu_area.x..menu_area.right() {
341 buf[(x, y)].set_bg(elevated);
342 }
343 }
344
345 // Soft top accent rail (1 cell) instead of a heavy border.
346 for x in menu_area.x..menu_area.right() {
347 buf[(x, menu_area.y)].set_bg(soft_accent);
348 }
349
350 let inner_width = menu_area.width.saturating_sub(2) as usize;
351 let mut lines: Vec<Line<'static>> = Vec::new();
352
353 // Title row
354 if !self.title.is_empty() {
355 let title = trim_to_width(&self.title, inner_width);
356 lines.push(Line::from(Span::styled(
357 format!(" {title}"),
358 Style::default()
359 .fg(palette::TEXT_HINT)
360 .bg(elevated)
361 .add_modifier(Modifier::BOLD),
362 )));
363 }
364
365 for (idx, entry) in self.entries.iter().enumerate() {
366 if entry.section_start && idx > 0 {
367 let divider = "─".repeat(inner_width.min(48));
368 lines.push(Line::from(Span::styled(
369 format!(" {divider}"),
370 Style::default().fg(palette::BORDER_COLOR).bg(elevated),
371 )));
372 }
373
374 let selected = idx == self.selected;
375 let row_style = if selected {
376 menu_style::selected_row_style()
377 } else {
378 let label_fg = if entry.primary {
379 accent
380 } else {
381 palette::TEXT_SOFT
382 };
383 Style::default().fg(label_fg).bg(elevated)
384 };
385 let glyph = if entry.glyph.is_empty() {
386 "·"
387 } else {
388 entry.glyph.as_str()
389 };
390 let hint = entry.hint.as_str();
391 let label_budget = inner_width
392 .saturating_sub(UnicodeWidthStr::width(glyph))
393 .saturating_sub(UnicodeWidthStr::width(hint))
394 .saturating_sub(4);
395 let label = trim_to_width(&entry.label, label_budget);
396 let pad = label_budget.saturating_sub(UnicodeWidthStr::width(label.as_str()));
397 let text = format!(" {glyph} {label}{} {hint} ", " ".repeat(pad));
398 let style = if !selected && entry.primary {
399 row_style.add_modifier(Modifier::BOLD)
400 } else {
401 row_style
402 };
403 lines.push(Line::from(Span::styled(text, style)));
404 }
405
406 if self
407 .entries
408 .iter()
409 .any(|entry| !entry.description.is_empty())
410 {
411 let divider = "─".repeat(inner_width.min(48));
412 lines.push(Line::from(Span::styled(
413 format!(" {divider}"),
414 Style::default().fg(palette::BORDER_COLOR).bg(elevated),
415 )));
416 let description = self
417 .entries
418 .get(self.selected)
419 .map(|entry| trim_to_width(&entry.description, inner_width.saturating_sub(2)))
420 .unwrap_or_default();
421 lines.push(Line::from(Span::styled(
422 format!(" {description}"),
423 Style::default().fg(palette::TEXT_HINT).bg(elevated),
424 )));
425 }
426
427 let body = Rect {
428 x: menu_area.x,
429 y: menu_area.y.saturating_add(1),
430 width: menu_area.width,
431 height: menu_area.height.saturating_sub(1),
432 };
433 Paragraph::new(lines).render(body, buf);
434 }
435 }
436
437 /// Append git-oriented actions when a path is known (worktree manager entry).
438 #[must_use]
439 pub fn with_git_actions(
440 mut entries: Vec<ContextMenuEntry>,
441 path: Option<&str>,
442 branch: Option<&str>,
443 ) -> Vec<ContextMenuEntry> {
444 let Some(path) = path.filter(|p| !p.is_empty()) else {
445 return entries;
446 };
447 for (index, (label, id)) in crate::tui::worktree_manager::context_menu_git_actions(path, branch)
448 .into_iter()
449 .enumerate()
450 {
451 let action = if id == "worktrees" {
452 ContextMenuAction::ExecuteCommand {
453 command: "/workspace worktrees".into(),
454 }
455 } else if let Some(rest) = id.strip_prefix("diff:") {
456 ContextMenuAction::ExecuteCommand {
457 command: format!("/diff {rest}"),
458 }
459 } else if let Some(rest) = id.strip_prefix("open:") {
460 ContextMenuAction::ExecuteCommand {
461 command: format!("/open {rest}"),
462 }
463 } else if let Some(rest) = id.strip_prefix("branch:") {
464 ContextMenuAction::CopyText {
465 text: rest.to_string(),
466 }
467 } else {
468 ContextMenuAction::CopyText {
469 text: label.clone(),
470 }
471 };
472 let entry = ContextMenuEntry::new(label, id, action).with_glyph("⌥");
473 entries.push(if index == 0 {
474 entry.section_start()
475 } else {
476 entry
477 });
478 }
479 entries
480 }
481
482 fn default_glyph_for(action: &ContextMenuAction) -> String {
483 // Best-effort icons from the action discriminant name.
484 let name = format!("{action:?}");
485 if name.contains("Copy") {
486 "⎘".to_string()
487 } else if name.contains("Paste") {
488 "📋".to_string()
489 } else if name.contains("Open") || name.contains("Edit") {
490 "↗".to_string()
491 } else if name.contains("Diff") || name.contains("Git") {
492 "⌥".to_string()
493 } else if name.contains("Select") {
494 "▣".to_string()
495 } else {
496 "·".to_string()
497 }
498 }
499
500 fn trim_to_width(text: &str, max_width: usize) -> String {
501 if UnicodeWidthStr::width(text) <= max_width {
502 return text.to_string();
503 }
504 if max_width <= 3 {
505 let mut out = String::new();
506 let mut width = 0usize;
507 for ch in text.chars() {
508 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
509 if width + ch_width > max_width {
510 break;
511 }
512 out.push(ch);
513 width += ch_width;
514 }
515 return out;
516 }
517
518 let limit = max_width.saturating_sub(3);
519 let mut out = String::new();
520 let mut width = 0usize;
521 for ch in text.chars() {
522 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
523 if width + ch_width > limit {
524 break;
525 }
526 out.push(ch);
527 width += ch_width;
528 }
529 out.push_str("...");
530 out
531 }
532
533 #[cfg(test)]
534 mod tests {
535 use super::*;
536
537 #[test]
538 fn primary_entry_is_selected_by_default() {
539 let entries = vec![
540 ContextMenuEntry::new("Copy", "", ContextMenuAction::CopySelection),
541 ContextMenuEntry::new("Paste", "", ContextMenuAction::Paste).primary(),
542 ];
543 let view = ContextMenuView::new_with_motion(entries, 0, 0, "menu".into(), false);
544 assert_eq!(view.selected, 1);
545 }
546
547 #[test]
548 fn reduced_motion_opens_instantly() {
549 let view = ContextMenuView::new_with_motion(
550 vec![ContextMenuEntry::new(
551 "Copy",
552 "",
553 ContextMenuAction::CopySelection,
554 )],
555 0,
556 0,
557 "menu".into(),
558 true,
559 );
560 assert!((view.appear_progress() - 1.0).abs() < f32::EPSILON);
561 }
562
563 #[test]
564 fn hover_rows_match_titled_menu_entries_and_dividers() {
565 let entries = vec![
566 ContextMenuEntry::new("Copy", "", ContextMenuAction::CopySelection),
567 ContextMenuEntry::new("Paste", "", ContextMenuAction::Paste).section_start(),
568 ContextMenuEntry::new("Help", "", ContextMenuAction::OpenHelp).primary(),
569 ];
570 let mut view = ContextMenuView::new_with_motion(entries, 0, 0, "Actions".into(), false);
571 let rect = Rect::new(10, 5, 30, 10);
572 view.last_rect.set(Some(rect));
573 let moved = |row| MouseEvent {
574 kind: MouseEventKind::Moved,
575 column: rect.x.saturating_add(1),
576 row,
577 modifiers: crossterm::event::KeyModifiers::NONE,
578 };
579
580 // The title and section divider are not selectable.
581 view.handle_mouse(moved(rect.y.saturating_add(1)));
582 assert_eq!(view.selected, 2);
583 view.handle_mouse(moved(rect.y.saturating_add(2)));
584 assert_eq!(view.selected, 0);
585 view.handle_mouse(moved(rect.y.saturating_add(3)));
586 assert_eq!(view.selected, 0);
587
588 view.handle_mouse(moved(rect.y.saturating_add(4)));
589 assert_eq!(view.selected, 1);
590 view.handle_mouse(moved(rect.y.saturating_add(5)));
591 assert_eq!(view.selected, 2);
592 }
593
594 #[test]
595 fn untitled_menu_entries_start_on_first_body_row() {
596 let view = ContextMenuView::new_with_motion(
597 vec![ContextMenuEntry::new(
598 "Copy",
599 "",
600 ContextMenuAction::CopySelection,
601 )],
602 0,
603 0,
604 String::new(),
605 false,
606 );
607 let rect = Rect::new(10, 5, 30, 5);
608
609 assert_eq!(view.entry_at_row(rect.y.saturating_add(1), rect), Some(0));
610 }
611 }
612
612 lines RUST