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