rat_menu/
menubar.rs

1//!
2//! A menubar with sub-menus.
3//!
4//! Combines [MenuLine] and [PopupMenu] and adds a [MenuStructure] trait
5//! to bind all together.
6//!
7//! Rendering and events are split in base-widget and popup.
8//! Use [Menubar] to set all configurations and then call [Menubar::into_widgets].
9//! This creates a [MenubarLine] and a [MenubarPopup] which implement
10//! the rendering traits. MenubarPopup must render *after* all regular
11//! widgets, MenubarLine can render whenever.
12//!
13//! Event-handling for the popup menu works with the [Popup] qualifier,
14//! and must be called before the [Regular] event-handlers to work correctly.
15//! Event-handling for the menu line is via the [Regular] event-handler.
16//!
17#![allow(clippy::uninlined_format_args)]
18use crate::event::MenuOutcome;
19use crate::menuline::{MenuLine, MenuLineState};
20use crate::popup_menu::{PopupMenu, PopupMenuState};
21use crate::{MenuStructure, MenuStyle};
22use rat_event::{ConsumedEvent, HandleEvent, MouseOnly, Popup, Regular};
23use rat_focus::{FocusBuilder, FocusFlag, HasFocus, Navigation};
24use rat_popup::Placement;
25use ratatui::buffer::Buffer;
26use ratatui::layout::{Alignment, Rect};
27use ratatui::style::Style;
28use ratatui::text::Line;
29use ratatui::widgets::{Block, StatefulWidget};
30use std::fmt::Debug;
31
32/// Menubar widget.
33/// This handles the configuration only, to get the widgets for rendering
34/// call [Menubar::into_widgets] and use both results for rendering.
35#[derive(Debug, Clone)]
36pub struct Menubar<'a> {
37    structure: Option<&'a dyn MenuStructure<'a>>,
38
39    title: Line<'a>,
40    style: Style,
41    title_style: Option<Style>,
42    select_style: Option<Style>,
43    focus_style: Option<Style>,
44    highlight_style: Option<Style>,
45    disabled_style: Option<Style>,
46    right_style: Option<Style>,
47
48    popup_alignment: Alignment,
49    popup_placement: Placement,
50    popup: PopupMenu<'a>,
51}
52
53/// Menubar line widget.
54/// This implements the actual render function.
55#[derive(Debug, Clone)]
56pub struct MenubarLine<'a> {
57    structure: Option<&'a dyn MenuStructure<'a>>,
58
59    title: Line<'a>,
60    style: Style,
61    title_style: Option<Style>,
62    select_style: Option<Style>,
63    focus_style: Option<Style>,
64    highlight_style: Option<Style>,
65    disabled_style: Option<Style>,
66    right_style: Option<Style>,
67}
68
69/// Menubar popup widget.
70/// Separate renderer for the popup part of the menubar.
71#[derive(Debug, Clone)]
72pub struct MenubarPopup<'a> {
73    structure: Option<&'a dyn MenuStructure<'a>>,
74
75    style: Style,
76    focus_style: Option<Style>,
77    highlight_style: Option<Style>,
78    disabled_style: Option<Style>,
79    right_style: Option<Style>,
80
81    popup_alignment: Alignment,
82    popup_placement: Placement,
83    popup: PopupMenu<'a>,
84}
85
86/// State & event-handling.
87#[derive(Debug, Default, Clone)]
88pub struct MenubarState {
89    /// Area for the menubar.
90    /// __readonly__. renewed for each render.
91    pub area: Rect,
92    /// State for the menu.
93    pub bar: MenuLineState,
94    /// State for the last rendered popup menu.
95    pub popup: PopupMenuState,
96}
97
98impl Default for Menubar<'_> {
99    fn default() -> Self {
100        Self {
101            structure: None,
102            title: Default::default(),
103            style: Default::default(),
104            title_style: None,
105            select_style: None,
106            focus_style: None,
107            highlight_style: None,
108            disabled_style: None,
109            right_style: None,
110            popup_alignment: Alignment::Left,
111            popup_placement: Placement::AboveOrBelow,
112            popup: Default::default(),
113        }
114    }
115}
116
117impl<'a> Menubar<'a> {
118    pub fn new(structure: &'a dyn MenuStructure<'a>) -> Self {
119        Self {
120            structure: Some(structure),
121            ..Default::default()
122        }
123    }
124
125    /// Title text.
126    #[inline]
127    pub fn title(mut self, title: impl Into<Line<'a>>) -> Self {
128        self.title = title.into();
129        self
130    }
131
132    /// Combined style.
133    #[inline]
134    pub fn styles(mut self, styles: MenuStyle) -> Self {
135        self.popup = self.popup.styles(styles.clone());
136
137        self.style = styles.style;
138        if styles.highlight.is_some() {
139            self.highlight_style = styles.highlight;
140        }
141        if styles.disabled.is_some() {
142            self.disabled_style = styles.disabled;
143        }
144        if styles.focus.is_some() {
145            self.focus_style = styles.focus;
146        }
147        if styles.title.is_some() {
148            self.title_style = styles.title;
149        }
150        if styles.select.is_some() {
151            self.select_style = styles.select;
152        }
153        if styles.focus.is_some() {
154            self.focus_style = styles.focus;
155        }
156        if styles.right.is_some() {
157            self.right_style = styles.right;
158        }
159        if let Some(alignment) = styles.popup.alignment {
160            self.popup_alignment = alignment;
161        }
162        if let Some(placement) = styles.popup.placement {
163            self.popup_placement = placement;
164        }
165        self
166    }
167
168    /// Base style.
169    #[inline]
170    pub fn style(mut self, style: Style) -> Self {
171        self.style = style;
172        self
173    }
174
175    /// Menu-title style.
176    #[inline]
177    pub fn title_style(mut self, style: Style) -> Self {
178        self.title_style = Some(style);
179        self
180    }
181
182    /// Selection
183    #[inline]
184    pub fn select_style(mut self, style: Style) -> Self {
185        self.select_style = Some(style);
186        self
187    }
188
189    /// Selection + Focus
190    #[inline]
191    pub fn focus_style(mut self, style: Style) -> Self {
192        self.focus_style = Some(style);
193        self
194    }
195
196    /// Selection + Focus
197    #[inline]
198    pub fn right_style(mut self, style: Style) -> Self {
199        self.right_style = Some(style);
200        self
201    }
202
203    /// Fixed width for the menu.
204    /// If not set it uses 1.5 times the length of the longest item.
205    pub fn popup_width(mut self, width: u16) -> Self {
206        self.popup = self.popup.width(width);
207        self
208    }
209
210    /// Placement relative to the render-area.
211    pub fn popup_alignment(mut self, alignment: Alignment) -> Self {
212        self.popup_alignment = alignment;
213        self
214    }
215
216    /// Placement relative to the render-area.
217    pub fn popup_placement(mut self, placement: Placement) -> Self {
218        self.popup_placement = placement;
219        self
220    }
221
222    /// Block for borders.
223    pub fn popup_block(mut self, block: Block<'a>) -> Self {
224        self.popup = self.popup.block(block);
225        self
226    }
227
228    /// Create the widgets for the Menubar. This returns a widget
229    /// for the menu-line and for the menu-popup.
230    ///
231    /// The menu-popup should be rendered, after all widgets
232    /// that might be below the popup have been rendered.
233    pub fn into_widgets(self) -> (MenubarLine<'a>, MenubarPopup<'a>) {
234        (
235            MenubarLine {
236                structure: self.structure,
237                title: self.title,
238                style: self.style,
239                title_style: self.title_style,
240                select_style: self.select_style,
241                focus_style: self.focus_style,
242                highlight_style: self.highlight_style,
243                disabled_style: self.disabled_style,
244                right_style: self.right_style,
245            },
246            MenubarPopup {
247                structure: self.structure,
248                style: self.style,
249                focus_style: self.focus_style,
250                highlight_style: self.highlight_style,
251                disabled_style: self.disabled_style,
252                right_style: self.right_style,
253                popup_alignment: self.popup_alignment,
254                popup_placement: self.popup_placement,
255                popup: self.popup,
256            },
257        )
258    }
259}
260
261impl StatefulWidget for MenubarLine<'_> {
262    type State = MenubarState;
263
264    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
265        render_menubar(&self, area, buf, state);
266    }
267}
268
269fn render_menubar(
270    widget: &MenubarLine<'_>,
271    area: Rect,
272    buf: &mut Buffer,
273    state: &mut MenubarState,
274) {
275    let mut menu = MenuLine::new()
276        .title(widget.title.clone())
277        .style(widget.style)
278        .title_style_opt(widget.title_style)
279        .select_style_opt(widget.select_style)
280        .focus_style_opt(widget.focus_style)
281        .highlight_style_opt(widget.highlight_style)
282        .disabled_style_opt(widget.disabled_style)
283        .right_style_opt(widget.right_style);
284
285    if let Some(structure) = &widget.structure {
286        structure.menus(&mut menu.menu);
287    }
288    menu.render(area, buf, &mut state.bar);
289
290    // Combined area + each part with a z-index.
291    state.area = state.bar.area;
292}
293
294impl StatefulWidget for MenubarPopup<'_> {
295    type State = MenubarState;
296
297    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
298        render_menu_popup(self, area, buf, state);
299    }
300}
301
302fn render_menu_popup(
303    widget: MenubarPopup<'_>,
304    _area: Rect,
305    buf: &mut Buffer,
306    state: &mut MenubarState,
307) {
308    // Combined area + each part with a z-index.
309    state.area = state.bar.area;
310
311    let Some(selected) = state.bar.selected() else {
312        return;
313    };
314    let Some(structure) = widget.structure else {
315        return;
316    };
317
318    if state.popup.is_active() {
319        let item = state.bar.item_areas[selected];
320
321        let popup_padding = widget.popup.get_block_padding();
322        let sub_offset = (-(popup_padding.left as i16 + 1), 0);
323
324        let mut popup = widget
325            .popup
326            .constraint(
327                widget
328                    .popup_placement
329                    .into_constraint(widget.popup_alignment, item),
330            )
331            .offset(sub_offset)
332            .style(widget.style)
333            .focus_style_opt(widget.focus_style)
334            .highlight_style_opt(widget.highlight_style)
335            .disabled_style_opt(widget.disabled_style)
336            .right_style_opt(widget.right_style);
337
338        structure.submenu(selected, &mut popup.menu);
339
340        if !popup.menu.items.is_empty() {
341            let area = state.bar.item_areas[selected];
342            popup.render(area, buf, &mut state.popup);
343
344            // Combined area + each part with a z-index.
345            state.area = state.bar.area.union(state.popup.popup.area);
346        }
347    } else {
348        state.popup = Default::default();
349    }
350}
351
352impl MenubarState {
353    /// State.
354    /// For the specifics use the public fields `menu` and `popup`.
355    pub fn new() -> Self {
356        Self::default()
357    }
358
359    /// New state with a focus name.
360    pub fn named(name: &'static str) -> Self {
361        Self {
362            bar: MenuLineState::named(format!("{}.bar", name).to_string().leak()),
363            popup: PopupMenuState::new(),
364            ..Default::default()
365        }
366    }
367
368    /// Submenu visible/active.
369    pub fn popup_active(&self) -> bool {
370        self.popup.is_active()
371    }
372
373    /// Submenu visible/active.
374    pub fn set_popup_active(&mut self, active: bool) {
375        self.popup.set_active(active);
376    }
377
378    /// Set the z-value for the popup-menu.
379    ///
380    /// This is the z-index used when adding the menubar to
381    /// the focus list.
382    pub fn set_popup_z(&mut self, z: u16) {
383        self.popup.set_popup_z(z)
384    }
385
386    /// The z-index for the popup-menu.
387    pub fn popup_z(&self) -> u16 {
388        self.popup.popup_z()
389    }
390
391    /// Selected as menu/submenu
392    pub fn selected(&self) -> (Option<usize>, Option<usize>) {
393        (self.bar.selected, self.popup.selected)
394    }
395}
396
397impl HasFocus for MenubarState {
398    fn build(&self, builder: &mut FocusBuilder) {
399        builder.widget_with_flags(self.focus(), self.area(), self.area_z(), self.navigable());
400        builder.widget_with_flags(
401            self.focus(),
402            self.popup.popup.area,
403            self.popup.popup.area_z,
404            Navigation::Mouse,
405        );
406    }
407
408    fn focus(&self) -> FocusFlag {
409        self.bar.focus.clone()
410    }
411
412    fn area(&self) -> Rect {
413        self.area
414    }
415}
416
417impl HandleEvent<crossterm::event::Event, Popup, MenuOutcome> for MenubarState {
418    fn handle(&mut self, event: &crossterm::event::Event, _qualifier: Popup) -> MenuOutcome {
419        handle_menubar(self, event, Popup, Regular)
420    }
421}
422
423impl HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome> for MenubarState {
424    fn handle(&mut self, event: &crossterm::event::Event, _qualifier: MouseOnly) -> MenuOutcome {
425        handle_menubar(self, event, MouseOnly, MouseOnly)
426    }
427}
428
429fn handle_menubar<Q1, Q2>(
430    state: &mut MenubarState,
431    event: &crossterm::event::Event,
432    qualifier1: Q1,
433    qualifier2: Q2,
434) -> MenuOutcome
435where
436    PopupMenuState: HandleEvent<crossterm::event::Event, Q1, MenuOutcome>,
437    MenuLineState: HandleEvent<crossterm::event::Event, Q2, MenuOutcome>,
438    MenuLineState: HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome>,
439{
440    if !state.is_focused() {
441        state.set_popup_active(false);
442    }
443
444    if state.bar.is_focused() {
445        let mut r = if let Some(selected) = state.bar.selected() {
446            if state.popup_active() {
447                match state.popup.handle(event, qualifier1) {
448                    MenuOutcome::Hide => {
449                        // only hide on focus lost. ignore this one.
450                        MenuOutcome::Continue
451                    }
452                    MenuOutcome::Selected(n) => MenuOutcome::MenuSelected(selected, n),
453                    MenuOutcome::Activated(n) => MenuOutcome::MenuActivated(selected, n),
454                    r => r,
455                }
456            } else {
457                MenuOutcome::Continue
458            }
459        } else {
460            MenuOutcome::Continue
461        };
462
463        r = r.or_else(|| {
464            let old_selected = state.bar.selected();
465            let r = state.bar.handle(event, qualifier2);
466            match r {
467                MenuOutcome::Selected(_) => {
468                    if state.bar.selected == old_selected {
469                        state.popup.flip_active();
470                    } else {
471                        state.popup.select(None);
472                        state.popup.set_active(true);
473                    }
474                }
475                MenuOutcome::Activated(_) => {
476                    state.popup.flip_active();
477                }
478                _ => {}
479            }
480            r
481        });
482
483        r
484    } else {
485        state.bar.handle(event, MouseOnly)
486    }
487}
488
489/// Handle menu events for the popup-menu.
490///
491/// This one is separate, as it needs to be called before other event-handlers
492/// to cope with overlapping regions.
493///
494/// focus - is the menubar focused?
495pub fn handle_popup_events(
496    state: &mut MenubarState,
497    focus: bool,
498    event: &crossterm::event::Event,
499) -> MenuOutcome {
500    state.bar.focus.set(focus);
501    state.handle(event, Popup)
502}
503
504/// Handle only mouse-events.
505pub fn handle_mouse_events(
506    state: &mut MenuLineState,
507    event: &crossterm::event::Event,
508) -> MenuOutcome {
509    state.handle(event, MouseOnly)
510}