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