1use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
3use ratatui::buffer::Buffer;
4use ratatui::layout::Rect;
5use ratatui::style::{Color, Modifier, Style};
6use std::cell::Cell;
7
8use super::context::AppContext;
9use super::{EventPropagation, Widget, WidgetId};
10use crate::event::keybinding::KeyBinding;
11
12#[derive(Clone, Debug)]
14pub struct ContextMenuItem {
15 pub label: String,
17 pub action: String,
19 pub shortcut: Option<String>,
21}
22
23impl ContextMenuItem {
24 pub fn new(label: &str, action: &str) -> Self {
26 Self {
27 label: label.to_string(),
28 action: action.to_string(),
29 shortcut: None,
30 }
31 }
32
33 pub fn with_shortcut(mut self, shortcut: &str) -> Self {
35 self.shortcut = Some(shortcut.to_string());
36 self
37 }
38}
39
40pub(crate) struct ContextMenuOverlay {
43 pub items: Vec<ContextMenuItem>,
44 pub cursor: Cell<usize>,
45 pub source_id: Option<WidgetId>,
47 pub anchor_x: u16,
49 pub anchor_y: u16,
50 last_area: Cell<(u16, u16, u16, u16)>, }
52
53impl ContextMenuOverlay {
54 pub fn new(
55 items: Vec<ContextMenuItem>,
56 source_id: Option<WidgetId>,
57 anchor_x: u16,
58 anchor_y: u16,
59 ) -> Self {
60 Self {
61 items,
62 cursor: Cell::new(0),
63 source_id,
64 anchor_x,
65 anchor_y,
66 last_area: Cell::new((0, 0, 0, 0)),
67 }
68 }
69}
70
71static CONTEXT_MENU_BINDINGS: &[KeyBinding] = &[
72 KeyBinding {
73 key: KeyCode::Up,
74 modifiers: KeyModifiers::NONE,
75 action: "cursor_up",
76 description: "Move up",
77 show: false,
78 },
79 KeyBinding {
80 key: KeyCode::Down,
81 modifiers: KeyModifiers::NONE,
82 action: "cursor_down",
83 description: "Move down",
84 show: false,
85 },
86 KeyBinding {
87 key: KeyCode::Enter,
88 modifiers: KeyModifiers::NONE,
89 action: "execute",
90 description: "Execute",
91 show: false,
92 },
93 KeyBinding {
94 key: KeyCode::Esc,
95 modifiers: KeyModifiers::NONE,
96 action: "close",
97 description: "Close",
98 show: false,
99 },
100];
101
102impl Widget for ContextMenuOverlay {
103 fn widget_type_name(&self) -> &'static str {
104 "ContextMenu"
105 }
106
107 fn can_focus(&self) -> bool {
108 true
109 }
110
111 fn is_overlay(&self) -> bool {
112 true
113 }
114
115 fn default_css() -> &'static str
116 where
117 Self: Sized,
118 {
119 ""
120 }
121
122 fn widget_default_css(&self) -> &'static str {
123 ""
124 }
125
126 fn key_bindings(&self) -> &[KeyBinding] {
127 CONTEXT_MENU_BINDINGS
128 }
129
130 fn on_event(&self, event: &dyn std::any::Any, ctx: &AppContext) -> EventPropagation {
131 if let Some(m) = event.downcast_ref::<MouseEvent>() {
132 match m.kind {
133 MouseEventKind::Down(MouseButton::Left) => {
134 let (ax, ay, aw, ah) = self.last_area.get();
135 if m.column >= ax && m.column < ax + aw && m.row >= ay && m.row < ay + ah {
137 let local_row = (m.row - ay) as usize;
138 if local_row < self.items.len() {
139 self.cursor.set(local_row);
140 self.on_action("execute", ctx);
141 return EventPropagation::Stop;
142 }
143 }
144 ctx.dismiss_overlay();
146 return EventPropagation::Stop;
147 }
148 MouseEventKind::Down(MouseButton::Right) => {
149 ctx.dismiss_overlay();
151 return EventPropagation::Stop;
152 }
153 _ => {}
154 }
155 }
156 EventPropagation::Continue
157 }
158
159 fn on_action(&self, action: &str, ctx: &AppContext) {
160 match action {
161 "cursor_up" => {
162 let c = self.cursor.get();
163 if c > 0 {
164 self.cursor.set(c - 1);
165 } else {
166 self.cursor.set(self.items.len().saturating_sub(1));
167 }
168 }
169 "cursor_down" => {
170 let c = self.cursor.get();
171 if c + 1 < self.items.len() {
172 self.cursor.set(c + 1);
173 } else {
174 self.cursor.set(0);
175 }
176 }
177 "execute" => {
178 let idx = self.cursor.get();
179 if let Some(item) = self.items.get(idx) {
180 if let Some(source_id) = self.source_id {
182 if let Some(widget) = ctx.arena.get(source_id) {
183 widget.on_action(&item.action, ctx);
184 }
185 }
186 }
187 ctx.dismiss_overlay();
188 }
189 "close" => {
190 ctx.dismiss_overlay();
191 }
192 _ => {}
193 }
194 }
195
196 fn render(&self, _ctx: &AppContext, area: Rect, buf: &mut Buffer) {
197 if area.height == 0 || area.width == 0 || self.items.is_empty() {
198 return;
199 }
200
201 let max_label_len = self
203 .items
204 .iter()
205 .map(|item| {
206 let shortcut_len = item.shortcut.as_ref().map(|s| s.len() + 2).unwrap_or(0);
207 item.label.len() + shortcut_len
208 })
209 .max()
210 .unwrap_or(10);
211 let menu_width = (max_label_len + 4).min(area.width as usize) as u16; let menu_height = (self.items.len() as u16 + 2).min(area.height); let menu_x = self.anchor_x.min(area.x + area.width - menu_width);
216 let menu_y = if self.anchor_y + menu_height > area.y + area.height {
217 (area.y + area.height).saturating_sub(menu_height)
218 } else {
219 self.anchor_y
220 };
221
222 self.last_area.set((
223 menu_x,
224 menu_y + 1,
225 menu_width,
226 menu_height.saturating_sub(2),
227 ));
228
229 let border_color = Color::Rgb(100, 100, 120);
230 let bg = Color::Rgb(30, 30, 42);
231 let fg = Color::Rgb(224, 224, 224);
232
233 crate::canvas::mcgugan_box(
235 buf,
236 menu_x,
237 menu_y,
238 menu_width,
239 menu_height,
240 border_color,
241 bg,
242 Color::Reset,
243 );
244
245 for y in (menu_y + 1)..(menu_y + menu_height - 1) {
247 for x in (menu_x + 1)..(menu_x + menu_width - 1) {
248 if let Some(cell) = buf.cell_mut((x, y)) {
249 cell.set_symbol(" ");
250 cell.set_bg(bg);
251 }
252 }
253 }
254
255 let cursor = self.cursor.get();
257 let inner_width = (menu_width - 2) as usize;
258 for (i, item) in self.items.iter().enumerate() {
259 let y = menu_y + 1 + i as u16;
260 if y >= menu_y + menu_height - 1 {
261 break;
262 }
263
264 let is_selected = i == cursor;
265 let style = if is_selected {
266 Style::default()
267 .fg(Color::Rgb(0, 255, 163))
268 .bg(bg)
269 .add_modifier(Modifier::BOLD)
270 } else {
271 Style::default().fg(fg).bg(bg)
272 };
273
274 let shortcut_text = item.shortcut.as_deref().unwrap_or("");
276 let label_max = inner_width.saturating_sub(shortcut_text.len() + 1);
277 let label: String = item.label.chars().take(label_max).collect();
278 let padded = format!(" {:<width$}", label, width = inner_width - 1);
279 buf.set_string(menu_x + 1, y, &padded, style);
280
281 if !shortcut_text.is_empty() {
283 let shortcut_style = if is_selected {
284 Style::default().fg(Color::Rgb(0, 180, 120)).bg(bg)
285 } else {
286 Style::default().fg(Color::Rgb(100, 100, 120)).bg(bg)
287 };
288 let sx = menu_x + menu_width - 1 - shortcut_text.len() as u16 - 1;
289 buf.set_string(sx, y, shortcut_text, shortcut_style);
290 }
291 }
292 }
293}