1use crate::_private::NonExhaustive;
29use crate::event::MenuOutcome;
30use crate::util::{get_block_size, 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 pub fn width(&self) -> u16 {
261 let block = get_block_size(&self.block);
262
263 let mut width = block.width;
264 if self.title.width() > 0 {
265 width += self.title.width() as u16 + 1;
266 }
267 for item in self.menu.items.iter() {
268 width += item.item_width() + item.right_width()
270 + if item.right.is_empty() { 0 } else { 2 }
271 + 1;
272 }
273 width
274 }
275}
276
277impl<'a> StatefulWidget for &MenuLine<'a> {
278 type State = MenuLineState;
279
280 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
281 render_ref(self, area, buf, state);
282 }
283}
284
285impl StatefulWidget for MenuLine<'_> {
286 type State = MenuLineState;
287
288 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
289 render_ref(&self, area, buf, state);
290 }
291}
292
293fn render_ref(widget: &MenuLine<'_>, area: Rect, buf: &mut Buffer, state: &mut MenuLineState) {
294 state.area = area;
295 state.inner = widget.block.inner_if_some(area);
296 state.item_areas.clear();
297
298 if widget.menu.items.is_empty() {
299 state.selected = None;
300 } else if state.selected.is_none() {
301 state.selected = Some(0);
302 }
303
304 state.navchar = widget
305 .menu
306 .items
307 .iter()
308 .map(|v| v.navchar.map(|w| w.to_ascii_lowercase()))
309 .collect();
310 state.disabled = widget.menu.items.iter().map(|v| v.disabled).collect();
311
312 let style = widget.style;
313 let right_style = style.patch(widget.right_style.unwrap_or_default());
314 let highlight_style = style.patch(widget.highlight_style.unwrap_or(Style::new().underlined()));
315 let disabled_style = style.patch(widget.disabled_style.unwrap_or_default());
316
317 let (sel_style, sel_right_style, sel_highlight_style, sel_disabled_style) =
318 if state.is_focused() {
319 let focus_style = widget.focus_style.unwrap_or(revert_style(style));
320 (
321 focus_style,
322 focus_style.patch(right_style),
323 focus_style,
324 focus_style.patch(widget.disabled_style.unwrap_or_default()),
325 )
326 } else {
327 (
328 style, right_style,
330 highlight_style,
331 disabled_style,
332 )
333 };
334
335 let title_style = if let Some(title_style) = widget.title_style {
336 title_style
337 } else {
338 style.underlined()
339 };
340
341 if let Some(block) = &widget.block {
342 block.render(area, buf);
343 } else {
344 buf.set_style(area, style);
345 }
346
347 let mut item_area = Rect::new(state.inner.x, state.inner.y, 0, 1);
348
349 if widget.title.width() > 0 {
350 item_area.width = widget.title.width() as u16;
351
352 buf.set_style(item_area, title_style);
353 widget.title.clone().render(item_area, buf);
354
355 item_area.x += item_area.width + 1;
356 }
357
358 for (n, item) in widget.menu.items.iter().enumerate() {
359 item_area.width =
360 item.item_width() + item.right_width() + if item.right.is_empty() { 0 } else { 2 };
361 if item_area.right() >= state.inner.right() {
362 item_area = item_area.clamp(state.inner);
363 }
364 state.item_areas.push(item_area);
365
366 #[allow(clippy::collapsible_else_if)]
367 let (style, right_style, highlight_style) = if state.selected == Some(n) {
368 if item.disabled {
369 (sel_disabled_style, sel_right_style, sel_highlight_style)
370 } else {
371 (sel_style, sel_right_style, sel_highlight_style)
372 }
373 } else {
374 if item.disabled {
375 (disabled_style, right_style, highlight_style)
376 } else {
377 (style, right_style, highlight_style)
378 }
379 };
380
381 let item_line = if let Some(highlight) = item.highlight.clone() {
382 Line::from_iter([
383 Span::from(&item.item[..highlight.start - 1]), Span::from(&item.item[highlight.start..highlight.end]).style(highlight_style),
385 Span::from(&item.item[highlight.end..]),
386 if !item.right.is_empty() {
387 Span::from(format!("({})", item.right)).style(right_style)
388 } else {
389 Span::default()
390 },
391 ])
392 } else {
393 Line::from_iter([
394 Span::from(item.item.as_ref()),
395 if !item.right.is_empty() {
396 Span::from(format!("({})", item.right)).style(right_style)
397 } else {
398 Span::default()
399 },
400 ])
401 };
402 item_line.style(style).render(item_area, buf);
403
404 item_area.x += item_area.width + 1;
405 }
406}
407
408impl HasFocus for MenuLineState {
409 fn build(&self, builder: &mut FocusBuilder) {
410 builder.leaf_widget(self);
411 }
412
413 fn focus(&self) -> FocusFlag {
415 self.focus.clone()
416 }
417
418 fn area(&self) -> Rect {
420 self.area
421 }
422}
423
424impl HasScreenCursor for MenuLineState {
425 fn screen_cursor(&self) -> Option<(u16, u16)> {
426 None
427 }
428}
429
430impl RelocatableState for MenuLineState {
431 fn relocate(&mut self, shift: (i16, i16), clip: Rect) {
432 self.area.relocate(shift, clip);
433 self.inner.relocate(shift, clip);
434 self.item_areas.relocate(shift, clip);
435 }
436}
437
438#[allow(clippy::len_without_is_empty)]
439impl MenuLineState {
440 pub fn new() -> Self {
441 Self::default()
442 }
443
444 pub fn named(name: &str) -> Self {
446 let mut z = Self::default();
447 z.focus = z.focus.with_name(name);
448 z
449 }
450
451 #[inline]
453 pub fn len(&self) -> usize {
454 self.item_areas.len()
455 }
456
457 pub fn is_empty(&self) -> bool {
459 self.item_areas.is_empty()
460 }
461
462 #[inline]
464 pub fn select(&mut self, select: Option<usize>) -> bool {
465 let old = self.selected;
466 self.selected = select;
467 old != self.selected
468 }
469
470 #[inline]
472 pub fn selected(&self) -> Option<usize> {
473 self.selected
474 }
475
476 #[inline]
478 pub fn prev_item(&mut self) -> bool {
479 let old = self.selected;
480
481 if self.disabled.is_empty() {
483 return false;
484 }
485
486 self.selected = if let Some(start) = old {
487 let mut idx = start;
488 loop {
489 if idx == 0 {
490 idx = start;
491 break;
492 }
493 idx -= 1;
494
495 if self.disabled.get(idx) == Some(&false) {
496 break;
497 }
498 }
499
500 Some(idx)
501 } else if !self.is_empty() {
502 Some(self.len().saturating_sub(1))
503 } else {
504 None
505 };
506
507 old != self.selected
508 }
509
510 #[inline]
512 pub fn next_item(&mut self) -> bool {
513 let old = self.selected;
514
515 if self.disabled.is_empty() {
517 return false;
518 }
519
520 self.selected = if let Some(start) = old {
521 let mut idx = start;
522 loop {
523 if idx + 1 == self.len() {
524 idx = start;
525 break;
526 }
527 idx += 1;
528
529 if self.disabled.get(idx) == Some(&false) {
530 break;
531 }
532 }
533 Some(idx)
534 } else if !self.is_empty() {
535 Some(0)
536 } else {
537 None
538 };
539
540 old != self.selected
541 }
542
543 #[inline]
545 pub fn navigate(&mut self, c: char) -> MenuOutcome {
546 if self.disabled.is_empty() {
548 return MenuOutcome::Continue;
549 }
550
551 let c = c.to_ascii_lowercase();
552 for (i, cc) in self.navchar.iter().enumerate() {
553 #[allow(clippy::collapsible_if)]
554 if *cc == Some(c) {
555 if self.disabled.get(i) == Some(&false) {
556 if self.selected == Some(i) {
557 return MenuOutcome::Activated(i);
558 } else {
559 self.selected = Some(i);
560 return MenuOutcome::Selected(i);
561 }
562 }
563 }
564 }
565
566 MenuOutcome::Continue
567 }
568
569 #[inline]
573 #[allow(clippy::collapsible_if)]
574 pub fn select_at(&mut self, pos: (u16, u16)) -> bool {
575 let old_selected = self.selected;
576
577 if self.disabled.is_empty() {
579 return false;
580 }
581
582 if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
583 if self.disabled.get(idx) == Some(&false) {
584 self.selected = Some(idx);
585 }
586 }
587
588 self.selected != old_selected
589 }
590
591 #[inline]
595 #[allow(clippy::collapsible_if)]
596 pub fn select_at_always(&mut self, pos: (u16, u16)) -> bool {
597 if self.disabled.is_empty() {
599 return false;
600 }
601
602 if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
603 if self.disabled.get(idx) == Some(&false) {
604 self.selected = Some(idx);
605 return true;
606 }
607 }
608
609 false
610 }
611
612 #[inline]
614 pub fn item_at(&self, pos: (u16, u16)) -> Option<usize> {
615 self.mouse.item_at(&self.item_areas, pos.0, pos.1)
616 }
617}
618
619impl Clone for MenuLineState {
620 fn clone(&self) -> Self {
621 Self {
622 area: self.area,
623 inner: self.inner,
624 item_areas: self.item_areas.clone(),
625 navchar: self.navchar.clone(),
626 disabled: self.disabled.clone(),
627 selected: self.selected,
628 focus: self.focus.new_instance(),
629 mouse: Default::default(),
630 non_exhaustive: NonExhaustive,
631 }
632 }
633}
634
635impl Default for MenuLineState {
636 fn default() -> Self {
637 Self {
638 area: Default::default(),
639 inner: Default::default(),
640 item_areas: Default::default(),
641 navchar: Default::default(),
642 disabled: Default::default(),
643 selected: Default::default(),
644 focus: Default::default(),
645 mouse: Default::default(),
646 non_exhaustive: NonExhaustive,
647 }
648 }
649}
650
651impl HandleEvent<crossterm::event::Event, Regular, MenuOutcome> for MenuLineState {
652 #[allow(clippy::redundant_closure)]
653 fn handle(&mut self, event: &crossterm::event::Event, _: Regular) -> MenuOutcome {
654 let res = if self.is_focused() {
655 match event {
656 ct_event!(key press ' ') => {
657 self
658 .selected .map_or(MenuOutcome::Continue, |v| MenuOutcome::Selected(v))
660 }
661 ct_event!(key press ANY-c) => {
662 self.navigate(*c) }
664 ct_event!(keycode press Left) => {
665 if self.prev_item() {
666 if let Some(selected) = self.selected {
667 MenuOutcome::Selected(selected)
668 } else {
669 MenuOutcome::Changed
670 }
671 } else {
672 MenuOutcome::Continue
673 }
674 }
675 ct_event!(keycode press Right) => {
676 if self.next_item() {
677 if let Some(selected) = self.selected {
678 MenuOutcome::Selected(selected)
679 } else {
680 MenuOutcome::Changed
681 }
682 } else {
683 MenuOutcome::Continue
684 }
685 }
686 ct_event!(keycode press Home) => {
687 if self.select(Some(0)) {
688 if let Some(selected) = self.selected {
689 MenuOutcome::Selected(selected)
690 } else {
691 MenuOutcome::Changed
692 }
693 } else {
694 MenuOutcome::Continue
695 }
696 }
697 ct_event!(keycode press End) => {
698 if self.select(Some(self.len().saturating_sub(1))) {
699 if let Some(selected) = self.selected {
700 MenuOutcome::Selected(selected)
701 } else {
702 MenuOutcome::Changed
703 }
704 } else {
705 MenuOutcome::Continue
706 }
707 }
708 ct_event!(keycode press Enter) => {
709 if let Some(select) = self.selected {
710 MenuOutcome::Activated(select)
711 } else {
712 MenuOutcome::Continue
713 }
714 }
715 _ => MenuOutcome::Continue,
716 }
717 } else {
718 MenuOutcome::Continue
719 };
720
721 if res == MenuOutcome::Continue {
722 self.handle(event, MouseOnly)
723 } else {
724 res
725 }
726 }
727}
728
729impl HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome> for MenuLineState {
730 fn handle(&mut self, event: &crossterm::event::Event, _: MouseOnly) -> MenuOutcome {
731 match event {
732 ct_event!(mouse any for m) if self.mouse.doubleclick(self.area, m) => {
733 let idx = self.item_at(self.mouse.pos_of(m));
734 if self.selected() == idx {
735 match self.selected {
736 Some(a) => MenuOutcome::Activated(a),
737 None => MenuOutcome::Continue,
738 }
739 } else {
740 MenuOutcome::Continue
741 }
742 }
743 ct_event!(mouse any for m) if self.mouse.drag(self.area, m) => {
744 let old = self.selected;
745 if self.select_at(self.mouse.pos_of(m)) {
746 if old != self.selected {
747 MenuOutcome::Selected(self.selected().expect("selected"))
748 } else {
749 MenuOutcome::Unchanged
750 }
751 } else {
752 MenuOutcome::Continue
753 }
754 }
755 ct_event!(mouse down Left for col, row) if self.area.contains((*col, *row).into()) => {
756 if self.select_at_always((*col, *row)) {
757 MenuOutcome::Selected(self.selected().expect("selected"))
758 } else {
759 MenuOutcome::Continue
760 }
761 }
762 _ => MenuOutcome::Continue,
763 }
764 }
765}
766
767pub fn handle_events(
771 state: &mut MenuLineState,
772 focus: bool,
773 event: &crossterm::event::Event,
774) -> MenuOutcome {
775 state.focus.set(focus);
776 state.handle(event, Regular)
777}
778
779pub fn handle_mouse_events(
781 state: &mut MenuLineState,
782 event: &crossterm::event::Event,
783) -> MenuOutcome {
784 state.handle(event, MouseOnly)
785}