rat_widget/
caption.rs

1//!
2//! Label/Caption for a linked widget with hotkey focusing.
3//!
4//! ** unstable **
5//!
6use crate::_private::NonExhaustive;
7use crate::util::revert_style;
8use rat_event::util::MouseFlags;
9use rat_event::{HandleEvent, Outcome, ct_event};
10use rat_focus::{Focus, FocusFlag, HasFocus};
11use rat_reloc::{RelocatableState, relocate_area};
12use ratatui::buffer::Buffer;
13use ratatui::layout::{Alignment, Rect};
14use ratatui::style::{Style, Stylize};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::{StatefulWidget, Widget};
17use std::borrow::Cow;
18use std::ops::Range;
19use unicode_segmentation::UnicodeSegmentation;
20
21///
22/// A label/caption linked to another widget.
23///
24/// *** unstable ***
25///
26#[derive(Debug, Clone)]
27pub struct Caption<'a> {
28    /// Text
29    text: Cow<'a, str>,
30    /// Text range to highlight. A byte-range into text.
31    highlight: Option<Range<usize>>,
32    /// Navigation key char.
33    navchar: Option<char>,
34    /// Hotkey text.
35    hotkey_text: Cow<'a, str>,
36    /// Hotkey alignment
37    hotkey_align: HotkeyAlignment,
38    /// Hotkey policy
39    hotkey_policy: HotkeyPolicy,
40    /// Hot-key 2
41    hotkey: Option<crossterm::event::KeyEvent>,
42    /// Text/Hotkey spacing
43    spacing: u16,
44    /// Label alignment
45    align: Alignment,
46
47    /// Linked widget
48    linked: Option<FocusFlag>,
49
50    style: Style,
51    hover_style: Option<Style>,
52    highlight_style: Option<Style>,
53    hotkey_style: Option<Style>,
54    focus_style: Option<Style>,
55}
56
57#[derive(Debug)]
58pub struct CaptionState {
59    /// Area for the whole widget.
60    /// __readonly__. renewed for each render.
61    pub area: Rect,
62    /// Hot-key 1
63    /// __readonly__. renewed for each render.
64    pub navchar: Option<char>,
65    /// Hot-key 2
66    /// __limited__. renewed for each render if set with the widget.
67    pub hotkey: Option<crossterm::event::KeyEvent>,
68
69    /// Associated widget
70    pub linked: FocusFlag,
71
72    /// Flags for mouse handling.
73    /// __used for mouse interaction__
74    pub mouse: MouseFlags,
75
76    pub non_exhaustive: NonExhaustive,
77}
78
79/// Composite style for the caption.
80#[derive(Debug, Clone)]
81pub struct CaptionStyle {
82    /// Base style
83    pub style: Style,
84    /// Hover style
85    pub hover: Option<Style>,
86    /// Highlight style
87    pub highlight: Option<Style>,
88    /// Hotkey style
89    pub hotkey: Option<Style>,
90    /// Focus style
91    pub focus: Option<Style>,
92
93    /// Label alignment
94    pub align: Option<Alignment>,
95    /// Hotkey alignment
96    pub hotkey_align: Option<HotkeyAlignment>,
97    /// Hotkey policy
98    pub hotkey_policy: Option<HotkeyPolicy>,
99    /// Label/hotkey spacing
100    pub spacing: Option<u16>,
101
102    pub non_exhaustive: NonExhaustive,
103}
104
105/// Policy for hover.
106#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
107pub enum HotkeyPolicy {
108    /// No special behaviour. Hotkey text is always shown.
109    #[default]
110    Always,
111    /// Only show the hotkey text on hover.
112    OnHover,
113    /// Only show the hotkey text when the main widget is focused.
114    WhenFocused,
115}
116
117/// Alignment of label-text and hotkey-text.
118#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
119pub enum HotkeyAlignment {
120    /// Display as "label hotkey"
121    #[default]
122    LabelHotkey,
123    /// Display as "hotkey label"
124    HotkeyLabel,
125}
126
127impl<'a> Default for Caption<'a> {
128    fn default() -> Self {
129        Self {
130            text: Default::default(),
131            highlight: Default::default(),
132            navchar: Default::default(),
133            hotkey_text: Default::default(),
134            hotkey_align: Default::default(),
135            hotkey_policy: Default::default(),
136            hotkey: Default::default(),
137            spacing: 1,
138            align: Default::default(),
139            linked: Default::default(),
140            style: Default::default(),
141            hover_style: Default::default(),
142            highlight_style: Default::default(),
143            hotkey_style: Default::default(),
144            focus_style: Default::default(),
145        }
146    }
147}
148
149impl<'a> Caption<'a> {
150    /// New
151    pub fn new() -> Self {
152        Default::default()
153    }
154
155    /// Uses '_' as special character.
156    ///
157    /// __Item__
158    ///
159    /// The first '_' marks the navigation-char.
160    /// Pipe '|' separates the item text and the hotkey text.
161    pub fn parse(txt: &'a str) -> Self {
162        let mut zelf = Caption::default();
163
164        let mut idx_underscore = None;
165        let mut idx_navchar_start = None;
166        let mut idx_navchar_end = None;
167        let mut idx_pipe = None;
168
169        let cit = txt.char_indices();
170        for (idx, c) in cit {
171            if idx_underscore.is_none() && c == '_' {
172                idx_underscore = Some(idx);
173            } else if idx_underscore.is_some() && idx_navchar_start.is_none() {
174                idx_navchar_start = Some(idx);
175            } else if idx_navchar_start.is_some() && idx_navchar_end.is_none() {
176                idx_navchar_end = Some(idx);
177            }
178            if c == '|' {
179                idx_pipe = Some(idx);
180            }
181        }
182        if idx_navchar_start.is_some() && idx_navchar_end.is_none() {
183            idx_navchar_end = Some(txt.len());
184        }
185
186        if let Some(pipe) = idx_pipe {
187            if let Some(navchar_end) = idx_navchar_end {
188                if navchar_end > pipe {
189                    idx_pipe = None;
190                }
191            }
192        }
193
194        let (text, hotkey_text) = if let Some(idx_pipe) = idx_pipe {
195            (&txt[..idx_pipe], &txt[idx_pipe + 1..])
196        } else {
197            (txt, "")
198        };
199
200        if let Some(idx_navchar_start) = idx_navchar_start {
201            if let Some(idx_navchar_end) = idx_navchar_end {
202                zelf.text = Cow::Borrowed(text);
203                zelf.highlight = Some(idx_navchar_start..idx_navchar_end);
204                zelf.navchar = Some(
205                    text[idx_navchar_start..idx_navchar_end]
206                        .chars()
207                        .next()
208                        .expect("char")
209                        .to_ascii_lowercase(),
210                );
211                zelf.hotkey_text = Cow::Borrowed(hotkey_text);
212            } else {
213                unreachable!();
214            }
215        } else {
216            zelf.text = Cow::Borrowed(text);
217            zelf.highlight = None;
218            zelf.navchar = None;
219            zelf.hotkey_text = Cow::Borrowed(hotkey_text);
220        }
221
222        zelf
223    }
224
225    /// Set the label text.
226    ///
227    /// You probably want to use [parse](Caption::parse) instead.
228    /// This is only useful if you want manual control over
229    /// highlight-range and hotkey text.
230    pub fn text(mut self, txt: &'a str) -> Self {
231        self.text = Cow::Borrowed(txt);
232        self
233    }
234
235    /// Spacing between text and hotkey-text.
236    pub fn spacing(mut self, spacing: u16) -> Self {
237        self.spacing = spacing;
238        self
239    }
240
241    /// Alternate navigation key
242    pub fn hotkey(mut self, hotkey: crossterm::event::KeyEvent) -> Self {
243        self.hotkey = Some(hotkey);
244        self
245    }
246
247    /// Hotkey text
248    pub fn hotkey_text(mut self, hotkey: &'a str) -> Self {
249        self.hotkey_text = Cow::Borrowed(hotkey);
250        self
251    }
252
253    /// Alignment of the hotkey text.
254    pub fn hotkey_align(mut self, align: HotkeyAlignment) -> Self {
255        self.hotkey_align = align;
256        self
257    }
258
259    /// Policy for when to show the hotkey text.
260    pub fn hotkey_policy(mut self, policy: HotkeyPolicy) -> Self {
261        self.hotkey_policy = policy;
262        self
263    }
264
265    /// Set the linked widget.
266    pub fn link(mut self, widget: &impl HasFocus) -> Self {
267        self.linked = Some(widget.focus());
268        self
269    }
270
271    /// Byte-range into text to be highlighted.
272    pub fn highlight(mut self, bytes: Range<usize>) -> Self {
273        self.highlight = Some(bytes);
274        self
275    }
276
277    /// Navigation-char.
278    pub fn navchar(mut self, navchar: char) -> Self {
279        self.navchar = Some(navchar);
280        self
281    }
282
283    /// Label alignment.
284    pub fn align(mut self, align: Alignment) -> Self {
285        self.align = align;
286        self
287    }
288
289    /// Set all styles.
290    pub fn styles(mut self, styles: CaptionStyle) -> Self {
291        self.style = styles.style;
292        if styles.hover.is_some() {
293            self.hover_style = styles.hover;
294        }
295        if styles.highlight.is_some() {
296            self.highlight_style = styles.highlight;
297        }
298        if styles.hotkey.is_some() {
299            self.hotkey_style = styles.hotkey;
300        }
301        if styles.focus.is_some() {
302            self.focus_style = styles.focus;
303        }
304        if let Some(spacing) = styles.spacing {
305            self.spacing = spacing;
306        }
307        if let Some(align) = styles.hotkey_align {
308            self.hotkey_align = align;
309        }
310        if let Some(align) = styles.align {
311            self.align = align;
312        }
313        if let Some(hotkey_policy) = styles.hotkey_policy {
314            self.hotkey_policy = hotkey_policy;
315        }
316        self
317    }
318
319    /// Base style.
320    #[inline]
321    pub fn style(mut self, style: Style) -> Self {
322        self.style = style;
323        self
324    }
325
326    /// Hover style.
327    #[inline]
328    pub fn hover_style(mut self, style: Style) -> Self {
329        self.hover_style = Some(style);
330        self
331    }
332
333    /// Hover style.
334    #[inline]
335    pub fn hover_opt(mut self, style: Option<Style>) -> Self {
336        self.hover_style = style;
337        self
338    }
339
340    /// Shortcut highlight style.
341    #[inline]
342    pub fn highlight_style(mut self, style: Style) -> Self {
343        self.highlight_style = Some(style);
344        self
345    }
346
347    /// Shortcut highlight style.
348    #[inline]
349    pub fn highlight_style_opt(mut self, style: Option<Style>) -> Self {
350        self.highlight_style = style;
351        self
352    }
353
354    /// Style for the hotkey.
355    #[inline]
356    pub fn hotkey_style(mut self, style: Style) -> Self {
357        self.hotkey_style = Some(style);
358        self
359    }
360
361    /// Style for the hotkey.
362    #[inline]
363    pub fn hotkey_style_opt(mut self, style: Option<Style>) -> Self {
364        self.hotkey_style = style;
365        self
366    }
367
368    /// Base-style when the main widget is focused.
369    #[inline]
370    pub fn focus_style(mut self, style: Style) -> Self {
371        self.focus_style = Some(style);
372        self
373    }
374
375    /// Base-style when the main widget is focused.
376    #[inline]
377    pub fn focus_style_opt(mut self, style: Option<Style>) -> Self {
378        self.focus_style = style;
379        self
380    }
381
382    /// Inherent width
383    pub fn text_width(&self) -> u16 {
384        self.text.graphemes(true).count() as u16
385    }
386
387    /// Inherent width
388    pub fn hotkey_width(&self) -> u16 {
389        self.hotkey_text.graphemes(true).count() as u16
390    }
391
392    /// Inherent width
393    pub fn width(&self) -> u16 {
394        self.text_width() + self.hotkey_width()
395    }
396
397    /// Inherent height
398    pub fn height(&self) -> u16 {
399        1
400    }
401}
402
403impl<'a> StatefulWidget for &Caption<'a> {
404    type State = CaptionState;
405
406    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
407        render_ref(self, area, buf, state);
408    }
409}
410
411impl<'a> StatefulWidget for Caption<'a> {
412    type State = CaptionState;
413
414    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
415        render_ref(&self, area, buf, state);
416    }
417}
418
419fn render_ref(widget: &Caption<'_>, area: Rect, buf: &mut Buffer, state: &mut CaptionState) {
420    state.area = area;
421
422    if widget.navchar.is_some() {
423        state.navchar = widget.navchar;
424    }
425    if widget.hotkey.is_some() {
426        state.hotkey = widget.hotkey;
427    }
428    if let Some(linked) = &widget.linked {
429        state.linked = linked.clone();
430    }
431
432    let mut prepend = String::default();
433    let mut append = String::default();
434
435    // styles
436    let style = if state.linked.is_focused() {
437        if let Some(focus_style) = widget.focus_style {
438            focus_style
439        } else {
440            revert_style(widget.style)
441        }
442    } else {
443        widget.style
444    };
445
446    let mut highlight_style = if let Some(highlight_style) = widget.highlight_style {
447        highlight_style
448    } else {
449        Style::new().underlined()
450    };
451    if let Some(hover_style) = widget.hover_style {
452        if state.mouse.hover.get() {
453            highlight_style = highlight_style.patch(hover_style);
454        }
455    }
456    highlight_style = style.patch(highlight_style);
457
458    let mut hotkey_style = widget.hotkey_style.unwrap_or_default();
459    if let Some(hover_style) = widget.hover_style {
460        if state.mouse.hover.get() {
461            hotkey_style = hotkey_style.patch(hover_style);
462        }
463    }
464    hotkey_style = style.patch(hotkey_style);
465
466    // layout
467
468    let hotkey_text = if widget.hotkey_policy == HotkeyPolicy::WhenFocused && state.linked.get()
469        || widget.hotkey_policy == HotkeyPolicy::OnHover && state.mouse.hover.get()
470        || widget.hotkey_policy == HotkeyPolicy::Always
471    {
472        widget.hotkey_text.as_ref()
473    } else {
474        ""
475    };
476
477    if !hotkey_text.is_empty() && widget.spacing > 0 {
478        match widget.hotkey_align {
479            HotkeyAlignment::LabelHotkey => {
480                append = " ".repeat(widget.spacing as usize);
481            }
482            HotkeyAlignment::HotkeyLabel => {
483                prepend = " ".repeat(widget.spacing as usize);
484            }
485        }
486    }
487
488    let text_line = match widget.hotkey_align {
489        HotkeyAlignment::LabelHotkey => {
490            if let Some(highlight) = widget.highlight.clone() {
491                Line::from_iter([
492                    Span::from(&widget.text[..highlight.start - 1]), // account for _
493                    Span::from(&widget.text[highlight.start..highlight.end]).style(highlight_style),
494                    Span::from(&widget.text[highlight.end..]),
495                    Span::from(append),
496                    Span::from(hotkey_text).style(hotkey_style),
497                ])
498            } else {
499                Line::from_iter([
500                    Span::from(widget.text.as_ref()), //
501                    Span::from(append),
502                    Span::from(hotkey_text).style(hotkey_style),
503                ])
504            }
505        }
506        HotkeyAlignment::HotkeyLabel => {
507            if let Some(highlight) = widget.highlight.clone() {
508                Line::from_iter([
509                    Span::from(hotkey_text).style(hotkey_style),
510                    Span::from(prepend),
511                    Span::from(&widget.text[..highlight.start - 1]), // account for _
512                    Span::from(&widget.text[highlight.start..highlight.end]).style(highlight_style),
513                    Span::from(&widget.text[highlight.end..]),
514                ])
515            } else {
516                Line::from_iter([
517                    Span::from(hotkey_text).style(hotkey_style),
518                    Span::from(prepend), //
519                    Span::from(widget.text.as_ref()),
520                ])
521            }
522        }
523    };
524    text_line
525        .alignment(widget.align) //
526        .style(style)
527        .render(state.area, buf);
528}
529
530impl Default for CaptionStyle {
531    fn default() -> Self {
532        Self {
533            style: Default::default(),
534            hover: Default::default(),
535            highlight: Default::default(),
536            hotkey: Default::default(),
537            focus: Default::default(),
538            align: Default::default(),
539            hotkey_align: Default::default(),
540            hotkey_policy: Default::default(),
541            spacing: Default::default(),
542            non_exhaustive: NonExhaustive,
543        }
544    }
545}
546
547impl Clone for CaptionState {
548    fn clone(&self) -> Self {
549        Self {
550            area: self.area,
551            navchar: self.navchar,
552            hotkey: self.hotkey,
553            linked: self.linked.clone(),
554            mouse: Default::default(),
555            non_exhaustive: NonExhaustive,
556        }
557    }
558}
559
560impl Default for CaptionState {
561    fn default() -> Self {
562        Self {
563            area: Default::default(),
564            navchar: Default::default(),
565            hotkey: Default::default(),
566            linked: Default::default(),
567            mouse: Default::default(),
568            non_exhaustive: NonExhaustive,
569        }
570    }
571}
572
573impl CaptionState {
574    pub fn new() -> Self {
575        Self::default()
576    }
577
578    pub fn navchar(&self) -> Option<char> {
579        self.navchar
580    }
581
582    pub fn set_navchar(&mut self, navchar: Option<char>) {
583        self.navchar = navchar;
584    }
585
586    pub fn hotkey(&self) -> Option<crossterm::event::KeyEvent> {
587        self.hotkey
588    }
589
590    pub fn set_hotkey(&mut self, hotkey: Option<crossterm::event::KeyEvent>) {
591        self.hotkey = hotkey;
592    }
593
594    pub fn linked(&self) -> FocusFlag {
595        self.linked.clone()
596    }
597
598    pub fn set_linked(&mut self, linked: FocusFlag) {
599        self.linked = linked;
600    }
601}
602
603impl RelocatableState for CaptionState {
604    fn relocate(&mut self, shift: (i16, i16), clip: Rect) {
605        self.area = relocate_area(self.area, shift, clip);
606    }
607}
608
609impl<'a> HandleEvent<crossterm::event::Event, &'a Focus, Outcome> for CaptionState {
610    fn handle(&mut self, event: &crossterm::event::Event, focus: &'a Focus) -> Outcome {
611        if let Some(navchar) = self.navchar {
612            if let crossterm::event::Event::Key(crossterm::event::KeyEvent {
613                code: crossterm::event::KeyCode::Char(test),
614                modifiers: crossterm::event::KeyModifiers::ALT,
615                kind: crossterm::event::KeyEventKind::Release,
616                ..
617            }) = event
618            {
619                if navchar == *test {
620                    focus.focus(&self.linked);
621                    return Outcome::Changed;
622                }
623            }
624        }
625        if let Some(hotkey) = self.hotkey {
626            if let crossterm::event::Event::Key(crossterm::event::KeyEvent {
627                code,
628                modifiers,
629                kind,
630                ..
631            }) = event
632            {
633                if hotkey.code == *code && hotkey.modifiers == *modifiers && hotkey.kind == *kind {
634                    focus.focus(&self.linked);
635                    return Outcome::Changed;
636                }
637            }
638        }
639
640        // no separate mouse-handler, isok
641        match event {
642            ct_event!(mouse any for m) if self.mouse.hover(self.area, m) => {
643                return Outcome::Changed;
644            }
645            ct_event!(mouse down Left for x,y) if self.area.contains((*x, *y).into()) => {
646                focus.focus(&self.linked);
647                return Outcome::Changed;
648            }
649            _ => {}
650        }
651
652        Outcome::Continue
653    }
654}
655
656/// Handle all events. for a Caption.
657///
658/// This additionally requires a valid Focus instance to handle
659/// the hot-keys.
660pub fn handle_events(
661    state: &mut CaptionState,
662    focus: &Focus,
663    event: &crossterm::event::Event,
664) -> Outcome {
665    HandleEvent::handle(state, event, focus)
666}