返回 DeepSeek-TUI-2026
context_menu.rs
根目录 / crates / tui / src / tui / context_menu.rs
1 //! Right-click context menu for mouse-captured TUI sessions.
2
3 use std::cell::Cell;
4
5 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
6 use ratatui::{
7 buffer::Buffer,
8 layout::Rect,
9 style::{Modifier, Style},
10 text::{Line, Span},
11 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
12 };
13 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
14
15 use crate::palette;
16 use crate::tui::views::{ContextMenuAction, ModalKind, ModalView, ViewAction, ViewEvent};
17
18 #[derive(Debug, Clone)]
19 pub struct ContextMenuEntry {
20 pub label: String,
21 pub description: String,
22 pub action: ContextMenuAction,
23 }
24
25 pub struct ContextMenuView {
26 entries: Vec<ContextMenuEntry>,
27 selected: usize,
28 column: u16,
29 row: u16,
30 last_rect: Cell<Option<Rect>>,
31 }
32
33 impl ContextMenuView {
34 pub fn new(entries: Vec<ContextMenuEntry>, column: u16, row: u16) -> Self {
35 Self {
36 entries,
37 selected: 0,
38 column,
39 row,
40 last_rect: Cell::new(None),
41 }
42 }
43
44 fn selected_action(&self) -> Option<ContextMenuAction> {
45 self.entries
46 .get(self.selected)
47 .map(|entry| entry.action.clone())
48 }
49
50 fn move_selection(&mut self, delta: isize) {
51 if self.entries.is_empty() {
52 self.selected = 0;
53 return;
54 }
55 let max = self.entries.len().saturating_sub(1) as isize;
56 self.selected = (self.selected as isize + delta).clamp(0, max) as usize;
57 }
58
59 fn menu_width(&self, area_width: u16) -> u16 {
60 let widest = self
61 .entries
62 .iter()
63 .map(|entry| {
64 UnicodeWidthStr::width(entry.label.as_str())
65 + UnicodeWidthStr::width(entry.description.as_str())
66 + 8
67 })
68 .max()
69 .unwrap_or(20);
70 let width = u16::try_from(widest.clamp(24, 64)).unwrap_or(64);
71 width.min(area_width.max(1))
72 }
73
74 fn menu_rect(&self, area: Rect) -> Rect {
75 let width = self.menu_width(area.width);
76 let desired_height =
77 u16::try_from(self.entries.len().saturating_add(2)).unwrap_or(u16::MAX);
78 let height = desired_height.min(area.height.max(1));
79 let max_x = area.right().saturating_sub(width).max(area.x);
80 let max_y = area.bottom().saturating_sub(height).max(area.y);
81 let x = self.column.max(area.x).min(max_x);
82 let y = self.row.max(area.y).min(max_y);
83 Rect {
84 x,
85 y,
86 width,
87 height,
88 }
89 }
90
91 fn clicked_entry(&self, mouse: MouseEvent) -> Option<usize> {
92 let rect = self.last_rect.get()?;
93 if mouse.column <= rect.x
94 || mouse.column >= rect.right().saturating_sub(1)
95 || mouse.row <= rect.y
96 || mouse.row >= rect.bottom().saturating_sub(1)
97 {
98 return None;
99 }
100 let idx = mouse.row.saturating_sub(rect.y + 1) as usize;
101 (idx < self.entries.len()).then_some(idx)
102 }
103 }
104
105 impl ModalView for ContextMenuView {
106 fn kind(&self) -> ModalKind {
107 ModalKind::ContextMenu
108 }
109
110 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
111 self
112 }
113
114 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
115 match key.code {
116 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
117 KeyCode::Up | KeyCode::Char('k') => {
118 self.move_selection(-1);
119 ViewAction::None
120 }
121 KeyCode::Down | KeyCode::Char('j') => {
122 self.move_selection(1);
123 ViewAction::None
124 }
125 KeyCode::Enter => self.selected_action().map_or(ViewAction::Close, |action| {
126 ViewAction::EmitAndClose(ViewEvent::ContextMenuSelected { action })
127 }),
128 KeyCode::Char(c) if c.is_ascii_digit() => {
129 let idx = c.to_digit(10).and_then(|digit| {
130 let digit = usize::try_from(digit).ok()?;
131 digit.checked_sub(1)
132 });
133 if let Some(idx) = idx.filter(|idx| *idx < self.entries.len()) {
134 self.selected = idx;
135 return self.selected_action().map_or(ViewAction::Close, |action| {
136 ViewAction::EmitAndClose(ViewEvent::ContextMenuSelected { action })
137 });
138 }
139 ViewAction::None
140 }
141 _ => ViewAction::None,
142 }
143 }
144
145 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
146 match mouse.kind {
147 MouseEventKind::Down(MouseButton::Left) => {
148 if let Some(idx) = self.clicked_entry(mouse) {
149 self.selected = idx;
150 return self.selected_action().map_or(ViewAction::Close, |action| {
151 ViewAction::EmitAndClose(ViewEvent::ContextMenuSelected { action })
152 });
153 }
154 ViewAction::Close
155 }
156 MouseEventKind::Down(MouseButton::Right) => ViewAction::Close,
157 MouseEventKind::ScrollUp => {
158 self.move_selection(-1);
159 ViewAction::None
160 }
161 MouseEventKind::ScrollDown => {
162 self.move_selection(1);
163 ViewAction::None
164 }
165 _ => ViewAction::None,
166 }
167 }
168
169 fn render(&self, area: Rect, buf: &mut Buffer) {
170 let menu_area = self.menu_rect(area);
171 self.last_rect.set(Some(menu_area));
172 Clear.render(menu_area, buf);
173
174 let inner_width = menu_area.width.saturating_sub(2) as usize;
175 let lines = self
176 .entries
177 .iter()
178 .enumerate()
179 .map(|(idx, entry)| {
180 let label = format!("{} {}", idx + 1, entry.label);
181 let description = if entry.description.trim().is_empty() {
182 String::new()
183 } else {
184 format!(" - {}", entry.description)
185 };
186 let text = trim_to_width(&format!("{label}{description}"), inner_width);
187 let style = if idx == self.selected {
188 Style::default()
189 .fg(palette::TEXT_PRIMARY)
190 .bg(palette::DEEPSEEK_BLUE)
191 .add_modifier(Modifier::BOLD)
192 } else {
193 Style::default()
194 .fg(palette::TEXT_SOFT)
195 .bg(palette::SURFACE_ELEVATED)
196 };
197 Line::from(Span::styled(text, style))
198 })
199 .collect::<Vec<_>>();
200
201 let block = Block::default()
202 .title(" Right click ")
203 .borders(Borders::ALL)
204 .border_style(Style::default().fg(palette::DEEPSEEK_SKY))
205 .style(Style::default().bg(palette::SURFACE_ELEVATED))
206 .padding(Padding::horizontal(0));
207
208 Paragraph::new(lines).block(block).render(menu_area, buf);
209 }
210 }
211
212 fn trim_to_width(text: &str, max_width: usize) -> String {
213 if UnicodeWidthStr::width(text) <= max_width {
214 return text.to_string();
215 }
216 if max_width <= 3 {
217 return text.chars().take(max_width).collect();
218 }
219
220 let limit = max_width.saturating_sub(3);
221 let mut out = String::new();
222 let mut width = 0usize;
223 for ch in text.chars() {
224 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
225 if width + ch_width > limit {
226 break;
227 }
228 out.push(ch);
229 width += ch_width;
230 }
231 out.push_str("...");
232 out
233 }
234
235 #[cfg(test)]
236 mod tests {
237 use crossterm::event::KeyModifiers;
238 use ratatui::buffer::Buffer;
239
240 use super::*;
241
242 fn entry(label: &str, action: ContextMenuAction) -> ContextMenuEntry {
243 ContextMenuEntry {
244 label: label.to_string(),
245 description: String::new(),
246 action,
247 }
248 }
249
250 #[test]
251 fn enter_emits_selected_action() {
252 let mut view = ContextMenuView::new(
253 vec![
254 entry("Paste", ContextMenuAction::Paste),
255 entry("Help", ContextMenuAction::OpenHelp),
256 ],
257 5,
258 5,
259 );
260
261 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
262 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
263
264 assert!(matches!(
265 action,
266 ViewAction::EmitAndClose(ViewEvent::ContextMenuSelected {
267 action: ContextMenuAction::OpenHelp
268 })
269 ));
270 }
271
272 #[test]
273 fn menu_clamps_to_render_area() {
274 let view = ContextMenuView::new(vec![entry("Paste", ContextMenuAction::Paste)], 200, 80);
275
276 let rect = view.menu_rect(Rect {
277 x: 0,
278 y: 0,
279 width: 40,
280 height: 10,
281 });
282
283 assert!(rect.right() <= 40);
284 assert!(rect.bottom() <= 10);
285 }
286
287 #[test]
288 fn left_click_selects_rendered_entry() {
289 let mut view = ContextMenuView::new(
290 vec![
291 entry("Paste", ContextMenuAction::Paste),
292 entry("Help", ContextMenuAction::OpenHelp),
293 ],
294 2,
295 2,
296 );
297 let area = Rect {
298 x: 0,
299 y: 0,
300 width: 40,
301 height: 10,
302 };
303 let mut buf = Buffer::empty(area);
304 view.render(area, &mut buf);
305
306 let action = view.handle_mouse(MouseEvent {
307 kind: MouseEventKind::Down(MouseButton::Left),
308 column: 4,
309 row: 4,
310 modifiers: KeyModifiers::NONE,
311 });
312
313 assert!(matches!(
314 action,
315 ViewAction::EmitAndClose(ViewEvent::ContextMenuSelected {
316 action: ContextMenuAction::OpenHelp
317 })
318 ));
319 }
320 }
321
321 lines RUST