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