ftui_style/
interactive.rs1#![forbid(unsafe_code)]
34
35use crate::style::Style;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum InteractionState {
40 Normal,
42 Hovered,
44 Focused,
46 Active,
48 Disabled,
50 FocusedHovered,
52}
53
54#[derive(Debug, Clone, PartialEq)]
61pub struct InteractiveStyle {
62 pub normal: Style,
64 pub hover: Option<Style>,
66 pub focus: Option<Style>,
68 pub active: Option<Style>,
70 pub disabled: Option<Style>,
72}
73
74impl InteractiveStyle {
75 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 #[must_use]
88 pub fn hover(mut self, style: Style) -> Self {
89 self.hover = Some(style);
90 self
91 }
92
93 #[must_use]
95 pub fn focused(mut self, style: Style) -> Self {
96 self.focus = Some(style);
97 self
98 }
99
100 #[must_use]
102 pub fn active(mut self, style: Style) -> Self {
103 self.active = Some(style);
104 self
105 }
106
107 #[must_use]
109 pub fn disabled(mut self, style: Style) -> Self {
110 self.disabled = Some(style);
111 self
112 }
113
114 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 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)); assert_eq!(resolved.bg, Some(GRAY)); }
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)); assert_eq!(resolved.fg, Some(BLUE)); }
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)); }
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)); }
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}