Skip to main content

ftui_widgets/focus/
indicator.rs

1#![forbid(unsafe_code)]
2
3//! Focus indicator styling for visually identifying the focused widget.
4//!
5//! [`FocusIndicator`] describes how a focused widget should render its
6//! focus cue. Widgets query the indicator to apply a focus style overlay,
7//! border highlight, or underline to their focused state.
8//!
9//! # Usage
10//!
11//! ```rust
12//! use ftui_widgets::focus::FocusIndicator;
13//! use ftui_style::Style;
14//!
15//! // Default: reverse video on focused element
16//! let indicator = FocusIndicator::default();
17//!
18//! // Custom: blue underline with bold
19//! let indicator = FocusIndicator::underline()
20//!     .with_style(Style::new().bold());
21//! ```
22
23use ftui_style::Style;
24
25/// The kind of visual cue used to indicate focus.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
27pub enum FocusIndicatorKind {
28    /// Apply a style overlay (e.g., reverse video) to the focused widget.
29    #[default]
30    StyleOverlay,
31    /// Draw an underline on the focused widget's content.
32    Underline,
33    /// Highlight the border of the focused widget's block.
34    Border,
35    /// No visual indicator (focus is tracked but not shown).
36    None,
37}
38
39/// Configuration for how focused widgets render their focus cue.
40///
41/// Combines a [`FocusIndicatorKind`] (what to draw) with a [`Style`]
42/// (how to draw it). Widgets call [`style`](FocusIndicator::style) to
43/// get the overlay style and [`kind`](FocusIndicator::kind) to decide
44/// the rendering strategy.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46pub struct FocusIndicator {
47    kind: FocusIndicatorKind,
48    style: Style,
49}
50
51impl Default for FocusIndicator {
52    /// Default focus indicator: reverse video overlay.
53    fn default() -> Self {
54        Self {
55            kind: FocusIndicatorKind::StyleOverlay,
56            style: Style::new().reverse(),
57        }
58    }
59}
60
61impl FocusIndicator {
62    /// Create a focus indicator with a custom style overlay.
63    #[must_use]
64    pub fn style_overlay(style: Style) -> Self {
65        Self {
66            kind: FocusIndicatorKind::StyleOverlay,
67            style,
68        }
69    }
70
71    /// Create a focus indicator that underlines focused content.
72    #[must_use]
73    pub fn underline() -> Self {
74        Self {
75            kind: FocusIndicatorKind::Underline,
76            style: Style::new().underline(),
77        }
78    }
79
80    /// Create a focus indicator that highlights the widget border.
81    #[must_use]
82    pub fn border() -> Self {
83        Self {
84            kind: FocusIndicatorKind::Border,
85            style: Style::new().bold(),
86        }
87    }
88
89    /// Create a focus indicator with no visual cue.
90    #[must_use]
91    pub fn none() -> Self {
92        Self {
93            kind: FocusIndicatorKind::None,
94            style: Style::new(),
95        }
96    }
97
98    /// Set the style for this indicator.
99    #[must_use]
100    pub fn with_style(mut self, style: Style) -> Self {
101        self.style = style;
102        self
103    }
104
105    /// Set the kind of indicator.
106    #[must_use]
107    pub fn with_kind(mut self, kind: FocusIndicatorKind) -> Self {
108        self.kind = kind;
109        self
110    }
111
112    /// Get the indicator kind.
113    #[inline]
114    #[must_use]
115    pub fn kind(&self) -> FocusIndicatorKind {
116        self.kind
117    }
118
119    /// Get the focus style to apply.
120    #[inline]
121    #[must_use]
122    pub fn style(&self) -> Style {
123        self.style
124    }
125
126    /// Check if this indicator has a visible focus cue.
127    #[inline]
128    #[must_use]
129    pub fn is_visible(&self) -> bool {
130        self.kind != FocusIndicatorKind::None
131    }
132
133    /// Apply the focus style as an overlay on the given base style.
134    ///
135    /// The focus style's set properties override the base; unset
136    /// properties fall through from the base.
137    #[must_use]
138    pub fn apply_to(&self, base: Style) -> Style {
139        if self.kind == FocusIndicatorKind::None {
140            return base;
141        }
142        self.style.merge(&base)
143    }
144}
145
146// =========================================================================
147// Tests
148// =========================================================================
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use ftui_render::cell::PackedRgba;
154
155    #[test]
156    fn default_is_reverse_overlay() {
157        let ind = FocusIndicator::default();
158        assert_eq!(ind.kind(), FocusIndicatorKind::StyleOverlay);
159        assert!(ind.is_visible());
160    }
161
162    #[test]
163    fn underline_indicator() {
164        let ind = FocusIndicator::underline();
165        assert_eq!(ind.kind(), FocusIndicatorKind::Underline);
166        assert!(ind.is_visible());
167    }
168
169    #[test]
170    fn border_indicator() {
171        let ind = FocusIndicator::border();
172        assert_eq!(ind.kind(), FocusIndicatorKind::Border);
173        assert!(ind.is_visible());
174    }
175
176    #[test]
177    fn none_indicator_not_visible() {
178        let ind = FocusIndicator::none();
179        assert_eq!(ind.kind(), FocusIndicatorKind::None);
180        assert!(!ind.is_visible());
181    }
182
183    #[test]
184    fn with_style_builder() {
185        let style = Style::new().bold().italic();
186        let ind = FocusIndicator::underline().with_style(style);
187        assert_eq!(ind.style(), style);
188        assert_eq!(ind.kind(), FocusIndicatorKind::Underline);
189    }
190
191    #[test]
192    fn with_kind_builder() {
193        let ind = FocusIndicator::default().with_kind(FocusIndicatorKind::Border);
194        assert_eq!(ind.kind(), FocusIndicatorKind::Border);
195    }
196
197    #[test]
198    fn apply_to_merges_styles() {
199        let base = Style::new().fg(PackedRgba::rgb(255, 0, 0));
200        let ind = FocusIndicator::style_overlay(Style::new().bold());
201        let result = ind.apply_to(base);
202        // Should have fg from base and bold from focus
203        assert_eq!(result.fg, Some(PackedRgba::rgb(255, 0, 0)));
204    }
205
206    #[test]
207    fn apply_to_none_returns_base() {
208        let base = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
209        let ind = FocusIndicator::none();
210        let result = ind.apply_to(base);
211        assert_eq!(result, base);
212    }
213
214    #[test]
215    fn style_overlay_constructor() {
216        let style = Style::new().italic();
217        let ind = FocusIndicator::style_overlay(style);
218        assert_eq!(ind.kind(), FocusIndicatorKind::StyleOverlay);
219        assert_eq!(ind.style(), style);
220    }
221}