返回 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::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 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_ACTION;
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 glyph_width = UnicodeWidthStr::width(glyph);
392 // Fixed glyph slot of two display columns: full-width icons (📌,
393 // 2 cols) fit as-is, narrow ones (? / ↩, 1 col) get a trailing
394 // space so every label starts at the same column.
395 let glyph_slot = if glyph_width < 2 {
396 format!("{glyph} ")
397 } else {
398 glyph.to_string()
399 };
400 let label_budget = inner_width
401 .saturating_sub(glyph_width.max(2))
402 .saturating_sub(UnicodeWidthStr::width(hint))
403 .saturating_sub(4);
404 let label = trim_to_width(&entry.label, label_budget);
405 let pad = label_budget.saturating_sub(UnicodeWidthStr::width(label.as_str()));
406 let text = format!(" {glyph_slot} {label}{} {hint} ", " ".repeat(pad));
407 let style = if !selected && entry.primary {
408 row_style.add_modifier(Modifier::BOLD)
409 } else {
410 row_style
411 };
412 lines.push(Line::from(Span::styled(text, style)));
413 }
414
415 if self
416 .entries
417 .iter()
418 .any(|entry| !entry.description.is_empty())
419 {
420 let divider = "─".repeat(inner_width.min(48));
421 lines.push(Line::from(Span::styled(
422 format!(" {divider}"),
423 Style::default().fg(palette::BORDER_COLOR).bg(elevated),
424 )));
425 let description = self
426 .entries
427 .get(self.selected)
428 .map(|entry| trim_to_width(&entry.description, inner_width.saturating_sub(2)))
429 .unwrap_or_default();
430 lines.push(Line::from(Span::styled(
431 format!(" {description}"),
432 Style::default().fg(palette::TEXT_HINT).bg(elevated),
433 )));
434 }
435
436 let body = Rect {
437 x: menu_area.x,
438 y: menu_area.y.saturating_add(1),
439 width: menu_area.width,
440 height: menu_area.height.saturating_sub(1),
441 };
442 Paragraph::new(lines).render(body, buf);
443 }
444 }
445
446 /// Append git-oriented actions when a path is known (worktree manager entry).
447 #[must_use]
448 pub fn with_git_actions(
449 mut entries: Vec<ContextMenuEntry>,
450 path: Option<&str>,
451 branch: Option<&str>,
452 ) -> Vec<ContextMenuEntry> {
453 let Some(path) = path.filter(|p| !p.is_empty()) else {
454 return entries;
455 };
456 for (index, (label, id)) in crate::tui::worktree_manager::context_menu_git_actions(path, branch)
457 .into_iter()
458 .enumerate()
459 {
460 let action = if id == "worktrees" {
461 ContextMenuAction::ExecuteCommand {
462 command: "/workspace worktrees".into(),
463 }
464 } else if let Some(rest) = id.strip_prefix("diff:") {
465 ContextMenuAction::ExecuteCommand {
466 command: format!("/diff {rest}"),
467 }
468 } else if let Some(rest) = id.strip_prefix("open:") {
469 ContextMenuAction::ExecuteCommand {
470 command: format!("/open {rest}"),
471 }
472 } else if let Some(rest) = id.strip_prefix("branch:") {
473 ContextMenuAction::CopyText {
474 text: rest.to_string(),
475 }
476 } else {
477 ContextMenuAction::CopyText {
478 text: label.clone(),
479 }
480 };
481 let entry = ContextMenuEntry::new(label, id, action).with_glyph("⌥");
482 entries.push(if index == 0 {
483 entry.section_start()
484 } else {
485 entry
486 });
487 }
488 entries
489 }
490
491 fn default_glyph_for(action: &ContextMenuAction) -> String {
492 // Best-effort icons from the action discriminant name.
493 let name = format!("{action:?}");
494 if name.contains("Copy") {
495 "⎘".to_string()
496 } else if name.contains("Paste") {
497 "📋".to_string()
498 } else if name.contains("Open") || name.contains("Edit") {
499 "↗".to_string()
500 } else if name.contains("Diff") || name.contains("Git") {
501 "⌥".to_string()
502 } else if name.contains("Select") {
503 "▣".to_string()
504 } else {
505 "·".to_string()
506 }
507 }
508
509 fn trim_to_width(text: &str, max_width: usize) -> String {
510 if UnicodeWidthStr::width(text) <= max_width {
511 return text.to_string();
512 }
513 if max_width <= 3 {
514 let mut out = String::new();
515 let mut width = 0usize;
516 for ch in text.chars() {
517 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
518 if width + ch_width > max_width {
519 break;
520 }
521 out.push(ch);
522 width += ch_width;
523 }
524 return out;
525 }
526
527 let limit = max_width.saturating_sub(3);
528 let mut out = String::new();
529 let mut width = 0usize;
530 for ch in text.chars() {
531 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
532 if width + ch_width > limit {
533 break;
534 }
535 out.push(ch);
536 width += ch_width;
537 }
538 out.push_str("...");
539 out
540 }
541
542 #[cfg(test)]
543 mod tests {
544 use super::*;
545
546 #[test]
547 fn primary_entry_is_selected_by_default() {
548 let entries = vec![
549 ContextMenuEntry::new("Copy", "", ContextMenuAction::CopySelection),
550 ContextMenuEntry::new("Paste", "", ContextMenuAction::Paste).primary(),
551 ];
552 let view = ContextMenuView::new_with_motion(entries, 0, 0, "menu".into(), false);
553 assert_eq!(view.selected, 1);
554 }
555
556 #[test]
557 fn reduced_motion_opens_instantly() {
558 let view = ContextMenuView::new_with_motion(
559 vec![ContextMenuEntry::new(
560 "Copy",
561 "",
562 ContextMenuAction::CopySelection,
563 )],
564 0,
565 0,
566 "menu".into(),
567 true,
568 );
569 assert!((view.appear_progress() - 1.0).abs() < f32::EPSILON);
570 }
571
572 #[test]
573 fn hover_rows_match_titled_menu_entries_and_dividers() {
574 let entries = vec![
575 ContextMenuEntry::new("Copy", "", ContextMenuAction::CopySelection),
576 ContextMenuEntry::new("Paste", "", ContextMenuAction::Paste).section_start(),
577 ContextMenuEntry::new("Help", "", ContextMenuAction::OpenHelp).primary(),
578 ];
579 let mut view = ContextMenuView::new_with_motion(entries, 0, 0, "Actions".into(), false);
580 let rect = Rect::new(10, 5, 30, 10);
581 view.last_rect.set(Some(rect));
582 let moved = |row| MouseEvent {
583 kind: MouseEventKind::Moved,
584 column: rect.x.saturating_add(1),
585 row,
586 modifiers: crossterm::event::KeyModifiers::NONE,
587 };
588
589 // The title and section divider are not selectable.
590 view.handle_mouse(moved(rect.y.saturating_add(1)));
591 assert_eq!(view.selected, 2);
592 view.handle_mouse(moved(rect.y.saturating_add(2)));
593 assert_eq!(view.selected, 0);
594 view.handle_mouse(moved(rect.y.saturating_add(3)));
595 assert_eq!(view.selected, 0);
596
597 view.handle_mouse(moved(rect.y.saturating_add(4)));
598 assert_eq!(view.selected, 1);
599 view.handle_mouse(moved(rect.y.saturating_add(5)));
600 assert_eq!(view.selected, 2);
601 }
602
603 #[test]
604 fn untitled_menu_entries_start_on_first_body_row() {
605 let view = ContextMenuView::new_with_motion(
606 vec![ContextMenuEntry::new(
607 "Copy",
608 "",
609 ContextMenuAction::CopySelection,
610 )],
611 0,
612 0,
613 String::new(),
614 false,
615 );
616 let rect = Rect::new(10, 5, 30, 5);
617
618 assert_eq!(view.entry_at_row(rect.y.saturating_add(1), rect), Some(0));
619 }
620 }
621
621 lines RUST