Skip to main content

ftui_style/
interactive.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Interactive style variants for stateful widgets.
3//!
4//! [`InteractiveStyle`] holds style overrides for different interaction states:
5//! normal, hovered, focused, active (pressed), and disabled. When resolving
6//! the current style, the appropriate variant is merged on top of the base
7//! style using [`Style::patch`].
8//!
9//! # Migration rationale
10//!
11//! Web CSS uses pseudo-classes (`:hover`, `:focus`, `:active`, `:disabled`)
12//! to apply conditional styles. This module provides an equivalent
13//! terminal-native model that the migration code emitter can target.
14//!
15//! # Example
16//!
17//! ```
18//! use ftui_style::Style;
19//! use ftui_style::interactive::{InteractiveStyle, InteractionState};
20//! use ftui_render::cell::PackedRgba;
21//!
22//! let interactive = InteractiveStyle::new(
23//!     Style::new().fg(PackedRgba::WHITE).bg(PackedRgba::rgb(64, 64, 64)),
24//! )
25//! .hover(Style::new().bg(PackedRgba::rgb(128, 128, 128)))
26//! .focused(Style::new().bg(PackedRgba::BLUE))
27//! .disabled(Style::new().fg(PackedRgba::rgb(64, 64, 64)));
28//!
29//! // Resolve for the current state
30//! let current = interactive.resolve(InteractionState::Hovered);
31//! ```
32
33#![forbid(unsafe_code)]
34
35use crate::style::Style;
36
37/// The interaction state of a widget.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum InteractionState {
40    /// Default state — no user interaction.
41    Normal,
42    /// Mouse cursor is over the widget.
43    Hovered,
44    /// Widget has keyboard focus.
45    Focused,
46    /// Widget is being pressed/activated.
47    Active,
48    /// Widget is non-interactive.
49    Disabled,
50    /// Widget has both focus and hover.
51    FocusedHovered,
52}
53
54/// Style variants for different interaction states.
55///
56/// Each variant is an optional [`Style`] overlay. When resolving, the variant
57/// for the current state is merged on top of the `normal` base style using
58/// [`Style::patch`], preserving CSS-like specificity: the more specific state
59/// wins for any property it sets.
60#[derive(Debug, Clone, PartialEq)]
61pub struct InteractiveStyle {
62    /// Base style applied in all states.
63    pub normal: Style,
64    /// Override applied when hovered.
65    pub hover: Option<Style>,
66    /// Override applied when focused.
67    pub focus: Option<Style>,
68    /// Override applied when active (pressed).
69    pub active: Option<Style>,
70    /// Override applied when disabled.
71    pub disabled: Option<Style>,
72}
73
74impl InteractiveStyle {
75    /// Create an interactive style with the given base style.
76    pub fn new(normal: Style) -> Self {
77        Self {
78            normal,
79            hover: None,
80            focus: None,
81            active: None,
82            disabled: None,
83        }
84    }
85
86    /// Set the hover style override.
87    #[must_use]
88    pub fn hover(mut self, style: Style) -> Self {
89        self.hover = Some(style);
90        self
91    }
92
93    /// Set the focus style override.
94    #[must_use]
95    pub fn focused(mut self, style: Style) -> Self {
96        self.focus = Some(style);
97        self
98    }
99
100    /// Set the active (pressed) style override.
101    #[must_use]
102    pub fn active(mut self, style: Style) -> Self {
103        self.active = Some(style);
104        self
105    }
106
107    /// Set the disabled style override.
108    #[must_use]
109    pub fn disabled(mut self, style: Style) -> Self {
110        self.disabled = Some(style);
111        self
112    }
113
114    /// Resolve the style for the given interaction state.
115    ///
116    /// Starts with `normal` and patches the state-specific override on top.
117    /// For `FocusedHovered`, both focus and hover are applied (focus first,
118    /// then hover, so hover wins for conflicting properties).
119    pub fn resolve(&self, state: InteractionState) -> Style {
120        let base = self.normal;
121        match state {
122            InteractionState::Normal => base,
123            InteractionState::Hovered => {
124                if let Some(h) = &self.hover {
125                    base.patch(h)
126                } else {
127                    base
128                }
129            }
130            InteractionState::Focused => {
131                if let Some(f) = &self.focus {
132                    base.patch(f)
133                } else {
134                    base
135                }
136            }
137            InteractionState::Active => {
138                if let Some(a) = &self.active {
139                    base.patch(a)
140                } else {
141                    base
142                }
143            }
144            InteractionState::Disabled => {
145                if let Some(d) = &self.disabled {
146                    base.patch(d)
147                } else {
148                    base
149                }
150            }
151            InteractionState::FocusedHovered => {
152                let mut result = base;
153                if let Some(f) = &self.focus {
154                    result = result.patch(f);
155                }
156                if let Some(h) = &self.hover {
157                    result = result.patch(h);
158                }
159                result
160            }
161        }
162    }
163
164    /// Check whether the given state has a specific override.
165    pub fn has_override(&self, state: InteractionState) -> bool {
166        match state {
167            InteractionState::Normal => true,
168            InteractionState::Hovered => self.hover.is_some(),
169            InteractionState::Focused => self.focus.is_some(),
170            InteractionState::Active => self.active.is_some(),
171            InteractionState::Disabled => self.disabled.is_some(),
172            InteractionState::FocusedHovered => self.focus.is_some() || self.hover.is_some(),
173        }
174    }
175}
176
177impl Default for InteractiveStyle {
178    fn default() -> Self {
179        Self::new(Style::new())
180    }
181}
182
183impl From<Style> for InteractiveStyle {
184    fn from(style: Style) -> Self {
185        Self::new(style)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use ftui_render::cell::PackedRgba;
193
194    const WHITE: PackedRgba = PackedRgba::WHITE;
195    const BLACK: PackedRgba = PackedRgba::BLACK;
196    const BLUE: PackedRgba = PackedRgba::BLUE;
197    const RED: PackedRgba = PackedRgba::RED;
198    const YELLOW: PackedRgba = PackedRgba::rgb(255, 255, 0);
199    const GRAY: PackedRgba = PackedRgba::rgb(128, 128, 128);
200    const DARK_GRAY: PackedRgba = PackedRgba::rgb(64, 64, 64);
201
202    #[test]
203    fn normal_returns_base_style() {
204        let style = InteractiveStyle::new(Style::new().fg(WHITE));
205        let resolved = style.resolve(InteractionState::Normal);
206        assert_eq!(resolved.fg, Some(WHITE));
207    }
208
209    #[test]
210    fn hover_patches_over_base() {
211        let style =
212            InteractiveStyle::new(Style::new().fg(WHITE).bg(BLACK)).hover(Style::new().bg(GRAY));
213        let resolved = style.resolve(InteractionState::Hovered);
214        assert_eq!(resolved.fg, Some(WHITE)); // inherited from base
215        assert_eq!(resolved.bg, Some(GRAY)); // overridden by hover
216    }
217
218    #[test]
219    fn hover_without_override_returns_base() {
220        let style = InteractiveStyle::new(Style::new().fg(WHITE));
221        let resolved = style.resolve(InteractionState::Hovered);
222        assert_eq!(resolved.fg, Some(WHITE));
223    }
224
225    #[test]
226    fn focus_patches_over_base() {
227        let style = InteractiveStyle::new(Style::new().fg(WHITE)).focused(Style::new().fg(BLUE));
228        let resolved = style.resolve(InteractionState::Focused);
229        assert_eq!(resolved.fg, Some(BLUE));
230    }
231
232    #[test]
233    fn active_patches_over_base() {
234        let style = InteractiveStyle::new(Style::new().fg(WHITE)).active(Style::new().fg(RED));
235        let resolved = style.resolve(InteractionState::Active);
236        assert_eq!(resolved.fg, Some(RED));
237    }
238
239    #[test]
240    fn disabled_patches_over_base() {
241        let style =
242            InteractiveStyle::new(Style::new().fg(WHITE)).disabled(Style::new().fg(DARK_GRAY));
243        let resolved = style.resolve(InteractionState::Disabled);
244        assert_eq!(resolved.fg, Some(DARK_GRAY));
245    }
246
247    #[test]
248    fn focused_hovered_applies_both() {
249        let style = InteractiveStyle::new(Style::new().fg(WHITE).bg(BLACK))
250            .focused(Style::new().fg(BLUE))
251            .hover(Style::new().bg(GRAY));
252        let resolved = style.resolve(InteractionState::FocusedHovered);
253        assert_eq!(resolved.bg, Some(GRAY)); // hover patches last
254        assert_eq!(resolved.fg, Some(BLUE)); // focus sets fg, hover doesn't override
255    }
256
257    #[test]
258    fn focused_hovered_hover_overrides_focus_on_conflict() {
259        let style = InteractiveStyle::new(Style::new())
260            .focused(Style::new().fg(BLUE))
261            .hover(Style::new().fg(RED));
262        let resolved = style.resolve(InteractionState::FocusedHovered);
263        assert_eq!(resolved.fg, Some(RED)); // hover applied last
264    }
265
266    #[test]
267    fn has_override_reports_correctly() {
268        let style = InteractiveStyle::new(Style::new()).hover(Style::new().fg(RED));
269        assert!(style.has_override(InteractionState::Normal));
270        assert!(style.has_override(InteractionState::Hovered));
271        assert!(!style.has_override(InteractionState::Focused));
272        assert!(!style.has_override(InteractionState::Active));
273        assert!(!style.has_override(InteractionState::Disabled));
274        assert!(style.has_override(InteractionState::FocusedHovered)); // hover exists
275    }
276
277    #[test]
278    fn default_has_no_overrides() {
279        let style = InteractiveStyle::default();
280        assert!(!style.has_override(InteractionState::Hovered));
281        assert!(!style.has_override(InteractionState::Focused));
282        assert!(!style.has_override(InteractionState::Active));
283        assert!(!style.has_override(InteractionState::Disabled));
284    }
285
286    #[test]
287    fn from_style_creates_normal_only() {
288        let style: InteractiveStyle = Style::new().fg(WHITE).into();
289        assert_eq!(style.normal.fg, Some(WHITE));
290        assert!(style.hover.is_none());
291        assert!(style.focus.is_none());
292    }
293
294    #[test]
295    fn all_states_set() {
296        let style = InteractiveStyle::new(Style::new().fg(WHITE))
297            .hover(Style::new().fg(YELLOW))
298            .focused(Style::new().fg(BLUE))
299            .active(Style::new().fg(RED))
300            .disabled(Style::new().fg(DARK_GRAY));
301
302        assert_eq!(style.resolve(InteractionState::Normal).fg, Some(WHITE));
303        assert_eq!(style.resolve(InteractionState::Hovered).fg, Some(YELLOW));
304        assert_eq!(style.resolve(InteractionState::Focused).fg, Some(BLUE));
305        assert_eq!(style.resolve(InteractionState::Active).fg, Some(RED));
306        assert_eq!(
307            style.resolve(InteractionState::Disabled).fg,
308            Some(DARK_GRAY)
309        );
310    }
311
312    #[test]
313    fn debug_impl_works() {
314        let style = InteractiveStyle::default();
315        let _ = format!("{style:?}");
316    }
317
318    #[test]
319    fn interaction_state_eq_and_clone() {
320        let state = InteractionState::Hovered;
321        let cloned = state;
322        assert_eq!(state, cloned);
323    }
324}