1#![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#[non_exhaustive]
39#[derive(Clone, Debug)]
40pub struct MenuTheme {
41 pub border: Color,
43 pub normal_fg: Color,
45 pub selected_fg: Color,
47 pub selected_bg: Color,
49 pub dimmed_fg: Color,
51 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 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
89pub 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 Rect {
113 x: screen_size.x + x,
114 y: screen_size.y + y,
115 width: w,
116 height: h,
117 }
118}
119
120pub 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 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
155fn 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 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 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#[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 #[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 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 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 #[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 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 #[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 #[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 #[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}