Skip to main content

hjkl_menu_tui/
lib.rs

1//! Ratatui adapter for `hjkl-menu`.
2//!
3//! Paints a [`ContextMenu`] into a ratatui [`Frame`] using [`MenuTheme`] for
4//! styling. The popup is a floating bordered box clamped to `screen_size`.
5//!
6//! # Usage
7//!
8//! ```rust,no_run
9//! // (requires a real ratatui terminal — compile-checked, not run in CI)
10//! use hjkl_menu::{build_code_menu, ContextMenu};
11//! use hjkl_menu_tui::{MenuTheme, bounding_rect, render};
12//! // Build menu:
13//! // let items = build_code_menu(true, true);
14//! // let menu  = ContextMenu::new(items, (col, row));
15//! // Compute rect and render:
16//! // let screen = frame.area();
17//! // let rect   = bounding_rect(&menu, screen);
18//! // render(frame, &menu, screen, &MenuTheme::default());
19//! ```
20
21#![forbid(unsafe_code)]
22
23use hjkl_menu::{ContextMenu, MenuAction, MenuItem};
24use ratatui::{
25    Frame,
26    layout::Rect,
27    style::{Color, Modifier, Style},
28    text::{Line, Span},
29    widgets::{Block, Borders, Clear, Paragraph},
30};
31
32// ── MenuTheme ─────────────────────────────────────────────────────────────────
33
34/// Color palette for the context menu.
35///
36/// `#[non_exhaustive]` — new color slots may be added in minor releases.
37/// Construct via [`MenuTheme::default`] then override individual fields.
38#[non_exhaustive]
39#[derive(Clone, Debug)]
40pub struct MenuTheme {
41    /// Border color.
42    pub border: Color,
43    /// Foreground for normal (not selected, not disabled) items.
44    pub normal_fg: Color,
45    /// Foreground and background for the highlighted row.
46    pub selected_fg: Color,
47    /// Background for the highlighted row.
48    pub selected_bg: Color,
49    /// Foreground for disabled items and hint text.
50    pub dimmed_fg: Color,
51    /// Foreground for separator lines.
52    pub separator_fg: Color,
53}
54
55impl Default for MenuTheme {
56    fn default() -> Self {
57        Self {
58            border: Color::Gray,
59            normal_fg: Color::White,
60            selected_fg: Color::Black,
61            selected_bg: Color::White,
62            dimmed_fg: Color::DarkGray,
63            separator_fg: Color::DarkGray,
64        }
65    }
66}
67
68impl MenuTheme {
69    /// Construct from explicit values.
70    pub fn new(
71        border: Color,
72        normal_fg: Color,
73        selected_fg: Color,
74        selected_bg: Color,
75        dimmed_fg: Color,
76        separator_fg: Color,
77    ) -> Self {
78        Self {
79            border,
80            normal_fg,
81            selected_fg,
82            selected_bg,
83            dimmed_fg,
84            separator_fg,
85        }
86    }
87}
88
89// ── Helpers ───────────────────────────────────────────────────────────────────
90
91/// Convert `hjkl_menu::ContextMenu` geometry to a ratatui [`Rect`] clamped
92/// to `screen_size`.
93///
94/// This is a thin wrapper around [`ContextMenu::bounding_rect`] that converts
95/// from `(u16, u16, u16, u16)` to `Rect`.
96///
97/// ```rust
98/// use hjkl_menu::{ContextMenu, MenuItem, MenuAction};
99/// use hjkl_menu_tui::bounding_rect;
100/// use ratatui::layout::Rect;
101///
102/// let items = vec![MenuItem::new("Copy", MenuAction::Copy, None)];
103/// let menu  = ContextMenu::new(items, (10, 5));
104/// let screen = Rect::new(0, 0, 80, 24);
105/// let r = bounding_rect(&menu, screen);
106/// assert!(r.x + r.width  <= screen.width);
107/// assert!(r.y + r.height <= screen.height);
108/// ```
109pub fn bounding_rect(menu: &ContextMenu, screen_size: Rect) -> Rect {
110    let (x, y, w, h) = menu.bounding_rect(screen_size.width, screen_size.height);
111    // Re-add the screen origin (handles non-zero x/y terminals).
112    Rect {
113        x: screen_size.x + x,
114        y: screen_size.y + y,
115        width: w,
116        height: h,
117    }
118}
119
120/// Render `menu` as a floating bordered popup inside `screen_size`.
121///
122/// Call this after all other widgets so the popup floats above them.
123pub fn render(frame: &mut Frame, menu: &ContextMenu, screen_size: Rect, theme: &MenuTheme) {
124    if menu.items.is_empty() {
125        return;
126    }
127
128    let rect = bounding_rect(menu, screen_size);
129
130    // Clear the cells behind the popup.
131    frame.render_widget(Clear, rect);
132
133    let block = Block::default()
134        .borders(Borders::ALL)
135        .border_style(Style::default().fg(theme.border));
136    let inner = block.inner(rect);
137    frame.render_widget(block, rect);
138
139    let content_w = inner.width;
140    for (i, item) in menu.items.iter().enumerate() {
141        let row_y = inner.y + i as u16;
142        if row_y >= inner.y + inner.height {
143            break;
144        }
145        let row_rect = Rect {
146            x: inner.x,
147            y: row_y,
148            width: content_w,
149            height: 1,
150        };
151        render_item(frame, item, i, menu.selected, content_w, row_rect, theme);
152    }
153}
154
155/// Render a single menu row into `row_rect`.
156fn render_item(
157    frame: &mut Frame,
158    item: &MenuItem,
159    idx: usize,
160    selected: usize,
161    content_w: u16,
162    row_rect: Rect,
163    theme: &MenuTheme,
164) {
165    // ── Separator ──────────────────────────────────────────────────────────
166    if item.action == MenuAction::Separator {
167        let sep: String = "─".repeat(content_w as usize);
168        let para = Paragraph::new(sep).style(Style::default().fg(theme.separator_fg));
169        frame.render_widget(para, row_rect);
170        return;
171    }
172
173    // ── Info header (dimmed, not highlighted) ──────────────────────────────
174    if item.action == MenuAction::Info {
175        let line = Line::from(vec![
176            Span::raw(" "),
177            Span::styled(item.label.clone(), Style::default().fg(theme.dimmed_fg)),
178        ]);
179        frame.render_widget(Paragraph::new(line), row_rect);
180        return;
181    }
182
183    let is_selected = idx == selected;
184    let is_disabled = !item.enabled;
185
186    let label_style = if is_disabled {
187        Style::default().fg(theme.dimmed_fg)
188    } else if is_selected {
189        Style::default()
190            .fg(theme.selected_fg)
191            .bg(theme.selected_bg)
192            .add_modifier(Modifier::BOLD)
193    } else {
194        Style::default().fg(theme.normal_fg)
195    };
196
197    let hint_style = if is_disabled {
198        Style::default().fg(theme.dimmed_fg)
199    } else if is_selected {
200        Style::default().fg(theme.dimmed_fg).bg(theme.selected_bg)
201    } else {
202        Style::default().fg(theme.dimmed_fg)
203    };
204
205    let label = &item.label;
206    let hint = item.shortcut_hint.as_deref().unwrap_or("");
207    let hint_len = if hint.is_empty() { 0 } else { hint.len() + 2 };
208    let gap = (content_w as usize).saturating_sub(label.len() + hint_len + 1);
209
210    let line = if hint.is_empty() {
211        Line::from(vec![
212            Span::raw(" "),
213            Span::styled(label.clone(), label_style),
214        ])
215    } else {
216        let spaces = " ".repeat(gap.max(1));
217        Line::from(vec![
218            Span::raw(" "),
219            Span::styled(label.clone(), label_style),
220            Span::raw(spaces),
221            Span::styled(hint.to_string(), hint_style),
222        ])
223    };
224
225    let row_bg = if is_selected {
226        Style::default().bg(theme.selected_bg)
227    } else {
228        Style::default()
229    };
230    frame.render_widget(Paragraph::new(line).style(row_bg), row_rect);
231}
232
233// ── Unit tests ────────────────────────────────────────────────────────────────
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use hjkl_menu::{
239        ContextMenu, MenuAction, MenuItem, build_code_menu, build_picker_menu,
240        build_split_border_menu, build_status_line_menu, build_tab_menu,
241    };
242
243    fn make_menu() -> ContextMenu {
244        let items = vec![
245            MenuItem::new("Cut", MenuAction::Cut, None),
246            MenuItem::new("Copy", MenuAction::Copy, None),
247            MenuItem::separator(),
248            MenuItem::new("Paste", MenuAction::Paste, None),
249        ];
250        ContextMenu::new(items, (0, 0))
251    }
252
253    // ── bounding_rect ───────────────────────────────────────────────────────
254
255    #[test]
256    fn bounding_rect_stays_inside_screen() {
257        let items: Vec<_> = (0..6)
258            .map(|i| MenuItem::new(format!("Item {i}"), MenuAction::Paste, None))
259            .collect();
260        let menu = ContextMenu::new(items, (5, 22));
261        let screen = Rect::new(0, 0, 80, 24);
262        let r = bounding_rect(&menu, screen);
263        assert!(
264            r.x + r.width <= screen.width,
265            "right edge must not exceed screen width"
266        );
267        assert!(
268            r.y + r.height <= screen.height,
269            "bottom edge must not exceed screen height"
270        );
271    }
272
273    #[test]
274    fn bounding_rect_near_bottom_flips_upward() {
275        let items: Vec<_> = (0..6)
276            .map(|i| MenuItem::new(format!("Item {i}"), MenuAction::Paste, None))
277            .collect();
278        let menu = ContextMenu::new(items, (5, 22));
279        let screen = Rect::new(0, 0, 80, 24);
280        let r = bounding_rect(&menu, screen);
281        assert_eq!(r.height, 8, "6 items + 2 border = 8");
282        assert!(
283            r.y < 22,
284            "popup must flip above anchor row 22; got y={}",
285            r.y
286        );
287        assert_eq!(r.y, 24 - 8);
288    }
289
290    #[test]
291    fn bounding_rect_near_right_shifts_left() {
292        let items = vec![
293            MenuItem::new("Reasonably Long Item Label", MenuAction::Paste, None),
294            MenuItem::new("Another Long Item Label", MenuAction::Copy, None),
295        ];
296        let menu = ContextMenu::new(items, (75, 5));
297        let screen = Rect::new(0, 0, 80, 24);
298        let r = bounding_rect(&menu, screen);
299        assert!(
300            r.x + r.width <= screen.width,
301            "right edge {} must not exceed 80",
302            r.x + r.width
303        );
304        assert!(
305            r.x < 75,
306            "must have shifted left from anchor 75; got x={}",
307            r.x
308        );
309    }
310
311    #[test]
312    fn bounding_rect_with_offset_screen_origin() {
313        // Screen doesn't start at (0,0) — bounding_rect should add origin offset.
314        let items = vec![MenuItem::new("Copy", MenuAction::Copy, None)];
315        let menu = ContextMenu::new(items, (0, 0));
316        let screen = Rect::new(5, 3, 80, 24);
317        let r = bounding_rect(&menu, screen);
318        // Origin offset must be included in x/y.
319        assert!(r.x >= 5, "x must be >= screen origin x=5; got {}", r.x);
320        assert!(r.y >= 3, "y must be >= screen origin y=3; got {}", r.y);
321    }
322
323    // ── row→item index math (regression for flipped-popup hover) ───────────
324
325    #[test]
326    fn row_to_item_index_correct_for_flipped_popup() {
327        let items: Vec<_> = (0..4)
328            .map(|i| MenuItem::new(format!("Item {i}"), MenuAction::Paste, None))
329            .collect();
330        let menu = ContextMenu::new(items, (5, 22));
331        let screen = Rect::new(0, 0, 80, 24);
332        let r = bounding_rect(&menu, screen);
333        // 4 items + 2 border = 6 rows; rect.y = 24-6 = 18.
334        assert_eq!(r.y, 18);
335        let row0 = r.y + 1;
336        let row3 = r.y + 4;
337        assert_eq!((row0 - r.y - 1) as usize, 0);
338        assert_eq!((row3 - r.y - 1) as usize, 3);
339    }
340
341    // ── MenuTheme ───────────────────────────────────────────────────────────
342
343    #[test]
344    fn menu_theme_default_fields() {
345        let t = MenuTheme::default();
346        assert_eq!(t.border, Color::Gray);
347        assert_eq!(t.selected_bg, Color::White);
348        assert_eq!(t.selected_fg, Color::Black);
349    }
350
351    #[test]
352    fn menu_theme_new_roundtrip() {
353        let t = MenuTheme::new(
354            Color::Red,
355            Color::Green,
356            Color::Blue,
357            Color::Yellow,
358            Color::Magenta,
359            Color::Cyan,
360        );
361        assert_eq!(t.border, Color::Red);
362        assert_eq!(t.normal_fg, Color::Green);
363        assert_eq!(t.selected_fg, Color::Blue);
364        assert_eq!(t.selected_bg, Color::Yellow);
365        assert_eq!(t.dimmed_fg, Color::Magenta);
366        assert_eq!(t.separator_fg, Color::Cyan);
367    }
368
369    // ── Builder smoke tests (ensure builders produce non-empty menus) ────────
370
371    #[test]
372    fn build_code_menu_non_empty() {
373        assert!(!build_code_menu(true, true).is_empty());
374    }
375
376    #[test]
377    fn build_status_line_menu_non_empty() {
378        assert!(!build_status_line_menu("rust", Some("rust-analyzer")).is_empty());
379    }
380
381    #[test]
382    fn build_split_border_menu_non_empty() {
383        assert!(!build_split_border_menu().is_empty());
384    }
385
386    #[test]
387    fn build_picker_menu_non_empty() {
388        assert!(!build_picker_menu(true).is_empty());
389    }
390
391    #[test]
392    fn build_tab_menu_non_empty() {
393        assert!(!build_tab_menu(true).is_empty());
394    }
395
396    // ── ContextMenu from make_menu is well-formed ────────────────────────────
397
398    #[test]
399    fn make_menu_initial_selection_is_selectable() {
400        let m = make_menu();
401        let item = &m.items[m.selected];
402        assert!(item.is_selectable());
403    }
404}