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