Skip to main content

buffr_ui/
context_menu.rs

1//! Context-menu overlay widget.
2//!
3//! Renders a floating right-click menu at the click coordinates, clamped
4//! to stay inside the visible window region.  The caller is responsible
5//! for keyboard / mouse dispatch; this struct is purely a renderer.
6//!
7//! # Layout
8//!
9//! ```text
10//! ┌─────────────────────────┐
11//! │ Back                    │  ← selected row (highlighted)
12//! │ Forward                 │
13//! │─────────────────────────│  ← separator (hairline)
14//! │ Reload                  │
15//! └─────────────────────────┘
16//! ```
17
18use crate::fill_rect;
19use crate::font;
20
21/// Height of a single selectable item row in pixels.
22pub const CONTEXT_MENU_ROW_HEIGHT: u32 = 24;
23/// Height of a separator row in pixels.
24pub const CONTEXT_MENU_SEP_HEIGHT: u32 = 6;
25/// Horizontal padding inside the menu panel.
26pub const CONTEXT_MENU_PADDING_X: u32 = 12;
27/// Minimum menu width in pixels.
28pub const CONTEXT_MENU_MIN_WIDTH: u32 = 180;
29
30// ── colours ───────────────────────────────────────────────────────────────────
31
32const BG: u32 = 0xFF_1E_20_2E;
33const BG_SELECTED: u32 = 0xFF_7A_A2_F7;
34const FG: u32 = 0xFF_EE_EE_EE;
35const FG_SELECTED: u32 = 0xFF_0A_0C_14;
36const FG_DISABLED: u32 = 0xFF_60_68_80;
37const SEP_COLOR: u32 = 0xFF_38_3C_52;
38const BORDER_COLOR: u32 = 0xFF_7A_A2_F7;
39
40/// One entry as seen by the widget.  The caller resolves `label` from
41/// `ContextMenuItem::label()` and sets `is_separator` / `enabled`.
42#[derive(Debug, Clone)]
43pub struct ContextMenuEntry {
44    /// Resolved human-readable label. Empty string for separators.
45    pub label: String,
46    /// Whether this row is a visual separator (non-selectable).
47    pub is_separator: bool,
48    /// Whether the item is interactive. Disabled items are rendered dimmed
49    /// and skipped by keyboard navigation.
50    pub enabled: bool,
51}
52
53/// Snapshot passed to [`ContextMenuOverlay::paint_at`] each frame.
54#[derive(Debug, Clone)]
55pub struct ContextMenuOverlay {
56    /// Ordered list of entries to render.
57    pub entries: Vec<ContextMenuEntry>,
58    /// Index into `entries` of the currently-selected row.
59    /// The caller is responsible for clamping to selectable rows.
60    pub selected: usize,
61    /// Requested pixel origin (top-left of the menu panel) in
62    /// chrome-buffer coordinates. The widget clamps this so the menu
63    /// stays inside `(buf_w, buf_h)`.
64    pub x: i32,
65    pub y: i32,
66}
67
68impl ContextMenuOverlay {
69    /// Compute the pixel width required to display all entry labels.
70    pub fn preferred_width(&self) -> u32 {
71        let label_w = self
72            .entries
73            .iter()
74            .filter(|e| !e.is_separator)
75            .map(|e| font::text_width(&e.label))
76            .max()
77            .unwrap_or(0) as u32;
78        (label_w + 2 * CONTEXT_MENU_PADDING_X + 2).max(CONTEXT_MENU_MIN_WIDTH)
79    }
80
81    /// Compute the pixel height of the entire panel.
82    pub fn preferred_height(&self) -> u32 {
83        let mut h: u32 = 2; // top + bottom border px
84        for e in &self.entries {
85            h += if e.is_separator {
86                CONTEXT_MENU_SEP_HEIGHT
87            } else {
88                CONTEXT_MENU_ROW_HEIGHT
89            };
90        }
91        h
92    }
93
94    /// Paint the overlay into `buf`.
95    ///
96    /// `buf_w` and `buf_h` are the full chrome buffer dimensions.
97    /// The panel is clamped to stay fully inside the buffer.
98    pub fn paint(&self, buf: &mut [u32], buf_w: usize, buf_h: usize) {
99        if self.entries.is_empty() || buf_w == 0 || buf_h == 0 {
100            return;
101        }
102
103        let panel_w = self.preferred_width() as i32;
104        let panel_h = self.preferred_height() as i32;
105
106        // Clamp to viewport.
107        let px = self.x.clamp(0, (buf_w as i32 - panel_w).max(0));
108        let py = self.y.clamp(0, (buf_h as i32 - panel_h).max(0));
109
110        // Border.
111        fill_rect(
112            buf,
113            buf_w,
114            buf_h,
115            px,
116            py,
117            panel_w as usize,
118            panel_h as usize,
119            BORDER_COLOR,
120        );
121        // Background inside border.
122        fill_rect(
123            buf,
124            buf_w,
125            buf_h,
126            px + 1,
127            py + 1,
128            (panel_w - 2).max(0) as usize,
129            (panel_h - 2).max(0) as usize,
130            BG,
131        );
132
133        let mut cursor_y = py + 1i32;
134        for (idx, entry) in self.entries.iter().enumerate() {
135            if entry.is_separator {
136                // Draw a hairline separator.
137                let sep_mid = cursor_y + CONTEXT_MENU_SEP_HEIGHT as i32 / 2;
138                fill_rect(
139                    buf,
140                    buf_w,
141                    buf_h,
142                    px + 1,
143                    sep_mid,
144                    (panel_w - 2).max(0) as usize,
145                    1,
146                    SEP_COLOR,
147                );
148                cursor_y += CONTEXT_MENU_SEP_HEIGHT as i32;
149                continue;
150            }
151
152            let row_h = CONTEXT_MENU_ROW_HEIGHT as i32;
153            let is_selected = idx == self.selected;
154
155            // Row background.
156            let row_bg = if is_selected { BG_SELECTED } else { BG };
157            fill_rect(
158                buf,
159                buf_w,
160                buf_h,
161                px + 1,
162                cursor_y,
163                (panel_w - 2).max(0) as usize,
164                row_h as usize,
165                row_bg,
166            );
167
168            // Label text, vertically centred in the row. Disabled rows
169            // keep their dimmed text even when highlighted — the bg flips
170            // to BG_SELECTED so the row is still visually picked up by
171            // hover, but the text colour signals "not interactive".
172            let text_color = if !entry.enabled {
173                FG_DISABLED
174            } else if is_selected {
175                FG_SELECTED
176            } else {
177                FG
178            };
179            let text_y = cursor_y + (row_h - font::glyph_h() as i32) / 2;
180            font::draw_text(
181                buf,
182                buf_w,
183                buf_h,
184                px + CONTEXT_MENU_PADDING_X as i32,
185                text_y,
186                &entry.label,
187                text_color,
188            );
189
190            cursor_y += row_h;
191        }
192    }
193
194    /// Compute the clamped panel rect `(x, y, w, h)` for a buffer of size
195    /// `(buf_w, buf_h)`. Mirrors the clamp logic in [`Self::paint`] so
196    /// callers can hit-test the same pixels that render.
197    pub fn panel_rect(&self, buf_w: usize, buf_h: usize) -> (i32, i32, i32, i32) {
198        let panel_w = self.preferred_width() as i32;
199        let panel_h = self.preferred_height() as i32;
200        let px = self.x.clamp(0, (buf_w as i32 - panel_w).max(0));
201        let py = self.y.clamp(0, (buf_h as i32 - panel_h).max(0));
202        (px, py, panel_w, panel_h)
203    }
204
205    /// True if pixel `(x, y)` (in chrome-buffer coords) falls inside the
206    /// clamped panel rect.
207    pub fn contains(&self, buf_w: usize, buf_h: usize, x: i32, y: i32) -> bool {
208        let (px, py, pw, ph) = self.panel_rect(buf_w, buf_h);
209        x >= px && x < px + pw && y >= py && y < py + ph
210    }
211
212    /// Resolve pixel `(x, y)` to a row index, or `None` if the hit lands
213    /// on a separator, on the border, or outside the panel.
214    ///
215    /// **Disabled rows are returned by index**, not filtered out — callers
216    /// still want to highlight them on hover for visual continuity. Gate
217    /// activation on the entry's `enabled` flag at the call site.
218    pub fn row_at(&self, buf_w: usize, buf_h: usize, x: i32, y: i32) -> Option<usize> {
219        if !self.contains(buf_w, buf_h, x, y) {
220            return None;
221        }
222        let (_px, py, _pw, _ph) = self.panel_rect(buf_w, buf_h);
223        let mut row_y = py + 1; // skip top border pixel
224        for (idx, entry) in self.entries.iter().enumerate() {
225            let row_h = if entry.is_separator {
226                CONTEXT_MENU_SEP_HEIGHT as i32
227            } else {
228                CONTEXT_MENU_ROW_HEIGHT as i32
229            };
230            if y >= row_y && y < row_y + row_h {
231                if entry.is_separator {
232                    return None;
233                }
234                return Some(idx);
235            }
236            row_y += row_h;
237        }
238        None
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn make_buf(w: usize, h: usize) -> Vec<u32> {
247        vec![0u32; w * h]
248    }
249
250    fn simple_menu(x: i32, y: i32) -> ContextMenuOverlay {
251        ContextMenuOverlay {
252            entries: vec![
253                ContextMenuEntry {
254                    label: "Back".into(),
255                    is_separator: false,
256                    enabled: true,
257                },
258                ContextMenuEntry {
259                    label: "".into(),
260                    is_separator: true,
261                    enabled: false,
262                },
263                ContextMenuEntry {
264                    label: "Reload".into(),
265                    is_separator: false,
266                    enabled: true,
267                },
268            ],
269            selected: 0,
270            x,
271            y,
272        }
273    }
274
275    #[test]
276    fn paint_does_not_panic() {
277        let w = 800;
278        let h = 600;
279        let mut buf = make_buf(w, h);
280        simple_menu(100, 200).paint(&mut buf, w, h);
281    }
282
283    #[test]
284    fn paint_writes_pixels_in_menu_area() {
285        let w = 800;
286        let h = 600;
287        let mut buf = make_buf(w, h);
288        simple_menu(0, 0).paint(&mut buf, w, h);
289        assert!(buf.iter().any(|&p| p != 0));
290    }
291
292    #[test]
293    fn clamps_menu_to_viewport_right_edge() {
294        let w = 800;
295        let h = 600;
296        let mut buf = make_buf(w, h);
297        // x=10000 should clamp so menu fits inside.
298        simple_menu(10000, 0).paint(&mut buf, w, h);
299        // Top-right corner of the buffer should have non-zero pixels (menu
300        // border rendered at the right edge).
301        let first_row_right = w - 1; // rightmost pixel of row 0
302        // After clamping the menu paints at x = buf_w - panel_w; at minimum
303        // the border pixel at that column exists.
304        assert!(buf[first_row_right] != 0 || buf.iter().any(|&p| p != 0));
305    }
306
307    #[test]
308    fn preferred_width_is_at_least_min() {
309        let m = simple_menu(0, 0);
310        assert!(m.preferred_width() >= CONTEXT_MENU_MIN_WIDTH);
311    }
312
313    #[test]
314    fn preferred_height_accounts_for_all_entries() {
315        let m = simple_menu(0, 0);
316        let expected =
317            2 + CONTEXT_MENU_ROW_HEIGHT + CONTEXT_MENU_SEP_HEIGHT + CONTEXT_MENU_ROW_HEIGHT;
318        assert_eq!(m.preferred_height(), expected);
319    }
320
321    #[test]
322    fn contains_inside_and_outside() {
323        let m = simple_menu(50, 60);
324        let (x, y, w, h) = m.panel_rect(800, 600);
325        assert!(m.contains(800, 600, x, y));
326        assert!(m.contains(800, 600, x + w - 1, y + h - 1));
327        assert!(!m.contains(800, 600, x - 1, y));
328        assert!(!m.contains(800, 600, x, y - 1));
329        assert!(!m.contains(800, 600, x + w, y));
330    }
331
332    #[test]
333    fn row_at_resolves_selectable_rows() {
334        let m = simple_menu(50, 60);
335        let (px, py, _, _) = m.panel_rect(800, 600);
336        // First row centre.
337        let row0_y = py + 1 + CONTEXT_MENU_ROW_HEIGHT as i32 / 2;
338        assert_eq!(m.row_at(800, 600, px + 10, row0_y), Some(0));
339        // Separator row centre — non-selectable.
340        let sep_y = py + 1 + CONTEXT_MENU_ROW_HEIGHT as i32 + CONTEXT_MENU_SEP_HEIGHT as i32 / 2;
341        assert_eq!(m.row_at(800, 600, px + 10, sep_y), None);
342        // Third (Reload) row centre.
343        let row2_y = py
344            + 1
345            + CONTEXT_MENU_ROW_HEIGHT as i32
346            + CONTEXT_MENU_SEP_HEIGHT as i32
347            + CONTEXT_MENU_ROW_HEIGHT as i32 / 2;
348        assert_eq!(m.row_at(800, 600, px + 10, row2_y), Some(2));
349        // Outside panel.
350        assert_eq!(m.row_at(800, 600, 0, 0), None);
351    }
352
353    #[test]
354    fn row_at_returns_disabled_rows_for_hover_continuity() {
355        // Disabled rows should still resolve via row_at so callers can
356        // highlight them on hover; activation gating happens at the call
357        // site. Only separators / outside hits return None.
358        let m = ContextMenuOverlay {
359            entries: vec![
360                ContextMenuEntry {
361                    label: "Back".into(),
362                    is_separator: false,
363                    enabled: false, // disabled
364                },
365                ContextMenuEntry {
366                    label: "Reload".into(),
367                    is_separator: false,
368                    enabled: true,
369                },
370            ],
371            selected: 1,
372            x: 10,
373            y: 10,
374        };
375        let (px, py, _, _) = m.panel_rect(800, 600);
376        let row0_y = py + 1 + CONTEXT_MENU_ROW_HEIGHT as i32 / 2;
377        assert_eq!(m.row_at(800, 600, px + 10, row0_y), Some(0));
378    }
379}