| 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 |