1use crate::fill_rect;
19use crate::font;
20
21pub const CONTEXT_MENU_ROW_HEIGHT: u32 = 24;
23pub const CONTEXT_MENU_SEP_HEIGHT: u32 = 6;
25pub const CONTEXT_MENU_PADDING_X: u32 = 12;
27pub const CONTEXT_MENU_MIN_WIDTH: u32 = 180;
29
30const 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#[derive(Debug, Clone)]
43pub struct ContextMenuEntry {
44 pub label: String,
46 pub is_separator: bool,
48 pub enabled: bool,
51}
52
53#[derive(Debug, Clone)]
55pub struct ContextMenuOverlay {
56 pub entries: Vec<ContextMenuEntry>,
58 pub selected: usize,
61 pub x: i32,
65 pub y: i32,
66}
67
68impl ContextMenuOverlay {
69 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 pub fn preferred_height(&self) -> u32 {
83 let mut h: u32 = 2; 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 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 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 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 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 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 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 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 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 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 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; 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 simple_menu(10000, 0).paint(&mut buf, w, h);
299 let first_row_right = w - 1; 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 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 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 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 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 let m = ContextMenuOverlay {
359 entries: vec![
360 ContextMenuEntry {
361 label: "Back".into(),
362 is_separator: false,
363 enabled: false, },
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}