1use retroglyph_core::color::{Color, Style};
4use retroglyph_core::grid::{Pos, Rect};
5
6use super::StatefulWidget;
7use crate::Surface;
8use crate::align::Align;
9use crate::draw::fill_rect;
10use crate::interact::Sense;
11use crate::state::MenuState;
12use crate::text::draw_clipped;
13use crate::theme::Theme;
14use crate::ui::Ui;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum Marker {
27 None,
29 Checkbox(bool),
31 Choice(bool),
33}
34
35impl Marker {
36 const fn text(self) -> &'static str {
38 match self {
39 Self::None => "",
40 Self::Checkbox(true) => "[x] ",
41 Self::Checkbox(false) => "[ ] ",
42 Self::Choice(true) => "(o) ",
43 Self::Choice(false) => "( ) ",
44 }
45 }
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum MenuRow<'a> {
51 Item {
53 label: &'a str,
55 marker: Marker,
57 enabled: bool,
61 },
62 Separator,
64 Label(&'a str),
67}
68
69impl MenuRow<'_> {
70 #[must_use]
73 pub const fn is_selectable(&self) -> bool {
74 matches!(self, Self::Item { enabled: true, .. })
75 }
76}
77
78struct Layout {
81 show_top: bool,
82 show_bottom: bool,
83 content_capacity: usize,
84}
85
86#[derive(Clone, Copy, Debug)]
133pub struct Menu<'a> {
134 rows: &'a [MenuRow<'a>],
135 item_style: Style,
136 selected_style: Style,
137 disabled_style: Style,
138 separator_style: Style,
139 label_style: Style,
140 overflow_style: Style,
141}
142
143impl<'a> Menu<'a> {
144 #[must_use]
146 pub fn new(rows: &'a [MenuRow<'a>]) -> Self {
147 Self {
148 rows,
149 item_style: Style::new(),
150 selected_style: Style::new(),
151 disabled_style: Style::new(),
152 separator_style: Style::new(),
153 label_style: Style::new(),
154 overflow_style: Style::new(),
155 }
156 .theme(Theme::DARK)
157 }
158
159 #[must_use]
161 pub const fn item_style(mut self, style: Style) -> Self {
162 self.item_style = style;
163 self
164 }
165
166 #[must_use]
168 pub const fn selected_style(mut self, style: Style) -> Self {
169 self.selected_style = style;
170 self
171 }
172
173 #[must_use]
175 pub const fn disabled_style(mut self, style: Style) -> Self {
176 self.disabled_style = style;
177 self
178 }
179
180 #[must_use]
182 pub const fn separator_style(mut self, style: Style) -> Self {
183 self.separator_style = style;
184 self
185 }
186
187 #[must_use]
189 pub const fn label_style(mut self, style: Style) -> Self {
190 self.label_style = style;
191 self
192 }
193
194 #[must_use]
196 pub const fn overflow_style(mut self, style: Style) -> Self {
197 self.overflow_style = style;
198 self
199 }
200
201 #[must_use]
208 pub fn theme(self, theme: Theme) -> Self {
209 self.theme_on(theme, theme.panel_bg)
210 }
211
212 #[must_use]
214 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
215 self.item_style = Style::new().fg(theme.fg).bg(bg);
216 self.label_style = Style::new().fg(theme.fg).bg(bg);
217 self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
218 self.disabled_style = Style::new().fg(theme.dim).bg(bg);
219 self.separator_style = Style::new().fg(theme.border).bg(bg);
220 self.overflow_style = Style::new().fg(theme.border).bg(bg);
221 self
222 }
223}
224
225impl Menu<'_> {
226 fn layout(&self, state: &MenuState, capacity: usize) -> Layout {
229 if capacity == 0 {
230 return Layout {
231 show_top: false,
232 show_bottom: false,
233 content_capacity: 0,
234 };
235 }
236 let show_top = state.offset() > 0;
237 let mut content_capacity = capacity - usize::from(show_top);
238 let show_bottom = self.rows.len().saturating_sub(state.offset()) > content_capacity;
239 if show_bottom {
240 content_capacity = content_capacity.saturating_sub(1);
241 }
242 Layout {
243 show_top,
244 show_bottom,
245 content_capacity,
246 }
247 }
248
249 fn draw(&self, surface: &mut Surface<'_>, state: &MenuState) {
251 let (width, height) = (surface.width(), surface.height());
252 if width == 0 || height == 0 || self.rows.is_empty() {
253 return;
254 }
255 let layout = self.layout(state, usize::from(height));
256
257 let mut y: u16 = 0;
258 if layout.show_top {
259 let _ = draw_clipped(
260 surface,
261 (0, y),
262 width,
263 "^",
264 Align::Left,
265 self.overflow_style,
266 );
267 y += 1;
268 }
269 for (index, row) in self
270 .rows
271 .iter()
272 .enumerate()
273 .skip(state.offset())
274 .take(layout.content_capacity)
275 {
276 let is_selected = Some(index) == state.selected();
277 let style = match row {
278 MenuRow::Separator => self.separator_style,
279 MenuRow::Label(_) => self.label_style,
280 MenuRow::Item { enabled: false, .. } => self.disabled_style,
281 MenuRow::Item { .. } if is_selected => self.selected_style,
282 MenuRow::Item { .. } => self.item_style,
283 };
284 if is_selected {
285 fill_rect(
286 surface,
287 Rect::new(0, y, width, 1),
288 ' ',
289 Style::new().bg(self.selected_style.background()),
290 );
291 }
292 match row {
293 MenuRow::Separator => {
294 fill_rect(surface, Rect::new(0, y, width, 1), '\u{2500}', style);
295 }
296 MenuRow::Label(text) => {
297 let _ = draw_clipped(surface, (0, y), width, text, Align::Left, style);
298 }
299 MenuRow::Item { label, marker, .. } => {
300 let marker_text = marker.text();
301 let marker_width = retroglyph_core::text::width(marker_text).min(width);
302 if marker_width > 0 {
303 let _ = draw_clipped(
304 surface,
305 (0, y),
306 marker_width,
307 marker_text,
308 Align::Left,
309 style,
310 );
311 }
312 let _ = draw_clipped(
313 surface,
314 (marker_width, y),
315 width - marker_width,
316 label,
317 Align::Left,
318 style,
319 );
320 }
321 }
322 y += 1;
323 }
324 if layout.show_bottom {
325 let _ = draw_clipped(
326 surface,
327 (0, y),
328 width,
329 "v",
330 Align::Left,
331 self.overflow_style,
332 );
333 }
334 }
335
336 fn index_at(&self, area: Rect, state: &MenuState, pos: Pos) -> Option<usize> {
339 let capacity = usize::from(area.height());
340 let layout = self.layout(state, capacity);
341 let row = usize::from(pos.y.checked_sub(area.top())?);
342 let content_row = row.checked_sub(usize::from(layout.show_top))?;
343 if content_row >= layout.content_capacity {
344 return None; }
346 let index = state.offset() + content_row;
347 (index < self.rows.len()).then_some(index)
348 }
349}
350
351impl StatefulWidget for Menu<'_> {
352 type State = MenuState;
353
354 fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State) {
355 self.draw(surface, state);
356 }
357}
358
359impl Menu<'_> {
360 #[must_use]
388 pub fn show<Id: Copy + PartialEq>(
389 &self,
390 ui: &mut Ui<'_, '_, Id>,
391 screen: Rect,
392 popup_area: Rect,
393 content_id: Id,
394 backdrop_id: Id,
395 state: &mut MenuState,
396 ) -> Option<usize> {
397 if !state.is_open() {
398 return None;
399 }
400
401 let backdrop = ui.region(screen, backdrop_id, Sense::click()).0;
402
403 let mut activated = None;
404 ui.modal(popup_area, |inner| {
405 let (response, mut surface) = inner.region(
406 popup_area,
407 content_id,
408 Sense::click() | Sense::HOVER | Sense::SCROLL,
409 );
410
411 let scroll_delta = response.scroll_delta();
412 if scroll_delta != 0 {
413 state.scroll_by(scroll_delta);
414 }
415 if response.clicked()
416 && let Some(pos) = response.pointer_pos()
417 && let Some(index) = self.index_at(popup_area, state, pos)
418 && self.rows[index].is_selectable()
419 {
420 state.select(Some(index));
421 activated = Some(index);
422 }
423
424 state.ensure_visible(usize::from(popup_area.height()));
425 self.draw(&mut surface, state);
426 });
427
428 if activated.is_some() || backdrop.clicked() {
429 state.close();
430 }
431
432 activated
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use retroglyph_core::grid::{Grid, Pos};
439
440 use super::*;
441 use crate::interact::Interaction;
442
443 const ROWS: [MenuRow<'static>; 5] = [
444 MenuRow::Separator,
445 MenuRow::Item {
446 label: "Cut",
447 marker: Marker::None,
448 enabled: false,
449 },
450 MenuRow::Item {
451 label: "Copy",
452 marker: Marker::None,
453 enabled: true,
454 },
455 MenuRow::Item {
456 label: "Word Wrap",
457 marker: Marker::Checkbox(true),
458 enabled: true,
459 },
460 MenuRow::Label("More"),
461 ];
462
463 fn selectable(i: usize) -> bool {
464 ROWS[i].is_selectable()
465 }
466
467 #[test]
468 fn draws_a_row_per_line() {
469 let area = Rect::new(0, 0, 20, 5);
470 let mut grid = Grid::new(20, 5);
471 let mut state = MenuState::new();
472 state.open(ROWS.len(), selectable);
473
474 StatefulWidget::render(
475 &Menu::new(&ROWS),
476 &mut Surface::new(&mut grid, area, 0),
477 &mut state,
478 );
479
480 assert_eq!(grid[Pos::new(0, 1)].glyph(), 'C'); assert_eq!(grid[Pos::new(0, 2)].glyph(), 'C'); assert_eq!(grid[Pos::new(0, 3)].glyph(), '['); }
484
485 #[test]
486 fn selected_row_is_highlighted() {
487 let area = Rect::new(0, 0, 20, 5);
488 let mut grid = Grid::new(20, 5);
489 let mut state = MenuState::new();
490 state.open(ROWS.len(), selectable); StatefulWidget::render(
493 &Menu::new(&ROWS),
494 &mut Surface::new(&mut grid, area, 0),
495 &mut state,
496 );
497
498 let selected_bg = grid[Pos::new(0, 2)].style().background();
499 let plain_bg = grid[Pos::new(0, 3)].style().background();
500 assert_ne!(selected_bg, plain_bg);
501 }
502
503 #[test]
504 fn overflow_draws_indicators_instead_of_silent_truncation() {
505 let area = Rect::new(0, 0, 20, 3); let mut grid = Grid::new(20, 3);
507 let mut state = MenuState::new();
508 state.open(ROWS.len(), selectable);
509 state.set_offset(1); StatefulWidget::render(
512 &Menu::new(&ROWS),
513 &mut Surface::new(&mut grid, area, 0),
514 &mut state,
515 );
516
517 assert_eq!(grid[Pos::new(0, 0)].glyph(), '^');
518 assert_eq!(grid[Pos::new(0, 2)].glyph(), 'v');
519 }
520
521 #[test]
522 fn index_at_skips_the_overflow_indicator_rows() {
523 let area = Rect::new(0, 0, 20, 3);
524 let mut state = MenuState::new();
525 state.open(ROWS.len(), selectable);
526 state.set_offset(1);
527
528 let menu = Menu::new(&ROWS);
529 assert_eq!(menu.index_at(area, &state, Pos::new(0, 0)), None); assert_eq!(menu.index_at(area, &state, Pos::new(0, 1)), Some(1)); assert_eq!(menu.index_at(area, &state, Pos::new(0, 2)), None); }
533
534 #[derive(Clone, Copy, PartialEq, Eq)]
535 enum Id {
536 Content,
537 Backdrop,
538 }
539
540 #[test]
541 fn show_returns_none_and_draws_nothing_while_closed() {
542 let screen = Rect::new(0, 0, 20, 10);
543 let popup = Rect::new(2, 2, 10, 5);
544 let mut grid = Grid::new(20, 10);
545 let mut state = MenuState::new();
546 let mut interaction = Interaction::<Id>::new();
547
548 let activated = interaction.frame(&mut Surface::new(&mut grid, screen, 0), |ui| {
549 Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
550 });
551
552 assert_eq!(activated, None);
553 assert_eq!(grid[Pos::new(2, 2)].glyph(), ' ');
554 }
555
556 #[test]
561 #[cfg(feature = "testing")]
562 fn clicking_a_selectable_row_activates_and_closes() {
563 use crate::testing::WidgetHarness;
564
565 let screen = Rect::new(0, 0, 20, 10);
566 let popup = Rect::new(0, 0, 20, 5); let mut state = MenuState::new();
568 state.open(ROWS.len(), selectable);
569 let mut harness = WidgetHarness::<Id>::new(20, 10);
570
571 harness.step(|ui| {
573 Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
574 });
575
576 harness.click(1, 2); let _ = harness.step(|ui| {
579 Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
580 });
581 let activated = harness.step(|ui| {
583 Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
584 });
585
586 assert_eq!(activated, Some(2));
587 assert!(!state.is_open());
588 }
589
590 #[test]
591 #[cfg(feature = "testing")]
592 fn clicking_outside_the_popup_closes_without_activating() {
593 use crate::testing::WidgetHarness;
594
595 let screen = Rect::new(0, 0, 20, 10);
596 let popup = Rect::new(0, 0, 10, 5);
597 let mut state = MenuState::new();
598 state.open(ROWS.len(), selectable);
599 let mut harness = WidgetHarness::<Id>::new(20, 10);
600
601 harness.step(|ui| {
602 Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
603 });
604
605 harness.click(15, 8);
607 let _ = harness.step(|ui| {
608 Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
609 });
610 let activated = harness.step(|ui| {
611 Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
612 });
613
614 assert_eq!(activated, None);
615 assert!(!state.is_open());
616 }
617
618 #[test]
619 #[cfg(feature = "testing")]
620 fn clicking_inside_the_popup_but_off_every_row_stays_open() {
621 use crate::testing::WidgetHarness;
625
626 let screen = Rect::new(0, 0, 20, 10);
627 let popup = Rect::new(0, 0, 20, 8); let mut state = MenuState::new();
629 state.open(ROWS.len(), selectable);
630 let mut harness = WidgetHarness::<Id>::new(20, 10);
631
632 harness.step(|ui| {
633 Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
634 });
635
636 harness.click(1, 7); let _ = harness.step(|ui| {
638 Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
639 });
640 let activated = harness.step(|ui| {
641 Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
642 });
643
644 assert_eq!(activated, None);
645 assert!(state.is_open()); }
647}