1use crate::_private::NonExhaustive;
5use crate::event::MenuOutcome;
6use crate::util::{fallback_select_style, revert_style};
7use crate::{MenuBuilder, MenuItem, MenuStyle};
8use rat_event::util::MouseFlags;
9use rat_event::{ct_event, HandleEvent, MouseOnly, Regular};
10use rat_focus::{FocusBuilder, FocusFlag, HasFocus};
11use ratatui::buffer::Buffer;
12use ratatui::layout::Rect;
13use ratatui::style::{Style, Stylize};
14use ratatui::text::{Line, Span};
15#[cfg(feature = "unstable-widget-ref")]
16use ratatui::widgets::StatefulWidgetRef;
17use ratatui::widgets::{StatefulWidget, Widget};
18use std::fmt::Debug;
19
20#[derive(Debug, Default, Clone)]
22pub struct MenuLine<'a> {
23 title: Line<'a>,
24 pub(crate) menu: MenuBuilder<'a>,
25
26 style: Style,
27 highlight_style: Option<Style>,
28 disabled_style: Option<Style>,
29 right_style: Option<Style>,
30 title_style: Option<Style>,
31 select_style: Option<Style>,
33 focus_style: Option<Style>,
34}
35
36#[derive(Debug)]
38pub struct MenuLineState {
39 pub area: Rect,
42 pub item_areas: Vec<Rect>,
45 pub navchar: Vec<Option<char>>,
48 pub disabled: Vec<bool>,
51
52 pub selected: Option<usize>,
56
57 pub focus: FocusFlag,
60
61 pub mouse: MouseFlags,
64
65 pub non_exhaustive: NonExhaustive,
66}
67
68impl<'a> MenuLine<'a> {
69 pub fn new() -> Self {
71 Default::default()
72 }
73
74 #[inline]
76 pub fn title(mut self, title: impl Into<Line<'a>>) -> Self {
77 self.title = title.into();
78 self
79 }
80
81 pub fn item(mut self, item: MenuItem<'a>) -> Self {
83 self.menu.item(item);
84 self
85 }
86
87 pub fn item_parsed(mut self, text: &'a str) -> Self {
93 self.menu.item_parsed(text);
94 self
95 }
96
97 pub fn item_str(mut self, txt: &'a str) -> Self {
99 self.menu.item_str(txt);
100 self
101 }
102
103 pub fn item_string(mut self, txt: String) -> Self {
105 self.menu.item_string(txt);
106 self
107 }
108
109 #[inline]
111 pub fn styles(mut self, styles: MenuStyle) -> Self {
112 self.style = styles.style;
113 if styles.highlight.is_some() {
114 self.highlight_style = styles.highlight;
115 }
116 if styles.disabled.is_some() {
117 self.disabled_style = styles.disabled;
118 }
119 if styles.right.is_some() {
120 self.right_style = styles.right;
121 }
122 if styles.focus.is_some() {
123 self.focus_style = styles.focus;
124 }
125 if styles.title.is_some() {
126 self.title_style = styles.title;
127 }
128 if styles.select.is_some() {
129 self.select_style = styles.select;
130 }
131 if styles.focus.is_some() {
132 self.focus_style = styles.focus;
133 }
134 self
135 }
136
137 #[inline]
139 pub fn style(mut self, style: Style) -> Self {
140 self.style = style;
141 self
142 }
143
144 #[inline]
146 pub fn highlight_style(mut self, style: Style) -> Self {
147 self.highlight_style = Some(style);
148 self
149 }
150
151 #[inline]
153 pub fn highlight_style_opt(mut self, style: Option<Style>) -> Self {
154 self.highlight_style = style;
155 self
156 }
157
158 #[inline]
160 pub fn disabled_style(mut self, style: Style) -> Self {
161 self.disabled_style = Some(style);
162 self
163 }
164
165 #[inline]
167 pub fn disabled_style_opt(mut self, style: Option<Style>) -> Self {
168 self.disabled_style = style;
169 self
170 }
171
172 #[inline]
174 pub fn right_style(mut self, style: Style) -> Self {
175 self.right_style = Some(style);
176 self
177 }
178
179 #[inline]
181 pub fn right_style_opt(mut self, style: Option<Style>) -> Self {
182 self.right_style = style;
183 self
184 }
185
186 #[inline]
188 pub fn title_style(mut self, style: Style) -> Self {
189 self.title_style = Some(style);
190 self
191 }
192
193 #[inline]
195 pub fn title_style_opt(mut self, style: Option<Style>) -> Self {
196 self.title_style = style;
197 self
198 }
199
200 #[inline]
202 pub fn select_style(mut self, style: Style) -> Self {
203 self.select_style = Some(style);
204 self
205 }
206
207 #[inline]
209 pub fn select_style_opt(mut self, style: Option<Style>) -> Self {
210 self.select_style = style;
211 self
212 }
213
214 #[inline]
216 pub fn focus_style(mut self, style: Style) -> Self {
217 self.focus_style = Some(style);
218 self
219 }
220
221 #[inline]
223 pub fn focus_style_opt(mut self, style: Option<Style>) -> Self {
224 self.focus_style = style;
225 self
226 }
227}
228
229#[cfg(feature = "unstable-widget-ref")]
230impl<'a> StatefulWidgetRef for MenuLine<'a> {
231 type State = MenuLineState;
232
233 fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
234 render_ref(self, area, buf, state);
235 }
236}
237
238impl StatefulWidget for MenuLine<'_> {
239 type State = MenuLineState;
240
241 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
242 render_ref(&self, area, buf, state);
243 }
244}
245
246fn render_ref(widget: &MenuLine<'_>, area: Rect, buf: &mut Buffer, state: &mut MenuLineState) {
247 state.area = area;
248 state.item_areas.clear();
249
250 if widget.menu.items.is_empty() {
251 state.selected = None;
252 } else if state.selected.is_none() {
253 state.selected = Some(0);
254 }
255
256 state.navchar = widget
257 .menu
258 .items
259 .iter()
260 .map(|v| v.navchar.map(|w| w.to_ascii_lowercase()))
261 .collect();
262 state.disabled = widget.menu.items.iter().map(|v| v.disabled).collect();
263
264 let style = widget.style;
265 #[allow(clippy::collapsible_else_if)]
266 let select_style = if state.is_focused() {
267 if let Some(focus_style) = widget.focus_style {
268 focus_style
269 } else {
270 revert_style(style)
271 }
272 } else {
273 if let Some(select_style) = widget.select_style {
274 select_style
275 } else {
276 fallback_select_style(style)
277 }
278 };
279 let title_style = if let Some(title_style) = widget.title_style {
280 title_style
281 } else {
282 style.clone().underlined()
283 };
284 let highlight_style = if let Some(highlight_style) = widget.highlight_style {
285 highlight_style
286 } else {
287 Style::new().underlined()
288 };
289 let right_style = if let Some(right_style) = widget.right_style {
290 right_style
291 } else {
292 Style::new().italic()
293 };
294 let disabled_style = if let Some(disabled_style) = widget.disabled_style {
295 disabled_style
296 } else {
297 widget.style
298 };
299
300 buf.set_style(area, style);
301
302 let mut item_area = Rect::new(area.x, area.y, 0, 1);
303
304 if widget.title.width() > 0 {
305 item_area.width = widget.title.width() as u16;
306
307 buf.set_style(item_area, title_style);
308 widget.title.clone().render(item_area, buf);
309
310 item_area.x += item_area.width + 1;
311 }
312
313 for (n, item) in widget.menu.items.iter().enumerate() {
314 item_area.width =
315 item.item_width() + item.right_width() + if item.right.is_empty() { 0 } else { 2 };
316 if item_area.right() >= area.right() {
317 item_area = item_area.clamp(area);
318 }
319 state.item_areas.push(item_area);
320
321 #[allow(clippy::collapsible_else_if)]
322 let (style, right_style) = if state.selected == Some(n) {
323 if item.disabled {
324 (
325 style.patch(disabled_style),
326 style.patch(disabled_style).patch(right_style),
327 )
328 } else {
329 (
330 style.patch(select_style),
331 style.patch(select_style).patch(right_style),
332 )
333 }
334 } else {
335 if item.disabled {
336 (
337 style.patch(disabled_style),
338 style.patch(disabled_style).patch(right_style),
339 )
340 } else {
341 (style, style.patch(right_style))
342 }
343 };
344
345 let item_line = if let Some(highlight) = item.highlight.clone() {
346 Line::from_iter([
347 Span::from(&item.item[..highlight.start - 1]), Span::from(&item.item[highlight.start..highlight.end]).style(highlight_style),
349 Span::from(&item.item[highlight.end..]),
350 if !item.right.is_empty() {
351 Span::from(format!("({})", item.right)).style(right_style)
352 } else {
353 Span::default()
354 },
355 ])
356 } else {
357 Line::from_iter([
358 Span::from(item.item.as_ref()),
359 if !item.right.is_empty() {
360 Span::from(format!("({})", item.right)).style(right_style)
361 } else {
362 Span::default()
363 },
364 ])
365 };
366 item_line.style(style).render(item_area, buf);
367
368 item_area.x += item_area.width + 1;
369 }
370}
371
372impl HasFocus for MenuLineState {
373 fn build(&self, builder: &mut FocusBuilder) {
374 builder.leaf_widget(self);
375 }
376
377 fn focus(&self) -> FocusFlag {
379 self.focus.clone()
380 }
381
382 fn area(&self) -> Rect {
384 self.area
385 }
386}
387
388#[allow(clippy::len_without_is_empty)]
389impl MenuLineState {
390 pub fn new() -> Self {
391 Self::default()
392 }
393
394 pub fn named(name: &str) -> Self {
396 Self {
397 focus: FocusFlag::named(name),
398 ..Default::default()
399 }
400 }
401
402 #[inline]
404 pub fn len(&self) -> usize {
405 self.item_areas.len()
406 }
407
408 pub fn is_empty(&self) -> bool {
410 self.item_areas.is_empty()
411 }
412
413 #[inline]
415 pub fn select(&mut self, select: Option<usize>) -> bool {
416 let old = self.selected;
417 self.selected = select;
418 old != self.selected
419 }
420
421 #[inline]
423 pub fn selected(&self) -> Option<usize> {
424 self.selected
425 }
426
427 #[inline]
429 pub fn prev_item(&mut self) -> bool {
430 let old = self.selected;
431
432 if self.disabled.is_empty() {
434 return false;
435 }
436
437 self.selected = if let Some(start) = old {
438 let mut idx = start;
439 loop {
440 if idx == 0 {
441 idx = start;
442 break;
443 }
444 idx -= 1;
445
446 if self.disabled.get(idx) == Some(&false) {
447 break;
448 }
449 }
450
451 Some(idx)
452 } else if self.len() > 0 {
453 Some(self.len().saturating_sub(1))
454 } else {
455 None
456 };
457
458 old != self.selected
459 }
460
461 #[inline]
463 pub fn next_item(&mut self) -> bool {
464 let old = self.selected;
465
466 if self.disabled.is_empty() {
468 return false;
469 }
470
471 self.selected = if let Some(start) = old {
472 let mut idx = start;
473 loop {
474 if idx + 1 == self.len() {
475 idx = start;
476 break;
477 }
478 idx += 1;
479
480 if self.disabled.get(idx) == Some(&false) {
481 break;
482 }
483 }
484 Some(idx)
485 } else if self.len() > 0 {
486 Some(0)
487 } else {
488 None
489 };
490
491 old != self.selected
492 }
493
494 #[inline]
496 pub fn navigate(&mut self, c: char) -> MenuOutcome {
497 if self.disabled.is_empty() {
499 return MenuOutcome::Continue;
500 }
501
502 let c = c.to_ascii_lowercase();
503 for (i, cc) in self.navchar.iter().enumerate() {
504 #[allow(clippy::collapsible_if)]
505 if *cc == Some(c) {
506 if self.disabled.get(i) == Some(&false) {
507 if self.selected == Some(i) {
508 return MenuOutcome::Activated(i);
509 } else {
510 self.selected = Some(i);
511 return MenuOutcome::Selected(i);
512 }
513 }
514 }
515 }
516
517 MenuOutcome::Continue
518 }
519
520 #[inline]
524 pub fn select_at(&mut self, pos: (u16, u16)) -> bool {
525 let old_selected = self.selected;
526
527 if self.disabled.is_empty() {
529 return false;
530 }
531
532 if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
533 if self.disabled.get(idx) == Some(&false) {
534 self.selected = Some(idx);
535 }
536 }
537
538 self.selected != old_selected
539 }
540
541 #[inline]
545 pub fn select_at_always(&mut self, pos: (u16, u16)) -> bool {
546 if self.disabled.is_empty() {
548 return false;
549 }
550
551 if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
552 if self.disabled.get(idx) == Some(&false) {
553 self.selected = Some(idx);
554 return true;
555 }
556 }
557
558 false
559 }
560
561 #[inline]
563 pub fn item_at(&self, pos: (u16, u16)) -> Option<usize> {
564 self.mouse.item_at(&self.item_areas, pos.0, pos.1)
565 }
566}
567
568impl Clone for MenuLineState {
569 fn clone(&self) -> Self {
570 Self {
571 area: self.area,
572 item_areas: self.item_areas.clone(),
573 navchar: self.navchar.clone(),
574 disabled: self.disabled.clone(),
575 selected: self.selected,
576 focus: FocusFlag::named(self.focus.name()),
577 mouse: Default::default(),
578 non_exhaustive: NonExhaustive,
579 }
580 }
581}
582
583impl Default for MenuLineState {
584 fn default() -> Self {
585 Self {
586 area: Default::default(),
587 item_areas: vec![],
588 navchar: vec![],
589 disabled: vec![],
590 selected: None,
591 focus: Default::default(),
592 mouse: Default::default(),
593 non_exhaustive: NonExhaustive,
594 }
595 }
596}
597
598impl HandleEvent<crossterm::event::Event, Regular, MenuOutcome> for MenuLineState {
599 #[allow(clippy::redundant_closure)]
600 fn handle(&mut self, event: &crossterm::event::Event, _: Regular) -> MenuOutcome {
601 let res = if self.is_focused() {
602 match event {
603 ct_event!(key press ' ') => {
604 self
605 .selected.map_or(MenuOutcome::Continue, |v| MenuOutcome::Selected(v))
607 }
608 ct_event!(key press ANY-c) => {
609 self.navigate(*c) }
611 ct_event!(keycode press Left) => {
612 if self.prev_item() {
613 if let Some(selected) = self.selected {
614 MenuOutcome::Selected(selected)
615 } else {
616 MenuOutcome::Changed
617 }
618 } else {
619 MenuOutcome::Continue
620 }
621 }
622 ct_event!(keycode press Right) => {
623 if self.next_item() {
624 if let Some(selected) = self.selected {
625 MenuOutcome::Selected(selected)
626 } else {
627 MenuOutcome::Changed
628 }
629 } else {
630 MenuOutcome::Continue
631 }
632 }
633 ct_event!(keycode press Home) => {
634 if self.select(Some(0)) {
635 if let Some(selected) = self.selected {
636 MenuOutcome::Selected(selected)
637 } else {
638 MenuOutcome::Changed
639 }
640 } else {
641 MenuOutcome::Continue
642 }
643 }
644 ct_event!(keycode press End) => {
645 if self.select(Some(self.len().saturating_sub(1))) {
646 if let Some(selected) = self.selected {
647 MenuOutcome::Selected(selected)
648 } else {
649 MenuOutcome::Changed
650 }
651 } else {
652 MenuOutcome::Continue
653 }
654 }
655 ct_event!(keycode press Enter) => {
656 if let Some(select) = self.selected {
657 MenuOutcome::Activated(select)
658 } else {
659 MenuOutcome::Continue
660 }
661 }
662 _ => MenuOutcome::Continue,
663 }
664 } else {
665 MenuOutcome::Continue
666 };
667
668 if res == MenuOutcome::Continue {
669 self.handle(event, MouseOnly)
670 } else {
671 res
672 }
673 }
674}
675
676impl HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome> for MenuLineState {
677 fn handle(&mut self, event: &crossterm::event::Event, _: MouseOnly) -> MenuOutcome {
678 match event {
679 ct_event!(mouse any for m) if self.mouse.doubleclick(self.area, m) => {
680 let idx = self.item_at(self.mouse.pos_of(m));
681 if self.selected() == idx {
682 match self.selected {
683 Some(a) => MenuOutcome::Activated(a),
684 None => MenuOutcome::Continue,
685 }
686 } else {
687 MenuOutcome::Continue
688 }
689 }
690 ct_event!(mouse any for m) if self.mouse.drag(self.area, m) => {
691 let old = self.selected;
692 if self.select_at(self.mouse.pos_of(m)) {
693 if old != self.selected {
694 MenuOutcome::Selected(self.selected().expect("selected"))
695 } else {
696 MenuOutcome::Unchanged
697 }
698 } else {
699 MenuOutcome::Continue
700 }
701 }
702 ct_event!(mouse down Left for col, row) if self.area.contains((*col, *row).into()) => {
703 if self.select_at_always((*col, *row)) {
704 MenuOutcome::Selected(self.selected().expect("selected"))
705 } else {
706 MenuOutcome::Continue
707 }
708 }
709 _ => MenuOutcome::Continue,
710 }
711 }
712}
713
714pub fn handle_events(
718 state: &mut MenuLineState,
719 focus: bool,
720 event: &crossterm::event::Event,
721) -> MenuOutcome {
722 state.focus.set(focus);
723 state.handle(event, Regular)
724}
725
726pub fn handle_mouse_events(
728 state: &mut MenuLineState,
729 event: &crossterm::event::Event,
730) -> MenuOutcome {
731 state.handle(event, MouseOnly)
732}