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 web_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 = "use the error code (if any) for telemetry/diagnostics"]
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    #[inline]
255    #[must_use]
256    pub fn is_visible(&self) -> bool {
257        self.visible
258    }
259
260    /// Check if the error is fully visible (animation complete).
261    #[must_use]
262    pub fn is_fully_visible(&self) -> bool {
263        self.visible && self.opacity >= 1.0
264    }
265
266    /// Get the current opacity.
267    #[must_use]
268    pub fn opacity(&self) -> f32 {
269        self.opacity
270    }
271
272    /// Check and clear the "just shown" flag (for screen reader announcements).
273    pub fn take_just_shown(&mut self) -> bool {
274        std::mem::take(&mut self.just_shown)
275    }
276
277    /// Get the ARIA ID for accessibility association.
278    #[must_use]
279    pub fn aria_id(&self) -> u32 {
280        self.aria_id
281    }
282
283    /// Update animation state. Call this each frame.
284    pub fn tick(&mut self, animation_duration: Duration) {
285        if let Some(start) = self.animation_start {
286            let elapsed = start.elapsed();
287            let progress = if animation_duration.is_zero() {
288                1.0
289            } else {
290                (elapsed.as_secs_f32() / animation_duration.as_secs_f32()).clamp(0.0, 1.0)
291            };
292
293            if self.visible {
294                // Fade in
295                self.opacity = progress;
296            } else {
297                // Fade out
298                self.opacity = 1.0 - progress;
299            }
300
301            // Animation complete
302            if progress >= 1.0 {
303                self.animation_start = None;
304                self.opacity = if self.visible { 1.0 } else { 0.0 };
305            }
306        }
307    }
308
309    /// Check if an animation is currently in progress.
310    #[must_use]
311    pub fn is_animating(&self) -> bool {
312        self.animation_start.is_some()
313    }
314}
315
316// ---------------------------------------------------------------------------
317// Widget Implementation
318// ---------------------------------------------------------------------------
319
320impl StatefulWidget for ValidationErrorDisplay {
321    type State = ValidationErrorState;
322
323    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
324        #[cfg(feature = "tracing")]
325        let _span = tracing::debug_span!(
326            "widget_render",
327            widget = "ValidationErrorDisplay",
328            x = area.x,
329            y = area.y,
330            w = area.width,
331            h = area.height
332        )
333        .entered();
334
335        if area.is_empty() || area.height < 1 {
336            return;
337        }
338
339        // Update animation
340        state.tick(self.animation_duration);
341
342        // Skip rendering if fully invisible
343        if state.opacity <= 0.0 && !state.visible {
344            return;
345        }
346
347        let deg = frame.buffer.degradation;
348
349        // Calculate effective opacity for styling
350        let effective_opacity = (state.opacity * 255.0) as u8;
351
352        // Adjust style with opacity
353        let icon_style = if deg.apply_styling() && effective_opacity < 255 {
354            let fg = self.icon_style.fg.unwrap_or(ERROR_FG_DEFAULT);
355            Style::new().fg(PackedRgba::rgba(fg.r(), fg.g(), fg.b(), effective_opacity))
356        } else if deg.apply_styling() {
357            self.icon_style
358        } else {
359            Style::default()
360        };
361
362        let text_style = if deg.apply_styling() && effective_opacity < 255 {
363            let fg = self.style.fg.unwrap_or(ERROR_FG_DEFAULT);
364            Style::new().fg(PackedRgba::rgba(fg.r(), fg.g(), fg.b(), effective_opacity))
365        } else if deg.apply_styling() {
366            self.style
367        } else {
368            Style::default()
369        };
370
371        // Draw icon
372        let y = area.y;
373        let mut x = area.x;
374        let max_x = area.right();
375
376        x = draw_text_span(frame, x, y, &self.icon, icon_style, max_x);
377
378        // Draw space separator
379        if x < max_x && self.show_message && !self.message.is_empty() {
380            x = x.saturating_add(1);
381
382            // Draw message (truncate with ellipsis if needed)
383            let remaining_width = max_x.saturating_sub(x) as usize;
384            let msg_width = display_width(self.message.as_str());
385
386            if msg_width <= remaining_width {
387                draw_text_span(frame, x, y, &self.message, text_style, max_x);
388            } else if remaining_width >= 4 {
389                // Truncate with ellipsis
390                let mut truncated = String::new();
391                let mut w = 0;
392                let limit = remaining_width.saturating_sub(1); // Leave room for "…"
393
394                for grapheme in unicode_segmentation::UnicodeSegmentation::graphemes(
395                    self.message.as_str(),
396                    true,
397                ) {
398                    let gw = grapheme_width(grapheme);
399                    if w + gw > limit {
400                        break;
401                    }
402                    truncated.push_str(grapheme);
403                    w += gw;
404                }
405                truncated.push('…');
406
407                draw_text_span(frame, x, y, &truncated, text_style, max_x);
408            } else if remaining_width >= 1 {
409                // Just ellipsis
410                draw_text_span(frame, x, y, "…", text_style, max_x);
411            }
412        }
413    }
414}
415
416impl Widget for ValidationErrorDisplay {
417    fn render(&self, area: Rect, frame: &mut Frame) {
418        let mut state = ValidationErrorState {
419            visible: true,
420            opacity: 1.0,
421            ..Default::default()
422        };
423        StatefulWidget::render(self, area, frame, &mut state);
424    }
425
426    fn is_essential(&self) -> bool {
427        // Errors are essential - users need to see them
428        true
429    }
430}
431
432// ---------------------------------------------------------------------------
433// Tests
434// ---------------------------------------------------------------------------
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use ftui_render::grapheme_pool::GraphemePool;
440
441    // -- Construction tests --
442
443    #[test]
444    fn new_creates_with_message() {
445        let error = ValidationErrorDisplay::new("Required field");
446        assert_eq!(error.message(), "Required field");
447        assert_eq!(error.error_code(), None);
448    }
449
450    #[test]
451    fn with_code_sets_error_code() {
452        let error = ValidationErrorDisplay::with_code("required", "This field is required");
453        assert_eq!(error.error_code(), Some("required"));
454        assert_eq!(error.message(), "This field is required");
455    }
456
457    #[test]
458    fn with_icon_overrides_default() {
459        let error = ValidationErrorDisplay::new("Error").with_icon("!");
460        assert_eq!(error.icon, "!");
461    }
462
463    #[test]
464    fn icon_only_disables_message() {
465        let error = ValidationErrorDisplay::new("Error").icon_only();
466        assert!(!error.show_message);
467    }
468
469    #[test]
470    fn default_uses_warning_icon() {
471        let error = ValidationErrorDisplay::default();
472        assert_eq!(error.icon, ERROR_ICON_DEFAULT);
473    }
474
475    // -- Min width calculation --
476
477    #[test]
478    fn min_width_icon_only() {
479        let error = ValidationErrorDisplay::new("Error").icon_only();
480        // Warning icon is 1 cell wide (may vary by font but assume 2 for emoji)
481        let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
482        assert_eq!(error.min_width(), icon_width);
483    }
484
485    #[test]
486    fn min_width_with_message() {
487        let error = ValidationErrorDisplay::new("Error");
488        let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
489        let msg_width = 5u16; // "Error"
490        assert_eq!(error.min_width(), icon_width + 1 + msg_width);
491    }
492
493    #[test]
494    fn min_width_empty_message() {
495        let error = ValidationErrorDisplay::new("");
496        let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
497        assert_eq!(error.min_width(), icon_width);
498    }
499
500    // -- State tests --
501
502    #[test]
503    fn state_default_is_hidden() {
504        let state = ValidationErrorState::default();
505        assert!(!state.is_visible());
506        assert_eq!(state.opacity(), 0.0);
507    }
508
509    #[test]
510    fn show_sets_visible_and_starts_animation() {
511        let mut state = ValidationErrorState::default();
512        state.show();
513        assert!(state.is_visible());
514        assert!(state.is_animating());
515        assert!(state.take_just_shown());
516    }
517
518    #[test]
519    fn hide_clears_visible() {
520        let mut state = ValidationErrorState::default();
521        state.show();
522        state.opacity = 1.0;
523        state.animation_start = None;
524        state.hide();
525        assert!(!state.is_visible());
526        assert!(state.is_animating());
527    }
528
529    #[test]
530    fn show_twice_is_noop() {
531        let mut state = ValidationErrorState::default();
532        state.show();
533        let start1 = state.animation_start;
534        state.just_shown = false;
535        state.show();
536        assert_eq!(state.animation_start, start1);
537        assert!(!state.just_shown); // Not re-triggered
538    }
539
540    #[test]
541    fn take_just_shown_clears_flag() {
542        let mut state = ValidationErrorState::default();
543        state.show();
544        assert!(state.take_just_shown());
545        assert!(!state.take_just_shown());
546    }
547
548    #[test]
549    fn tick_advances_opacity() {
550        let mut state = ValidationErrorState::default();
551        state.show();
552        // Simulate time passing
553        state.animation_start = Some(Instant::now() - Duration::from_millis(100));
554        state.tick(Duration::from_millis(150));
555        assert!(state.opacity > 0.0);
556        assert!(state.opacity < 1.0);
557    }
558
559    #[test]
560    fn tick_completes_animation() {
561        let mut state = ValidationErrorState::default();
562        state.show();
563        state.animation_start = Some(Instant::now() - Duration::from_millis(200));
564        state.tick(Duration::from_millis(150));
565        assert_eq!(state.opacity, 1.0);
566        assert!(!state.is_animating());
567    }
568
569    #[test]
570    fn tick_fade_out() {
571        let mut state = ValidationErrorState {
572            visible: false,
573            opacity: 1.0,
574            animation_start: Some(Instant::now() - Duration::from_millis(75)),
575            ..Default::default()
576        };
577        state.tick(Duration::from_millis(150));
578        assert!(state.opacity < 1.0);
579        assert!(state.opacity > 0.0);
580    }
581
582    #[test]
583    fn is_fully_visible_requires_complete_animation() {
584        let mut state = ValidationErrorState::default();
585        state.show();
586        assert!(!state.is_fully_visible());
587        state.opacity = 1.0;
588        state.animation_start = None;
589        assert!(state.is_fully_visible());
590    }
591
592    #[test]
593    fn aria_id_can_be_set() {
594        let state = ValidationErrorState::default().with_aria_id(42);
595        assert_eq!(state.aria_id(), 42);
596    }
597
598    // -- Rendering tests --
599
600    #[test]
601    fn render_draws_icon() {
602        let error = ValidationErrorDisplay::new("Error");
603        let area = Rect::new(0, 0, 20, 1);
604        let mut pool = GraphemePool::new();
605        let mut frame = Frame::new(20, 1, &mut pool);
606        Widget::render(&error, area, &mut frame);
607
608        // Icon should be at position 0
609        // Note: emoji width varies, but we check something was drawn
610        let cell = frame.buffer.get(0, 0).unwrap();
611        assert!(!cell.is_empty());
612    }
613
614    #[test]
615    fn render_draws_message() {
616        let error = ValidationErrorDisplay::new("Required").with_icon("!");
617        let area = Rect::new(0, 0, 20, 1);
618        let mut pool = GraphemePool::new();
619        let mut frame = Frame::new(20, 1, &mut pool);
620        Widget::render(&error, area, &mut frame);
621
622        // "!" at 0, space at 1, "Required" starts at 2
623        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
624        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('R'));
625    }
626
627    #[test]
628    fn render_truncates_long_message() {
629        let error = ValidationErrorDisplay::new("This is a very long error message").with_icon("!");
630        let area = Rect::new(0, 0, 12, 1);
631        let mut pool = GraphemePool::new();
632        let mut frame = Frame::new(12, 1, &mut pool);
633        Widget::render(&error, area, &mut frame);
634
635        // Should end with ellipsis somewhere
636        let mut found_ellipsis = false;
637        for x in 0..12 {
638            if let Some(cell) = frame.buffer.get(x, 0)
639                && cell.content.as_char() == Some('…')
640            {
641                found_ellipsis = true;
642                break;
643            }
644        }
645        assert!(found_ellipsis);
646    }
647
648    #[test]
649    fn render_empty_area_is_noop() {
650        let error = ValidationErrorDisplay::new("Error");
651        let area = Rect::new(0, 0, 0, 0);
652        let mut pool = GraphemePool::new();
653        let mut frame = Frame::new(1, 1, &mut pool);
654        let mut state = ValidationErrorState::default();
655        StatefulWidget::render(&error, area, &mut frame, &mut state);
656        // Should not panic
657    }
658
659    #[test]
660    fn render_hidden_state_draws_nothing() {
661        let error = ValidationErrorDisplay::new("Error");
662        let area = Rect::new(0, 0, 20, 1);
663        let mut pool = GraphemePool::new();
664        let mut frame = Frame::new(20, 1, &mut pool);
665        let mut state = ValidationErrorState::default(); // Not visible
666        StatefulWidget::render(&error, area, &mut frame, &mut state);
667
668        // Nothing should be drawn
669        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
670    }
671
672    #[test]
673    fn render_visible_state_draws_content() {
674        let error = ValidationErrorDisplay::new("Error").with_icon("!");
675        let area = Rect::new(0, 0, 20, 1);
676        let mut pool = GraphemePool::new();
677        let mut frame = Frame::new(20, 1, &mut pool);
678        let mut state = ValidationErrorState {
679            visible: true,
680            opacity: 1.0,
681            ..Default::default()
682        };
683        StatefulWidget::render(&error, area, &mut frame, &mut state);
684
685        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
686    }
687
688    #[test]
689    fn render_icon_only_mode() {
690        let error = ValidationErrorDisplay::new("This error should not appear")
691            .with_icon("X")
692            .icon_only();
693        let area = Rect::new(0, 0, 20, 1);
694        let mut pool = GraphemePool::new();
695        let mut frame = Frame::new(20, 1, &mut pool);
696        Widget::render(&error, area, &mut frame);
697
698        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('X'));
699        // Position 1+ should be empty (no message)
700        assert!(frame.buffer.get(1, 0).unwrap().is_empty());
701    }
702
703    #[test]
704    fn is_essential_returns_true() {
705        let error = ValidationErrorDisplay::new("Error");
706        assert!(error.is_essential());
707    }
708
709    // -- Property tests --
710
711    #[test]
712    fn opacity_always_clamped() {
713        let mut state = ValidationErrorState::default();
714        state.show();
715        state.animation_start = Some(Instant::now() - Duration::from_secs(10));
716        state.tick(Duration::from_millis(100));
717        assert!(state.opacity >= 0.0);
718        assert!(state.opacity <= 1.0);
719    }
720
721    #[test]
722    fn animation_duration_zero_is_immediate() {
723        let mut state = ValidationErrorState::default();
724        state.show();
725        state.tick(Duration::ZERO);
726        assert_eq!(state.opacity, 1.0);
727        assert!(!state.is_animating());
728    }
729
730    // -- Style tests --
731
732    #[test]
733    fn style_is_applied_to_message() {
734        let custom_style = Style::new().fg(PackedRgba::rgb(100, 200, 50));
735        let error = ValidationErrorDisplay::new("Error")
736            .with_icon("!")
737            .with_style(custom_style);
738        let area = Rect::new(0, 0, 20, 1);
739        let mut pool = GraphemePool::new();
740        let mut frame = Frame::new(20, 1, &mut pool);
741        Widget::render(&error, area, &mut frame);
742
743        // Check message cell has custom fg color
744        let cell = frame.buffer.get(2, 0).unwrap(); // 'R' of "Required"
745        assert_eq!(
746            cell.fg,
747            PackedRgba::rgb(100, 200, 50),
748            "message cell should use the custom fg color"
749        );
750    }
751
752    #[test]
753    fn icon_style_separate_from_message_style() {
754        let error = ValidationErrorDisplay::new("Error")
755            .with_icon("!")
756            .with_icon_style(Style::new().fg(PackedRgba::rgb(255, 255, 0)))
757            .with_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)));
758        let area = Rect::new(0, 0, 20, 1);
759        let mut pool = GraphemePool::new();
760        let mut frame = Frame::new(20, 1, &mut pool);
761        Widget::render(&error, area, &mut frame);
762
763        let icon_cell = frame.buffer.get(0, 0).unwrap();
764        let msg_cell = frame.buffer.get(2, 0).unwrap();
765        // Icon should have yellow, message should have red
766        assert_ne!(icon_cell.fg, msg_cell.fg);
767    }
768}