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