Skip to main content

textual_rs/widget/
context_menu.rs

1//! Right-click context menu overlay widget and supporting types.
2use 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/// A single item in a context menu.
13#[derive(Clone, Debug)]
14pub struct ContextMenuItem {
15    /// Display label shown in the menu.
16    pub label: String,
17    /// Action string dispatched to the source widget when this item is activated.
18    pub action: String,
19    /// Optional keyboard shortcut hint shown right-aligned in the menu.
20    pub shortcut: Option<String>,
21}
22
23impl ContextMenuItem {
24    /// Create a new ContextMenuItem with a label and action, no shortcut.
25    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    /// Set the optional shortcut hint displayed on the right side of the item.
34    pub fn with_shortcut(mut self, shortcut: &str) -> Self {
35        self.shortcut = Some(shortcut.to_string());
36        self
37    }
38}
39
40/// Context menu overlay widget. Spawned on right-click, positioned at click coordinates.
41/// Renders as a floating panel with selectable items.
42pub(crate) struct ContextMenuOverlay {
43    pub items: Vec<ContextMenuItem>,
44    pub cursor: Cell<usize>,
45    /// The widget that was right-clicked (receives the action).
46    pub source_id: Option<WidgetId>,
47    /// Screen position where the menu should appear.
48    pub anchor_x: u16,
49    pub anchor_y: u16,
50    last_area: Cell<(u16, u16, u16, u16)>, // x, y, w, h for mouse hit testing
51}
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                    // Click inside menu — select item
136                    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                    // Click outside menu — close it
145                    ctx.dismiss_overlay();
146                    return EventPropagation::Stop;
147                }
148                MouseEventKind::Down(MouseButton::Right) => {
149                    // Right-click anywhere closes the menu
150                    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                    // Dispatch the action to the source widget
181                    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        // Calculate menu dimensions
202        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; // +4 for padding
212        let menu_height = (self.items.len() as u16 + 2).min(area.height); // +2 for border
213
214        // Position: try to place at anchor, adjust if overflows
215        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        // Draw McGugan box border
234        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        // Fill inside with bg
246        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        // Render items
256        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            // Label on left, shortcut on right
275            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            // Shortcut right-aligned
282            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}