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