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