Skip to main content

ftui_widgets/
validation_error.rs

1#![forbid(unsafe_code)]
2
3//! Inline validation error display widget.
4//!
5//! Displays validation errors near form fields with:
6//! - Configurable error styling (default: red text with icon)
7//! - Smooth appearance animation via opacity interpolation
8//! - Screen reader accessibility (ARIA error association via semantic info)
9//!
10//! # Example
11//!
12//! ```ignore
13//! use ftui_widgets::{ValidationErrorDisplay, ValidationErrorState};
14//!
15//! // Simple inline error
16//! let error = ValidationErrorDisplay::new("This field is required");
17//! let mut state = ValidationErrorState::default();
18//! error.render(area, &mut frame, &mut state);
19//!
20//! // With custom styling
21//! let error = ValidationErrorDisplay::new("Invalid email address")
22//!     .with_icon("!")
23//!     .with_style(Style::new().fg(PackedRgba::rgb(255, 100, 100)));
24//! ```
25//!
26//! # Invariants (Alien Artifact)
27//!
28//! 1. **Icon presence**: Icon is always rendered when error is visible
29//! 2. **Animation bounds**: Opacity is clamped to [0.0, 1.0]
30//! 3. **Width calculation**: Total width = icon_width + 1 + message_width
31//! 4. **Accessibility**: When rendered, screen readers can announce error text
32//!
33//! # Failure Modes
34//!
35//! | Scenario | Behavior |
36//! |----------|----------|
37//! | Empty message | Renders icon only |
38//! | Zero-width area | No-op, state unchanged |
39//! | Very narrow area | Truncates message with ellipsis |
40//! | Animation overflow | Saturates at 0.0 or 1.0 |
41
42use std::time::{Duration, Instant};
43
44use ftui_core::geometry::Rect;
45use ftui_render::cell::PackedRgba;
46use ftui_render::frame::Frame;
47use ftui_style::Style;
48use ftui_text::{display_width, grapheme_width};
49
50use crate::{StatefulWidget, Widget, draw_text_span};
51
52// ---------------------------------------------------------------------------
53// Constants
54// ---------------------------------------------------------------------------
55
56/// Default error foreground color (red).
57pub const ERROR_FG_DEFAULT: PackedRgba = PackedRgba::rgb(220, 60, 60);
58
59/// Default error background color (dark red).
60pub const ERROR_BG_DEFAULT: PackedRgba = PackedRgba::rgb(40, 0, 0);
61
62/// Default error icon.
63pub const ERROR_ICON_DEFAULT: &str = "⚠";
64
65/// Default animation duration.
66pub const ANIMATION_DURATION_MS: u64 = 150;
67
68// ---------------------------------------------------------------------------
69// ValidationErrorDisplay
70// ---------------------------------------------------------------------------
71
72/// A widget for displaying inline validation errors.
73///
74/// Renders an error message with an icon, styled for visibility and
75/// accessibility. Supports smooth appearance/disappearance animations.
76#[derive(Debug, Clone)]
77pub struct ValidationErrorDisplay {
78    /// The error message to display.
79    message: String,
80    /// Optional error code (for programmatic handling).
81    error_code: Option<&'static str>,
82    /// Icon to show before the message.
83    icon: String,
84    /// Style for the error text.
85    style: Style,
86    /// Style for the icon.
87    icon_style: Style,
88    /// Animation duration for appearance.
89    animation_duration: Duration,
90    /// Whether to show the message (vs icon only when narrow).
91    show_message: bool,
92}
93
94impl Default for ValidationErrorDisplay {
95    fn default() -> Self {
96        Self {
97            message: String::new(),
98            error_code: None,
99            icon: ERROR_ICON_DEFAULT.to_string(),
100            style: Style::new().fg(ERROR_FG_DEFAULT),
101            icon_style: Style::new().fg(ERROR_FG_DEFAULT),
102            animation_duration: Duration::from_millis(ANIMATION_DURATION_MS),
103            show_message: true,
104        }
105    }
106}
107
108impl ValidationErrorDisplay {
109    /// Create a new validation error display with the given message.
110    #[must_use]
111    pub fn new(message: impl Into<String>) -> Self {
112        Self {
113            message: message.into(),
114            ..Default::default()
115        }
116    }
117
118    /// Create from an error code and message.
119    #[must_use]
120    pub fn with_code(error_code: &'static str, message: impl Into<String>) -> Self {
121        Self {
122            message: message.into(),
123            error_code: Some(error_code),
124            ..Default::default()
125        }
126    }
127
128    /// Set a custom icon (default: "⚠").
129    #[must_use]
130    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
131        self.icon = icon.into();
132        self
133    }
134
135    /// Set the error text style.
136    #[must_use]
137    pub fn with_style(mut self, style: Style) -> Self {
138        self.style = style;
139        self
140    }
141
142    /// Set the icon style.
143    #[must_use]
144    pub fn with_icon_style(mut self, style: Style) -> Self {
145        self.icon_style = style;
146        self
147    }
148
149    /// Set the animation duration.
150    #[must_use]
151    pub fn with_animation_duration(mut self, duration: Duration) -> Self {
152        self.animation_duration = duration;
153        self
154    }
155
156    /// Disable message display (icon only).
157    #[must_use]
158    pub fn icon_only(mut self) -> Self {
159        self.show_message = false;
160        self
161    }
162
163    /// Get the error message.
164    #[must_use]
165    pub fn message(&self) -> &str {
166        &self.message
167    }
168
169    /// Get the error code, if any.
170    #[must_use]
171    pub fn error_code(&self) -> Option<&'static str> {
172        self.error_code
173    }
174
175    /// Calculate the minimum width needed to display the error.
176    #[must_use]
177    pub fn min_width(&self) -> u16 {
178        let icon_width = display_width(self.icon.as_str()) as u16;
179        if self.show_message && !self.message.is_empty() {
180            let msg_width = display_width(self.message.as_str()) as u16;
181            icon_width.saturating_add(1).saturating_add(msg_width)
182        } else {
183            icon_width
184        }
185    }
186}
187
188// ---------------------------------------------------------------------------
189// ValidationErrorState
190// ---------------------------------------------------------------------------
191
192/// State for validation error animation and accessibility.
193#[derive(Debug, Clone)]
194pub struct ValidationErrorState {
195    /// Whether the error is currently visible.
196    visible: bool,
197    /// Animation start time.
198    animation_start: Option<Instant>,
199    /// Current opacity (0.0 = hidden, 1.0 = fully visible).
200    opacity: f32,
201    /// Whether the error was just shown (for screen reader announcement).
202    just_shown: bool,
203    /// Unique ID for ARIA association.
204    aria_id: u32,
205}
206
207impl Default for ValidationErrorState {
208    fn default() -> Self {
209        Self {
210            visible: false,
211            animation_start: None,
212            opacity: 0.0,
213            just_shown: false,
214            aria_id: 0,
215        }
216    }
217}
218
219impl ValidationErrorState {
220    /// Create a new state with the given ARIA ID.
221    #[must_use]
222    pub fn with_aria_id(mut self, id: u32) -> Self {
223        self.aria_id = id;
224        self
225    }
226
227    /// Show the error (triggers animation).
228    pub fn show(&mut self) {
229        if !self.visible {
230            self.visible = true;
231            self.animation_start = Some(Instant::now());
232            self.just_shown = true;
233        }
234    }
235
236    /// Hide the error (triggers fade-out animation).
237    pub fn hide(&mut self) {
238        if self.visible {
239            self.visible = false;
240            self.animation_start = Some(Instant::now());
241        }
242    }
243
244    /// Set visibility directly (for immediate show/hide).
245    pub fn set_visible(&mut self, visible: bool) {
246        if visible {
247            self.show();
248        } else {
249            self.hide();
250        }
251    }
252
253    /// Check if the error is currently visible.
254    #[must_use]
255    pub fn is_visible(&self) -> bool {
256        self.visible
257    }
258
259    /// Check if the error is fully visible (animation complete).
260    #[must_use]
261    pub fn is_fully_visible(&self) -> bool {
262        self.visible && self.opacity >= 1.0
263    }
264
265    /// Get the current opacity.
266    #[must_use]
267    pub fn opacity(&self) -> f32 {
268        self.opacity
269    }
270
271    /// Check and clear the "just shown" flag (for screen reader announcements).
272    pub fn take_just_shown(&mut self) -> bool {
273        std::mem::take(&mut self.just_shown)
274    }
275
276    /// Get the ARIA ID for accessibility association.
277    #[must_use]
278    pub fn aria_id(&self) -> u32 {
279        self.aria_id
280    }
281
282    /// Update animation state. Call this each frame.
283    pub fn tick(&mut self, animation_duration: Duration) {
284        if let Some(start) = self.animation_start {
285            let elapsed = start.elapsed();
286            let progress = if animation_duration.is_zero() {
287                1.0
288            } else {
289                (elapsed.as_secs_f32() / animation_duration.as_secs_f32()).clamp(0.0, 1.0)
290            };
291
292            if self.visible {
293                // Fade in
294                self.opacity = progress;
295            } else {
296                // Fade out
297                self.opacity = 1.0 - progress;
298            }
299
300            // Animation complete
301            if progress >= 1.0 {
302                self.animation_start = None;
303                self.opacity = if self.visible { 1.0 } else { 0.0 };
304            }
305        }
306    }
307
308    /// Check if an animation is currently in progress.
309    #[must_use]
310    pub fn is_animating(&self) -> bool {
311        self.animation_start.is_some()
312    }
313}
314
315// ---------------------------------------------------------------------------
316// Widget Implementation
317// ---------------------------------------------------------------------------
318
319impl StatefulWidget for ValidationErrorDisplay {
320    type State = ValidationErrorState;
321
322    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
323        #[cfg(feature = "tracing")]
324        let _span = tracing::debug_span!(
325            "widget_render",
326            widget = "ValidationErrorDisplay",
327            x = area.x,
328            y = area.y,
329            w = area.width,
330            h = area.height
331        )
332        .entered();
333
334        if area.is_empty() || area.height < 1 {
335            return;
336        }
337
338        // Update animation
339        state.tick(self.animation_duration);
340
341        // Skip rendering if fully invisible
342        if state.opacity <= 0.0 && !state.visible {
343            return;
344        }
345
346        let deg = frame.buffer.degradation;
347
348        // Calculate effective opacity for styling
349        let effective_opacity = (state.opacity * 255.0) as u8;
350
351        // Adjust style with opacity
352        let icon_style = if deg.apply_styling() && effective_opacity < 255 {
353            let fg = self.icon_style.fg.unwrap_or(ERROR_FG_DEFAULT);
354            Style::new().fg(PackedRgba::rgba(fg.r(), fg.g(), fg.b(), effective_opacity))
355        } else if deg.apply_styling() {
356            self.icon_style
357        } else {
358            Style::default()
359        };
360
361        let text_style = if deg.apply_styling() && effective_opacity < 255 {
362            let fg = self.style.fg.unwrap_or(ERROR_FG_DEFAULT);
363            Style::new().fg(PackedRgba::rgba(fg.r(), fg.g(), fg.b(), effective_opacity))
364        } else if deg.apply_styling() {
365            self.style
366        } else {
367            Style::default()
368        };
369
370        // Draw icon
371        let y = area.y;
372        let mut x = area.x;
373        let max_x = area.right();
374
375        x = draw_text_span(frame, x, y, &self.icon, icon_style, max_x);
376
377        // Draw space separator
378        if x < max_x && self.show_message && !self.message.is_empty() {
379            x = x.saturating_add(1);
380
381            // Draw message (truncate with ellipsis if needed)
382            let remaining_width = max_x.saturating_sub(x) as usize;
383            let msg_width = display_width(self.message.as_str());
384
385            if msg_width <= remaining_width {
386                draw_text_span(frame, x, y, &self.message, text_style, max_x);
387            } else if remaining_width >= 4 {
388                // Truncate with ellipsis
389                let mut truncated = String::new();
390                let mut w = 0;
391                let limit = remaining_width.saturating_sub(1); // Leave room for "…"
392
393                for grapheme in unicode_segmentation::UnicodeSegmentation::graphemes(
394                    self.message.as_str(),
395                    true,
396                ) {
397                    let gw = grapheme_width(grapheme);
398                    if w + gw > limit {
399                        break;
400                    }
401                    truncated.push_str(grapheme);
402                    w += gw;
403                }
404                truncated.push('…');
405
406                draw_text_span(frame, x, y, &truncated, text_style, max_x);
407            } else if remaining_width >= 1 {
408                // Just ellipsis
409                draw_text_span(frame, x, y, "…", text_style, max_x);
410            }
411        }
412    }
413}
414
415impl Widget for ValidationErrorDisplay {
416    fn render(&self, area: Rect, frame: &mut Frame) {
417        let mut state = ValidationErrorState {
418            visible: true,
419            opacity: 1.0,
420            ..Default::default()
421        };
422        StatefulWidget::render(self, area, frame, &mut state);
423    }
424
425    fn is_essential(&self) -> bool {
426        // Errors are essential - users need to see them
427        true
428    }
429}
430
431// ---------------------------------------------------------------------------
432// Tests
433// ---------------------------------------------------------------------------
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use ftui_render::grapheme_pool::GraphemePool;
439
440    // -- Construction tests --
441
442    #[test]
443    fn new_creates_with_message() {
444        let error = ValidationErrorDisplay::new("Required field");
445        assert_eq!(error.message(), "Required field");
446        assert_eq!(error.error_code(), None);
447    }
448
449    #[test]
450    fn with_code_sets_error_code() {
451        let error = ValidationErrorDisplay::with_code("required", "This field is required");
452        assert_eq!(error.error_code(), Some("required"));
453        assert_eq!(error.message(), "This field is required");
454    }
455
456    #[test]
457    fn with_icon_overrides_default() {
458        let error = ValidationErrorDisplay::new("Error").with_icon("!");
459        assert_eq!(error.icon, "!");
460    }
461
462    #[test]
463    fn icon_only_disables_message() {
464        let error = ValidationErrorDisplay::new("Error").icon_only();
465        assert!(!error.show_message);
466    }
467
468    #[test]
469    fn default_uses_warning_icon() {
470        let error = ValidationErrorDisplay::default();
471        assert_eq!(error.icon, ERROR_ICON_DEFAULT);
472    }
473
474    // -- Min width calculation --
475
476    #[test]
477    fn min_width_icon_only() {
478        let error = ValidationErrorDisplay::new("Error").icon_only();
479        // Warning icon is 1 cell wide (may vary by font but assume 2 for emoji)
480        let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
481        assert_eq!(error.min_width(), icon_width);
482    }
483
484    #[test]
485    fn min_width_with_message() {
486        let error = ValidationErrorDisplay::new("Error");
487        let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
488        let msg_width = 5u16; // "Error"
489        assert_eq!(error.min_width(), icon_width + 1 + msg_width);
490    }
491
492    #[test]
493    fn min_width_empty_message() {
494        let error = ValidationErrorDisplay::new("");
495        let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
496        assert_eq!(error.min_width(), icon_width);
497    }
498
499    // -- State tests --
500
501    #[test]
502    fn state_default_is_hidden() {
503        let state = ValidationErrorState::default();
504        assert!(!state.is_visible());
505        assert_eq!(state.opacity(), 0.0);
506    }
507
508    #[test]
509    fn show_sets_visible_and_starts_animation() {
510        let mut state = ValidationErrorState::default();
511        state.show();
512        assert!(state.is_visible());
513        assert!(state.is_animating());
514        assert!(state.take_just_shown());
515    }
516
517    #[test]
518    fn hide_clears_visible() {
519        let mut state = ValidationErrorState::default();
520        state.show();
521        state.opacity = 1.0;
522        state.animation_start = None;
523        state.hide();
524        assert!(!state.is_visible());
525        assert!(state.is_animating());
526    }
527
528    #[test]
529    fn show_twice_is_noop() {
530        let mut state = ValidationErrorState::default();
531        state.show();
532        let start1 = state.animation_start;
533        state.just_shown = false;
534        state.show();
535        assert_eq!(state.animation_start, start1);
536        assert!(!state.just_shown); // Not re-triggered
537    }
538
539    #[test]
540    fn take_just_shown_clears_flag() {
541        let mut state = ValidationErrorState::default();
542        state.show();
543        assert!(state.take_just_shown());
544        assert!(!state.take_just_shown());
545    }
546
547    #[test]
548    fn tick_advances_opacity() {
549        let mut state = ValidationErrorState::default();
550        state.show();
551        // Simulate time passing
552        state.animation_start = Some(Instant::now() - Duration::from_millis(100));
553        state.tick(Duration::from_millis(150));
554        assert!(state.opacity > 0.0);
555        assert!(state.opacity < 1.0);
556    }
557
558    #[test]
559    fn tick_completes_animation() {
560        let mut state = ValidationErrorState::default();
561        state.show();
562        state.animation_start = Some(Instant::now() - Duration::from_millis(200));
563        state.tick(Duration::from_millis(150));
564        assert_eq!(state.opacity, 1.0);
565        assert!(!state.is_animating());
566    }
567
568    #[test]
569    fn tick_fade_out() {
570        let mut state = ValidationErrorState {
571            visible: false,
572            opacity: 1.0,
573            animation_start: Some(Instant::now() - Duration::from_millis(75)),
574            ..Default::default()
575        };
576        state.tick(Duration::from_millis(150));
577        assert!(state.opacity < 1.0);
578        assert!(state.opacity > 0.0);
579    }
580
581    #[test]
582    fn is_fully_visible_requires_complete_animation() {
583        let mut state = ValidationErrorState::default();
584        state.show();
585        assert!(!state.is_fully_visible());
586        state.opacity = 1.0;
587        state.animation_start = None;
588        assert!(state.is_fully_visible());
589    }
590
591    #[test]
592    fn aria_id_can_be_set() {
593        let state = ValidationErrorState::default().with_aria_id(42);
594        assert_eq!(state.aria_id(), 42);
595    }
596
597    // -- Rendering tests --
598
599    #[test]
600    fn render_draws_icon() {
601        let error = ValidationErrorDisplay::new("Error");
602        let area = Rect::new(0, 0, 20, 1);
603        let mut pool = GraphemePool::new();
604        let mut frame = Frame::new(20, 1, &mut pool);
605        Widget::render(&error, area, &mut frame);
606
607        // Icon should be at position 0
608        // Note: emoji width varies, but we check something was drawn
609        let cell = frame.buffer.get(0, 0).unwrap();
610        assert!(!cell.is_empty());
611    }
612
613    #[test]
614    fn render_draws_message() {
615        let error = ValidationErrorDisplay::new("Required").with_icon("!");
616        let area = Rect::new(0, 0, 20, 1);
617        let mut pool = GraphemePool::new();
618        let mut frame = Frame::new(20, 1, &mut pool);
619        Widget::render(&error, area, &mut frame);
620
621        // "!" at 0, space at 1, "Required" starts at 2
622        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
623        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('R'));
624    }
625
626    #[test]
627    fn render_truncates_long_message() {
628        let error = ValidationErrorDisplay::new("This is a very long error message").with_icon("!");
629        let area = Rect::new(0, 0, 12, 1);
630        let mut pool = GraphemePool::new();
631        let mut frame = Frame::new(12, 1, &mut pool);
632        Widget::render(&error, area, &mut frame);
633
634        // Should end with ellipsis somewhere
635        let mut found_ellipsis = false;
636        for x in 0..12 {
637            if let Some(cell) = frame.buffer.get(x, 0)
638                && cell.content.as_char() == Some('…')
639            {
640                found_ellipsis = true;
641                break;
642            }
643        }
644        assert!(found_ellipsis);
645    }
646
647    #[test]
648    fn render_empty_area_is_noop() {
649        let error = ValidationErrorDisplay::new("Error");
650        let area = Rect::new(0, 0, 0, 0);
651        let mut pool = GraphemePool::new();
652        let mut frame = Frame::new(1, 1, &mut pool);
653        let mut state = ValidationErrorState::default();
654        StatefulWidget::render(&error, area, &mut frame, &mut state);
655        // Should not panic
656    }
657
658    #[test]
659    fn render_hidden_state_draws_nothing() {
660        let error = ValidationErrorDisplay::new("Error");
661        let area = Rect::new(0, 0, 20, 1);
662        let mut pool = GraphemePool::new();
663        let mut frame = Frame::new(20, 1, &mut pool);
664        let mut state = ValidationErrorState::default(); // Not visible
665        StatefulWidget::render(&error, area, &mut frame, &mut state);
666
667        // Nothing should be drawn
668        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
669    }
670
671    #[test]
672    fn render_visible_state_draws_content() {
673        let error = ValidationErrorDisplay::new("Error").with_icon("!");
674        let area = Rect::new(0, 0, 20, 1);
675        let mut pool = GraphemePool::new();
676        let mut frame = Frame::new(20, 1, &mut pool);
677        let mut state = ValidationErrorState {
678            visible: true,
679            opacity: 1.0,
680            ..Default::default()
681        };
682        StatefulWidget::render(&error, area, &mut frame, &mut state);
683
684        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
685    }
686
687    #[test]
688    fn render_icon_only_mode() {
689        let error = ValidationErrorDisplay::new("This error should not appear")
690            .with_icon("X")
691            .icon_only();
692        let area = Rect::new(0, 0, 20, 1);
693        let mut pool = GraphemePool::new();
694        let mut frame = Frame::new(20, 1, &mut pool);
695        Widget::render(&error, area, &mut frame);
696
697        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('X'));
698        // Position 1+ should be empty (no message)
699        assert!(frame.buffer.get(1, 0).unwrap().is_empty());
700    }
701
702    #[test]
703    fn is_essential_returns_true() {
704        let error = ValidationErrorDisplay::new("Error");
705        assert!(error.is_essential());
706    }
707
708    // -- Property tests --
709
710    #[test]
711    fn opacity_always_clamped() {
712        let mut state = ValidationErrorState::default();
713        state.show();
714        state.animation_start = Some(Instant::now() - Duration::from_secs(10));
715        state.tick(Duration::from_millis(100));
716        assert!(state.opacity >= 0.0);
717        assert!(state.opacity <= 1.0);
718    }
719
720    #[test]
721    fn animation_duration_zero_is_immediate() {
722        let mut state = ValidationErrorState::default();
723        state.show();
724        state.tick(Duration::ZERO);
725        assert_eq!(state.opacity, 1.0);
726        assert!(!state.is_animating());
727    }
728
729    // -- Style tests --
730
731    #[test]
732    fn style_is_applied_to_message() {
733        let custom_style = Style::new().fg(PackedRgba::rgb(100, 200, 50));
734        let error = ValidationErrorDisplay::new("Error")
735            .with_icon("!")
736            .with_style(custom_style);
737        let area = Rect::new(0, 0, 20, 1);
738        let mut pool = GraphemePool::new();
739        let mut frame = Frame::new(20, 1, &mut pool);
740        Widget::render(&error, area, &mut frame);
741
742        // Check message cell has custom fg color
743        let cell = frame.buffer.get(2, 0).unwrap(); // 'R' of "Required"
744        assert_eq!(
745            cell.fg,
746            PackedRgba::rgb(100, 200, 50),
747            "message cell should use the custom fg color"
748        );
749    }
750
751    #[test]
752    fn icon_style_separate_from_message_style() {
753        let error = ValidationErrorDisplay::new("Error")
754            .with_icon("!")
755            .with_icon_style(Style::new().fg(PackedRgba::rgb(255, 255, 0)))
756            .with_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)));
757        let area = Rect::new(0, 0, 20, 1);
758        let mut pool = GraphemePool::new();
759        let mut frame = Frame::new(20, 1, &mut pool);
760        Widget::render(&error, area, &mut frame);
761
762        let icon_cell = frame.buffer.get(0, 0).unwrap();
763        let msg_cell = frame.buffer.get(2, 0).unwrap();
764        // Icon should have yellow, message should have red
765        assert_ne!(icon_cell.fg, msg_cell.fg);
766    }
767}