1use crate::event::{Event, MouseButton, MouseKind};
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::keymap::Key;
6use crate::style::CellStyle;
7use crate::text;
8use crate::theme::State;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11use super::cells;
12use super::popup_menu::type_ahead;
13use super::row::{self, LEAD};
14use super::rows::{self, RowScroll};
15
16type KeyMessage<Msg> = Box<dyn Fn(&str) -> Msg>;
18
19type GroupMessage<Msg> = Box<dyn Fn(&str, bool) -> Msg>;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct MenuItem {
25 key: String,
26 label: String,
27 icon: Option<(String, Option<String>)>,
28 badge: Option<String>,
29}
30
31impl MenuItem {
32 #[must_use]
34 pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
35 Self { key: key.into(), label: label.into(), icon: None, badge: None }
36 }
37
38 #[must_use]
40 pub fn icon(mut self, key: impl Into<String>, color: Option<&str>) -> Self {
41 self.icon = Some((key.into(), color.map(str::to_owned)));
42 self
43 }
44
45 #[must_use]
47 pub fn badge(mut self, text: impl Into<String>) -> Self {
48 self.badge = Some(text.into());
49 self
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct MenuGroup {
56 key: String,
57 title: Option<String>,
58 items: Vec<MenuItem>,
59}
60
61impl MenuGroup {
62 #[must_use]
64 pub fn new(key: impl Into<String>, items: impl IntoIterator<Item = MenuItem>) -> Self {
65 Self { key: key.into(), title: None, items: items.into_iter().collect() }
66 }
67
68 #[must_use]
70 pub fn title(mut self, title: impl Into<String>) -> Self {
71 self.title = Some(title.into());
72 self
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum Row {
79 Gap,
80 Heading(usize),
81 Item(usize, usize),
82}
83
84#[derive(Debug, Default)]
85struct MenuMemory {
86 cursor: Option<Row>,
87 followed: Option<Row>,
88 flashed: Option<Row>,
89 pointer: Option<(i32, i32)>,
91}
92
93pub struct Menu<Msg> {
112 groups: Vec<MenuGroup>,
113 selected: Option<String>,
114 collapsed: Vec<String>,
115 on_select: Option<KeyMessage<Msg>>,
116 on_toggle: Option<GroupMessage<Msg>>,
117 wrap: bool,
118}
119
120impl<Msg: 'static> Menu<Msg> {
121 #[must_use]
123 pub fn new(groups: impl IntoIterator<Item = MenuGroup>) -> Self {
124 Self {
125 groups: groups.into_iter().collect(),
126 selected: None,
127 collapsed: Vec::new(),
128 on_select: None,
129 on_toggle: None,
130 wrap: false,
131 }
132 }
133
134 #[must_use]
137 pub fn wrap(mut self, wrap: bool) -> Self {
138 self.wrap = wrap;
139 self
140 }
141
142 #[must_use]
144 pub fn selected(mut self, key: Option<&str>) -> Self {
145 self.selected = key.map(str::to_owned);
146 self
147 }
148
149 #[must_use]
151 pub fn on_select(mut self, message: impl Fn(&str) -> Msg + 'static) -> Self {
152 self.on_select = Some(Box::new(message));
153 self
154 }
155
156 #[must_use]
159 pub fn collapsible(mut self, message: impl Fn(&str, bool) -> Msg + 'static) -> Self {
160 self.on_toggle = Some(Box::new(message));
161 self
162 }
163
164 #[must_use]
166 pub fn collapsed(mut self, groups: impl IntoIterator<Item = impl Into<String>>) -> Self {
167 self.collapsed = groups.into_iter().map(Into::into).collect();
168 self
169 }
170
171 fn is_closed(&self, group: usize) -> bool {
172 self.on_toggle.is_some() && self.collapsed.contains(&self.groups[group].key)
173 }
174
175 fn rows(&self) -> Vec<Row> {
176 let mut rows = Vec::new();
177 for (index, group) in self.groups.iter().enumerate() {
178 if index > 0 {
179 rows.push(Row::Gap);
180 }
181 if group.title.is_some() {
182 rows.push(Row::Heading(index));
183 }
184 if !self.is_closed(index) {
185 rows.extend((0..group.items.len()).map(|item| Row::Item(index, item)));
186 }
187 }
188 rows
189 }
190
191 fn navigable(&self, row: Row) -> bool {
193 match row {
194 Row::Gap => false,
195 Row::Heading(_) => self.on_toggle.is_some(),
196 Row::Item(..) => true,
197 }
198 }
199
200 fn selected_row(&self) -> Option<Row> {
201 let key = self.selected.as_deref()?;
202 self.groups
203 .iter()
204 .enumerate()
205 .find_map(|(g, group)| group.items.iter().position(|item| item.key == key).map(|i| Row::Item(g, i)))
206 }
207
208 fn cursor(&self, remembered: Option<Row>, rows: &[Row]) -> Option<usize> {
211 let find = |row: Option<Row>| row.and_then(|row| rows.iter().position(|r| *r == row));
212 find(remembered)
213 .or_else(|| find(self.selected_row()))
214 .or_else(|| rows.iter().position(|row| self.navigable(*row)))
215 }
216
217 fn item(&self, group: usize, item: usize) -> &MenuItem {
218 &self.groups[group].items[item]
219 }
220
221 fn open(&self, cx: &mut EventCx<'_, Msg>, row: Row) {
222 match row {
223 Row::Item(group, item) => {
224 let key = &self.item(group, item).key;
225 if let Some(message) = &self.on_select {
226 cx.memory::<MenuMemory>().flashed = Some(row);
227 cx.flash();
228 if self.selected.as_deref() != Some(key) {
229 cx.emit(message(key));
230 }
231 }
232 }
233 Row::Heading(group) => self.toggle(cx, group, self.is_closed(group)),
234 Row::Gap => {}
235 }
236 }
237
238 fn toggle(&self, cx: &mut EventCx<'_, Msg>, group: usize, open: bool) {
239 if let Some(message) = &self.on_toggle
240 && open == self.is_closed(group)
241 {
242 cx.emit(message(&self.groups[group].key, open));
243 }
244 }
245
246 fn neighbour(&self, rows: &[Row], from: usize, forward: bool) -> usize {
248 let next = self.step(rows, from, forward);
249 if !self.wrap || next != from {
250 return next;
251 }
252 let edge = if forward {
253 rows.iter().position(|r| self.navigable(*r))
254 } else {
255 rows.iter().rposition(|r| self.navigable(*r))
256 };
257 edge.unwrap_or(from)
258 }
259
260 fn step(&self, rows: &[Row], from: usize, forward: bool) -> usize {
261 let mut index = from;
262 loop {
263 let next = if forward { index + 1 } else { index.wrapping_sub(1) };
264 match rows.get(next) {
265 Some(row) if self.navigable(*row) => return next,
266 Some(_) => index = next,
267 None => return from,
268 }
269 }
270 }
271}
272
273impl<Msg: 'static> Widget<Msg> for Menu<Msg> {
274 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
275 let rows = self.rows();
276 let widest = rows
277 .iter()
278 .map(|row| match *row {
279 Row::Gap => 0,
280 Row::Heading(group) => {
281 cells::sum([text::width(self.groups[group].title.as_deref().unwrap_or_default()), LEAD, 3])
282 }
283 Row::Item(group, item) => {
284 let item = self.item(group, item);
285 cells::sum([
286 LEAD,
287 text::width(&item.label),
288 item.icon.as_ref().map_or(0, |_| 2),
289 item.badge.as_deref().map_or(0, |badge| text::width(badge).saturating_add(2)),
290 3,
291 ])
292 }
293 })
294 .max()
295 .unwrap_or(0);
296 Size::new(widest, clamp_u16(i32::try_from(rows.len()).unwrap_or(i32::MAX))).min(available)
297 }
298
299 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
300 cx.register_hit(area);
301 let rows = self.rows();
302 if rows.is_empty() || area.is_empty() {
303 return;
304 }
305 let focused = cx.is_focused();
306 let pressed = cx.is_pressed();
307 let pointer = cx.pointer();
308 let slide = cx.env().slide();
309 let visible = usize::from(area.height);
310 let mut offset = cx.memory::<RowScroll>().offset;
312 let (cursor, flashed) = {
313 let memory = cx.memory::<MenuMemory>();
314 if pointer != memory.pointer {
317 memory.pointer = pointer;
318 let bar = rows.len() > visible && pointer.is_some_and(|(x, _)| x == area.right() - 1);
319 let under = pointer
320 .filter(|_| !bar)
321 .and_then(|(_, y)| usize::try_from(y - area.y).ok())
322 .and_then(|line| rows.get(offset + line))
323 .filter(|row| self.navigable(**row));
324 if let Some(row) = under {
325 memory.cursor = Some(*row);
326 }
327 }
328 let cursor = self.cursor(memory.cursor, &rows);
329 let follow = if focused { cursor.map(|c| rows[c]) } else { self.selected_row() };
330 if follow != memory.followed {
331 if let Some(index) = follow.and_then(|row| rows.iter().position(|r| *r == row)) {
332 if index < offset {
333 offset = index;
334 } else if index >= offset + visible {
335 offset = index + 1 - visible;
336 }
337 }
338 memory.followed = follow;
339 }
340 (cursor, memory.flashed)
341 };
342 offset = offset.min(rows.len().saturating_sub(visible));
343 cx.memory::<RowScroll>().offset = offset;
344 let width = if rows.len() > visible { area.width.saturating_sub(1) } else { area.width };
345 let selected = self.selected_row();
346
347 for (line, index) in (offset..rows.len()).take(visible).enumerate() {
348 let rect = Rect::new(area.x, area.y + i32::try_from(line).unwrap_or(0), width, 1);
349 let row = rows[index];
350 let mut states = Vec::new();
351 let pointed = pointer.is_some_and(|(x, y)| rect.contains(x, y));
352 let lit = if focused { cursor == Some(index) } else { pointed };
353 if self.navigable(row) && lit {
354 states.push(State::Hover);
355 }
356 match row {
357 Row::Gap => {}
358 Row::Heading(group) => {
359 let style = cx.style("menu-heading", None, &states);
360 let title = self.groups[group].title.as_deref().unwrap_or_default();
361 let chevron = self.on_toggle.is_some().then(|| {
364 let key = if self.is_closed(group) { "chevron-right" } else { "chevron-down" };
365 text::truncate(&cx.env().icons().glyph(key), 1).into_owned()
366 });
367 let trailing = if chevron.is_some() { 2 } else { 0 };
368 let raised = states.contains(&State::Hover);
369 row::paint(cx, rect, &style, slide && raised, &[], title, trailing);
370 if let Some(glyph) = chevron {
371 let plain = CellStyle { bg: None, ..style.text() };
372 cx.text(rect.right() - 2, rect.y, &glyph, plain, 1);
373 }
374 }
375 Row::Item(group, item_index) => {
376 let item = self.item(group, item_index);
377 if selected == Some(row) {
378 states.push(State::Selected);
379 if focused {
380 states.push(State::Focus);
381 }
382 }
383 if pressed && flashed == Some(row) {
384 states.push(State::Pressed);
385 }
386 let style = cx.style("menu-item", None, &states);
387 let text_style = style.text();
388 let raised = states.contains(&State::Hover) || states.contains(&State::Selected);
389 let badge_width = item.badge.as_deref().map_or(0, |badge| text::width(badge).saturating_add(2));
390 let marks: Vec<row::Mark> = item
391 .icon
392 .iter()
393 .map(|(key, color)| row::icon(cx, key, color.as_deref(), text_style.fg))
394 .collect();
395 row::paint(cx, rect, &style, slide && raised, &marks, &item.label, badge_width);
396 if let Some(badge) = &item.badge {
397 let badge_style = cx.style("menu-badge", None, &states).text();
398 row::paint_trailing(cx, rect, badge, CellStyle { bg: None, ..badge_style });
399 }
400 }
401 }
402 }
403 rows::paint_scrollbar(cx, area, rows.len(), offset, None);
404 }
405
406 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
407 let rows = self.rows();
408 if rows.is_empty() {
409 return false;
410 }
411 let area = cx.area();
412 match event {
413 Event::Key(key) => {
414 let remembered = cx.memory::<MenuMemory>().cursor;
415 let Some(cursor) = self.cursor(remembered, &rows) else {
416 return false;
417 };
418 let row = rows[cursor];
419 let target = if key.is_plain(Key::Up) {
420 self.neighbour(&rows, cursor, false)
421 } else if key.is_plain(Key::Down) {
422 self.neighbour(&rows, cursor, true)
423 } else if key.is_plain(Key::Home) {
424 rows.iter().position(|r| self.navigable(*r)).unwrap_or(cursor)
425 } else if key.is_plain(Key::End) {
426 rows.iter().rposition(|r| self.navigable(*r)).unwrap_or(cursor)
427 } else if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
428 self.open(cx, row);
429 return true;
430 } else if key.is_plain(Key::Right) {
431 let Row::Heading(group) = row else { return false };
432 self.toggle(cx, group, true);
433 return self.on_toggle.is_some();
434 } else if key.is_plain(Key::Left) {
435 match row {
436 Row::Heading(group) => self.toggle(cx, group, false),
437 Row::Item(group, _) if self.on_toggle.is_some() && self.groups[group].title.is_some() => {
438 cx.memory::<MenuMemory>().cursor = Some(Row::Heading(group));
439 }
440 _ => return false,
441 }
442 return self.on_toggle.is_some();
443 } else if let (Some(typed), false) = (key.text, key.chord.mods.ctrl || key.chord.mods.alt) {
444 let items: Vec<usize> = (0..rows.len()).filter(|i| matches!(rows[*i], Row::Item(..))).collect();
445 let labels: Vec<String> = items
446 .iter()
447 .map(|i| match rows[*i] {
448 Row::Item(g, it) => self.item(g, it).label.clone(),
449 _ => String::new(),
450 })
451 .collect();
452 let from = items.iter().rposition(|i| *i <= cursor).unwrap_or(items.len().saturating_sub(1));
453 match type_ahead(&labels, from, typed) {
454 Some(found) => items[found],
455 None => return !labels.is_empty(),
456 }
457 } else {
458 return false;
459 };
460 cx.memory::<MenuMemory>().cursor = Some(rows[target]);
461 true
462 }
463 Event::Mouse(mouse) => {
464 if rows::scroll_mouse(cx, mouse, area, rows.len()) {
466 return true;
467 }
468 let offset = cx.memory::<RowScroll>().offset;
469 match mouse.kind {
470 MouseKind::Down(MouseButton::Left) => {
471 let index = usize::try_from(mouse.y - area.y).ok().map(|line| offset + line);
472 let Some(row) = index.and_then(|i| rows.get(i).copied()).filter(|r| self.navigable(*r)) else {
473 return false;
474 };
475 cx.memory::<MenuMemory>().cursor = Some(row);
476 self.open(cx, row);
477 true
478 }
479 _ => false,
480 }
481 }
482 _ => false,
483 }
484 }
485
486 fn focusable(&self) -> bool {
487 self.groups.iter().any(|group| !group.items.is_empty())
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::runtime::{App, Command, Harness};
495 use crate::widget::View;
496
497 struct Sidebar {
498 page: String,
499 closed: Vec<String>,
500 collapsible: bool,
501 wrap: bool,
502 }
503
504 #[derive(Debug, Clone)]
505 enum Msg {
506 Go(String),
507 Group(String, bool),
508 }
509
510 impl App for Sidebar {
511 type Msg = Msg;
512 fn update(&mut self, msg: Msg) -> Command<Msg> {
513 match msg {
514 Msg::Go(page) => self.page = page,
515 Msg::Group(group, open) => {
516 self.closed.retain(|g| *g != group);
517 if !open {
518 self.closed.push(group);
519 }
520 }
521 }
522 Command::none()
523 }
524 fn view(&self, ui: &mut View<'_, Msg>) {
525 let groups = [
526 MenuGroup::new(
527 "work",
528 [MenuItem::new("overview", "Overview"), MenuItem::new("deploys", "Deploys").badge("3")],
529 )
530 .title("WORKSPACE"),
531 MenuGroup::new(
532 "infra",
533 [
534 MenuItem::new("containers", "Containers").icon("dot", Some("success")),
535 MenuItem::new("volumes", "Volumes"),
536 ],
537 )
538 .title("INFRASTRUCTURE"),
539 ];
540 let mut menu =
541 Menu::new(groups).selected(Some(&self.page)).wrap(self.wrap).on_select(|key| Msg::Go(key.to_owned()));
542 if self.collapsible {
543 menu =
544 menu.collapsible(|group, open| Msg::Group(group.to_owned(), open)).collapsed(self.closed.clone());
545 }
546 ui.add(menu).fill().id("menu");
547 }
548 }
549
550 fn sidebar(collapsible: bool) -> Sidebar {
551 Sidebar { page: "overview".into(), closed: Vec::new(), collapsible, wrap: false }
552 }
553
554 #[test]
555 fn a_wrapping_menu_goes_round_its_ends_and_stops_otherwise() {
556 let mut h = Harness::new(Sidebar { wrap: true, ..sidebar(false) }, 30, 8);
557 h.press("tab").press("end").press("down").press("enter");
558 assert_eq!(h.app().page, "overview", "down on the last item reaches the first");
559 h.press("home").press("up").press("enter");
560 assert_eq!(h.app().page, "volumes", "up on the first item reaches the last");
561 let mut h = Harness::new(sidebar(false), 30, 8);
562 h.press("tab").press("end").press("down").press("enter");
563 assert_eq!(h.app().page, "volumes");
564 }
565
566 #[test]
567 fn groups_headings_badges_and_selection() {
568 let h = Harness::new(sidebar(false), 24, 7);
569 assert_eq!(
570 h.screen(),
571 " WORKSPACE\n▌ Overview\n Deploys 3\n\n INFRASTRUCTURE\n ● Containers\n Volumes\n"
572 );
573 assert_eq!(h.bg(5, 1), h.env().theme().color("active"));
574 }
575
576 #[test]
577 fn keyboard_moves_a_cursor_and_enter_opens() {
578 let mut h = Harness::new(sidebar(false), 24, 7);
579 h.press("tab").press("down");
580 assert_eq!(h.app().page, "overview", "moving does not navigate");
581 assert!(h.screen().contains("▌ Deploys 3"), "{}", h.screen());
582 h.press("down").press("enter");
583 assert_eq!(h.app().page, "containers");
584 h.press("v");
585 h.press("space");
586 assert_eq!(h.app().page, "volumes");
587 h.press("home").press("enter");
588 assert_eq!(h.app().page, "overview");
589 h.click_text("Deploys");
590 assert_eq!(h.app().page, "deploys");
591 }
592
593 #[test]
594 fn the_scrollbar_is_dragged_and_never_opens_the_row_beside_it() {
595 let mut h = Harness::new(sidebar(false), 24, 4);
596 assert!(h.screen().starts_with(" WORKSPACE"), "{}", h.screen());
597 h.mouse(MouseKind::Down(MouseButton::Left), 23, 2);
598 h.mouse(MouseKind::Drag(MouseButton::Left), 23, 3);
599 h.mouse(MouseKind::Up(MouseButton::Left), 23, 3);
600 assert_eq!(h.app().page, "overview", "a press on the scrollbar opens nothing");
601 let screen = h.screen();
602 assert!(screen.contains("Volumes") && !screen.contains("WORKSPACE"), "dragged to the end:\n{screen}");
603 h.mouse(MouseKind::ScrollUp, 5, 1);
604 assert!(h.screen().starts_with(" WORKSPACE"), "{}", h.screen());
605 }
606
607 #[test]
608 fn the_pointer_carries_the_one_highlight() {
609 let mut h = Harness::new(sidebar(false), 24, 7);
610 h.press("tab").press("down");
611 assert!(h.screen().contains("▌ Deploys"), "{}", h.screen());
612 let (x, y) = h.find("Volumes").expect("volumes row");
613 h.hover(x + 2, y);
614 let screen = h.screen();
615 assert_eq!(
616 screen,
617 " WORKSPACE\n▌ Overview\n Deploys 3\n\n INFRASTRUCTURE\n ● Containers\n▌ Volumes\n",
618 "the keyboard's row gives way to the pointer's"
619 );
620 h.press("up");
621 let screen = h.screen();
622 assert!(screen.contains("▌ ● Containers") && screen.contains("\n Volumes"), "{screen}");
623 h.press("enter");
624 assert_eq!(h.app().page, "containers", "the keyboard continued from the pointer's row");
625 }
626
627 #[test]
628 fn a_foldable_heading_rises_and_slides_while_its_chevron_stays() {
629 let mut h = Harness::new(sidebar(true), 24, 7);
630 h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
631 h.hover(6, 0);
632 assert_eq!(h.screen().lines().next(), Some("▌ WORKSPACE ▾"), "{}", h.screen());
633 assert_eq!(h.bg(10, 0), h.env().theme().color("raised"));
634 let mut env = crate::env::Env::builtin();
635 env.set_slide(false);
636 let mut h = Harness::with_env(sidebar(true), env, 24, 7);
637 h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
638 h.hover(6, 0);
639 assert_eq!(h.screen().lines().next(), Some("▌ WORKSPACE ▾"), "{}", h.screen());
640 let mut plain = Harness::new(sidebar(false), 24, 7);
641 plain.hover(6, 0);
642 assert_eq!(plain.screen().lines().next(), Some(" WORKSPACE"), "a heading that cannot fold never rises");
643 }
644
645 #[test]
646 fn collapsible_groups_fold_by_click_and_arrows() {
647 let mut h = Harness::new(sidebar(true), 24, 7);
648 assert!(h.screen().starts_with(" WORKSPACE ▾\n"), "{}", h.screen());
649 h.click_text("INFRASTRUCTURE");
650 assert_eq!(h.app().closed, vec!["infra".to_owned()]);
651 assert!(!h.screen().contains("Volumes") && h.screen().contains('▶'), "{}", h.screen());
652 h.press("home").press("down").press("left");
653 assert_eq!(h.app().closed, vec!["infra".to_owned()], "left on an item goes to its heading");
654 h.press("left");
655 assert!(h.app().closed.contains(&"work".to_owned()), "{:?}", h.app().closed);
656 h.press("right");
657 assert!(!h.app().closed.contains(&"work".to_owned()));
658 let plain = Harness::new(sidebar(false), 24, 7);
659 assert!(!plain.screen().contains('▾'), "plain menus have no chevrons");
660 }
661}