1#![forbid(unsafe_code)]
2
3use 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, clear_text_row, draw_text_span};
51
52pub const ERROR_FG_DEFAULT: PackedRgba = PackedRgba::rgb(220, 60, 60);
58
59pub const ERROR_BG_DEFAULT: PackedRgba = PackedRgba::rgb(40, 0, 0);
61
62pub const ERROR_ICON_DEFAULT: &str = "⚠";
64
65pub const ANIMATION_DURATION_MS: u64 = 150;
67
68#[derive(Debug, Clone)]
77pub struct ValidationErrorDisplay {
78 message: String,
80 error_code: Option<&'static str>,
82 icon: String,
84 style: Style,
86 icon_style: Style,
88 animation_duration: Duration,
90 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 #[must_use]
111 pub fn new(message: impl Into<String>) -> Self {
112 Self {
113 message: message.into(),
114 ..Default::default()
115 }
116 }
117
118 #[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 #[must_use]
130 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
131 self.icon = icon.into();
132 self
133 }
134
135 #[must_use]
137 pub fn with_style(mut self, style: Style) -> Self {
138 self.style = style;
139 self
140 }
141
142 #[must_use]
144 pub fn with_icon_style(mut self, style: Style) -> Self {
145 self.icon_style = style;
146 self
147 }
148
149 #[must_use]
151 pub fn with_animation_duration(mut self, duration: Duration) -> Self {
152 self.animation_duration = duration;
153 self
154 }
155
156 #[must_use]
158 pub fn icon_only(mut self) -> Self {
159 self.show_message = false;
160 self
161 }
162
163 #[must_use]
165 pub fn message(&self) -> &str {
166 &self.message
167 }
168
169 #[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 #[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#[derive(Debug, Clone)]
194pub struct ValidationErrorState {
195 visible: bool,
197 animation_start: Option<Instant>,
199 opacity: f32,
201 just_shown: bool,
203 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 #[must_use]
222 pub fn with_aria_id(mut self, id: u32) -> Self {
223 self.aria_id = id;
224 self
225 }
226
227 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 pub fn hide(&mut self) {
238 if self.visible {
239 self.visible = false;
240 self.animation_start = Some(Instant::now());
241 }
242 }
243
244 pub fn set_visible(&mut self, visible: bool) {
246 if visible {
247 self.show();
248 } else {
249 self.hide();
250 }
251 }
252
253 #[inline]
255 #[must_use]
256 pub fn is_visible(&self) -> bool {
257 self.visible
258 }
259
260 #[must_use]
262 pub fn is_fully_visible(&self) -> bool {
263 self.visible && self.opacity >= 1.0
264 }
265
266 #[must_use]
268 pub fn opacity(&self) -> f32 {
269 self.opacity
270 }
271
272 pub fn take_just_shown(&mut self) -> bool {
274 std::mem::take(&mut self.just_shown)
275 }
276
277 #[must_use]
279 pub fn aria_id(&self) -> u32 {
280 self.aria_id
281 }
282
283 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 self.opacity = progress;
296 } else {
297 self.opacity = 1.0 - progress;
299 }
300
301 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 #[must_use]
311 pub fn is_animating(&self) -> bool {
312 self.animation_start.is_some()
313 }
314}
315
316impl 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 let row_area = Rect::new(area.x, area.y, area.width, 1);
340
341 state.tick(self.animation_duration);
343
344 if state.opacity <= 0.0 && !state.visible {
346 clear_text_row(frame, row_area, Style::default());
347 return;
348 }
349
350 let deg = frame.buffer.degradation;
351
352 let effective_opacity = (state.opacity * 255.0) as u8;
354
355 let icon_style = if deg.apply_styling() && effective_opacity < 255 {
357 let fg = self.icon_style.fg.unwrap_or(ERROR_FG_DEFAULT);
358 Style::new().fg(PackedRgba::rgba(fg.r(), fg.g(), fg.b(), effective_opacity))
359 } else if deg.apply_styling() {
360 self.icon_style
361 } else {
362 Style::default()
363 };
364
365 let text_style = if deg.apply_styling() && effective_opacity < 255 {
366 let fg = self.style.fg.unwrap_or(ERROR_FG_DEFAULT);
367 Style::new().fg(PackedRgba::rgba(fg.r(), fg.g(), fg.b(), effective_opacity))
368 } else if deg.apply_styling() {
369 self.style
370 } else {
371 Style::default()
372 };
373
374 clear_text_row(frame, row_area, Style::default());
375
376 let y = area.y;
378 let mut x = area.x;
379 let max_x = area.right();
380
381 x = draw_text_span(frame, x, y, &self.icon, icon_style, max_x);
382
383 if x < max_x && self.show_message && !self.message.is_empty() {
385 x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
386
387 let remaining_width = max_x.saturating_sub(x) as usize;
389 let msg_width = display_width(self.message.as_str());
390
391 if msg_width <= remaining_width {
392 draw_text_span(frame, x, y, &self.message, text_style, max_x);
393 } else if remaining_width >= 4 {
394 let mut truncated = String::new();
396 let mut w = 0;
397 let limit = remaining_width.saturating_sub(1); for grapheme in unicode_segmentation::UnicodeSegmentation::graphemes(
400 self.message.as_str(),
401 true,
402 ) {
403 let gw = grapheme_width(grapheme);
404 if w + gw > limit {
405 break;
406 }
407 truncated.push_str(grapheme);
408 w += gw;
409 }
410 truncated.push('…');
411
412 draw_text_span(frame, x, y, &truncated, text_style, max_x);
413 } else if remaining_width >= 1 {
414 draw_text_span(frame, x, y, "…", text_style, max_x);
416 }
417 }
418 }
419}
420
421impl Widget for ValidationErrorDisplay {
422 fn render(&self, area: Rect, frame: &mut Frame) {
423 let mut state = ValidationErrorState {
424 visible: true,
425 opacity: 1.0,
426 ..Default::default()
427 };
428 StatefulWidget::render(self, area, frame, &mut state);
429 }
430
431 fn is_essential(&self) -> bool {
432 true
434 }
435}
436
437#[cfg(test)]
442mod tests {
443 use super::*;
444 use ftui_render::cell::Cell;
445 use ftui_render::grapheme_pool::GraphemePool;
446
447 #[test]
450 fn new_creates_with_message() {
451 let error = ValidationErrorDisplay::new("Required field");
452 assert_eq!(error.message(), "Required field");
453 assert_eq!(error.error_code(), None);
454 }
455
456 #[test]
457 fn with_code_sets_error_code() {
458 let error = ValidationErrorDisplay::with_code("required", "This field is required");
459 assert_eq!(error.error_code(), Some("required"));
460 assert_eq!(error.message(), "This field is required");
461 }
462
463 #[test]
464 fn with_icon_overrides_default() {
465 let error = ValidationErrorDisplay::new("Error").with_icon("!");
466 assert_eq!(error.icon, "!");
467 }
468
469 #[test]
470 fn icon_only_disables_message() {
471 let error = ValidationErrorDisplay::new("Error").icon_only();
472 assert!(!error.show_message);
473 }
474
475 #[test]
476 fn default_uses_warning_icon() {
477 let error = ValidationErrorDisplay::default();
478 assert_eq!(error.icon, ERROR_ICON_DEFAULT);
479 }
480
481 #[test]
484 fn min_width_icon_only() {
485 let error = ValidationErrorDisplay::new("Error").icon_only();
486 let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
488 assert_eq!(error.min_width(), icon_width);
489 }
490
491 #[test]
492 fn min_width_with_message() {
493 let error = ValidationErrorDisplay::new("Error");
494 let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
495 let msg_width = 5u16; assert_eq!(error.min_width(), icon_width + 1 + msg_width);
497 }
498
499 #[test]
500 fn min_width_empty_message() {
501 let error = ValidationErrorDisplay::new("");
502 let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
503 assert_eq!(error.min_width(), icon_width);
504 }
505
506 #[test]
509 fn state_default_is_hidden() {
510 let state = ValidationErrorState::default();
511 assert!(!state.is_visible());
512 assert_eq!(state.opacity(), 0.0);
513 }
514
515 #[test]
516 fn show_sets_visible_and_starts_animation() {
517 let mut state = ValidationErrorState::default();
518 state.show();
519 assert!(state.is_visible());
520 assert!(state.is_animating());
521 assert!(state.take_just_shown());
522 }
523
524 #[test]
525 fn hide_clears_visible() {
526 let mut state = ValidationErrorState::default();
527 state.show();
528 state.opacity = 1.0;
529 state.animation_start = None;
530 state.hide();
531 assert!(!state.is_visible());
532 assert!(state.is_animating());
533 }
534
535 #[test]
536 fn show_twice_is_noop() {
537 let mut state = ValidationErrorState::default();
538 state.show();
539 let start1 = state.animation_start;
540 state.just_shown = false;
541 state.show();
542 assert_eq!(state.animation_start, start1);
543 assert!(!state.just_shown); }
545
546 #[test]
547 fn take_just_shown_clears_flag() {
548 let mut state = ValidationErrorState::default();
549 state.show();
550 assert!(state.take_just_shown());
551 assert!(!state.take_just_shown());
552 }
553
554 #[test]
555 fn tick_advances_opacity() {
556 let mut state = ValidationErrorState::default();
557 state.show();
558 state.animation_start = Some(Instant::now() - Duration::from_millis(100));
560 state.tick(Duration::from_millis(150));
561 assert!(state.opacity > 0.0);
562 assert!(state.opacity < 1.0);
563 }
564
565 #[test]
566 fn tick_completes_animation() {
567 let mut state = ValidationErrorState::default();
568 state.show();
569 state.animation_start = Some(Instant::now() - Duration::from_millis(200));
570 state.tick(Duration::from_millis(150));
571 assert_eq!(state.opacity, 1.0);
572 assert!(!state.is_animating());
573 }
574
575 #[test]
576 fn tick_fade_out() {
577 let mut state = ValidationErrorState {
578 visible: false,
579 opacity: 1.0,
580 animation_start: Some(Instant::now() - Duration::from_millis(75)),
581 ..Default::default()
582 };
583 state.tick(Duration::from_millis(150));
584 assert!(state.opacity < 1.0);
585 assert!(state.opacity > 0.0);
586 }
587
588 #[test]
589 fn is_fully_visible_requires_complete_animation() {
590 let mut state = ValidationErrorState::default();
591 state.show();
592 assert!(!state.is_fully_visible());
593 state.opacity = 1.0;
594 state.animation_start = None;
595 assert!(state.is_fully_visible());
596 }
597
598 #[test]
599 fn aria_id_can_be_set() {
600 let state = ValidationErrorState::default().with_aria_id(42);
601 assert_eq!(state.aria_id(), 42);
602 }
603
604 #[test]
607 fn render_draws_icon() {
608 let error = ValidationErrorDisplay::new("Error");
609 let area = Rect::new(0, 0, 20, 1);
610 let mut pool = GraphemePool::new();
611 let mut frame = Frame::new(20, 1, &mut pool);
612 Widget::render(&error, area, &mut frame);
613
614 let cell = frame.buffer.get(0, 0).unwrap();
617 assert!(!cell.is_empty());
618 }
619
620 #[test]
621 fn render_draws_message() {
622 let error = ValidationErrorDisplay::new("Required").with_icon("!");
623 let area = Rect::new(0, 0, 20, 1);
624 let mut pool = GraphemePool::new();
625 let mut frame = Frame::new(20, 1, &mut pool);
626 Widget::render(&error, area, &mut frame);
627
628 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
630 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('R'));
631 }
632
633 #[test]
634 fn render_clears_separator_cell() {
635 let error = ValidationErrorDisplay::new("Required").with_icon("!");
636 let area = Rect::new(0, 0, 20, 1);
637 let mut pool = GraphemePool::new();
638 let mut frame = Frame::new(20, 1, &mut pool);
639 frame.buffer.set_fast(1, 0, Cell::from_char('X'));
640 Widget::render(&error, area, &mut frame);
641
642 assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some(' '));
643 }
644
645 #[test]
646 fn render_truncates_long_message() {
647 let error = ValidationErrorDisplay::new("This is a very long error message").with_icon("!");
648 let area = Rect::new(0, 0, 12, 1);
649 let mut pool = GraphemePool::new();
650 let mut frame = Frame::new(12, 1, &mut pool);
651 Widget::render(&error, area, &mut frame);
652
653 let mut found_ellipsis = false;
655 for x in 0..12 {
656 if let Some(cell) = frame.buffer.get(x, 0)
657 && cell.content.as_char() == Some('…')
658 {
659 found_ellipsis = true;
660 break;
661 }
662 }
663 assert!(found_ellipsis);
664 }
665
666 #[test]
667 fn render_empty_area_is_noop() {
668 let error = ValidationErrorDisplay::new("Error");
669 let area = Rect::new(0, 0, 0, 0);
670 let mut pool = GraphemePool::new();
671 let mut frame = Frame::new(1, 1, &mut pool);
672 let mut state = ValidationErrorState::default();
673 StatefulWidget::render(&error, area, &mut frame, &mut state);
674 }
676
677 #[test]
678 fn render_hidden_state_draws_nothing() {
679 let error = ValidationErrorDisplay::new("Error");
680 let area = Rect::new(0, 0, 20, 1);
681 let mut pool = GraphemePool::new();
682 let mut frame = Frame::new(20, 1, &mut pool);
683 let mut state = ValidationErrorState::default(); StatefulWidget::render(&error, area, &mut frame, &mut state);
685
686 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
688 }
689
690 #[test]
691 fn render_hidden_state_clears_stale_row() {
692 let error = ValidationErrorDisplay::new("Error").with_icon("!");
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 let mut visible = ValidationErrorState {
697 visible: true,
698 opacity: 1.0,
699 ..Default::default()
700 };
701 let mut hidden = ValidationErrorState::default();
702
703 StatefulWidget::render(&error, area, &mut frame, &mut visible);
704 StatefulWidget::render(&error, area, &mut frame, &mut hidden);
705
706 for x in 0..20u16 {
707 assert_eq!(frame.buffer.get(x, 0).unwrap().content.as_char(), Some(' '));
708 }
709 }
710
711 #[test]
712 fn render_visible_state_draws_content() {
713 let error = ValidationErrorDisplay::new("Error").with_icon("!");
714 let area = Rect::new(0, 0, 20, 1);
715 let mut pool = GraphemePool::new();
716 let mut frame = Frame::new(20, 1, &mut pool);
717 let mut state = ValidationErrorState {
718 visible: true,
719 opacity: 1.0,
720 ..Default::default()
721 };
722 StatefulWidget::render(&error, area, &mut frame, &mut state);
723
724 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
725 }
726
727 #[test]
728 fn render_icon_only_mode() {
729 let error = ValidationErrorDisplay::new("This error should not appear")
730 .with_icon("X")
731 .icon_only();
732 let area = Rect::new(0, 0, 20, 1);
733 let mut pool = GraphemePool::new();
734 let mut frame = Frame::new(20, 1, &mut pool);
735 Widget::render(&error, area, &mut frame);
736
737 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('X'));
738 assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some(' '));
740 }
741
742 #[test]
743 fn render_shorter_message_clears_stale_suffix() {
744 let long = ValidationErrorDisplay::new("Long validation error").with_icon("!");
745 let short = ValidationErrorDisplay::new("No").with_icon("!");
746 let area = Rect::new(0, 0, 20, 1);
747 let mut pool = GraphemePool::new();
748 let mut frame = Frame::new(20, 1, &mut pool);
749
750 Widget::render(&long, area, &mut frame);
751 Widget::render(&short, area, &mut frame);
752
753 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
754 assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some(' '));
755 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('N'));
756 assert_eq!(frame.buffer.get(3, 0).unwrap().content.as_char(), Some('o'));
757 assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some(' '));
758 assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some(' '));
759 }
760
761 #[test]
762 fn is_essential_returns_true() {
763 let error = ValidationErrorDisplay::new("Error");
764 assert!(error.is_essential());
765 }
766
767 #[test]
770 fn opacity_always_clamped() {
771 let mut state = ValidationErrorState::default();
772 state.show();
773 state.animation_start = Some(Instant::now() - Duration::from_secs(10));
774 state.tick(Duration::from_millis(100));
775 assert!(state.opacity >= 0.0);
776 assert!(state.opacity <= 1.0);
777 }
778
779 #[test]
780 fn animation_duration_zero_is_immediate() {
781 let mut state = ValidationErrorState::default();
782 state.show();
783 state.tick(Duration::ZERO);
784 assert_eq!(state.opacity, 1.0);
785 assert!(!state.is_animating());
786 }
787
788 #[test]
791 fn style_is_applied_to_message() {
792 let custom_style = Style::new().fg(PackedRgba::rgb(100, 200, 50));
793 let error = ValidationErrorDisplay::new("Error")
794 .with_icon("!")
795 .with_style(custom_style);
796 let area = Rect::new(0, 0, 20, 1);
797 let mut pool = GraphemePool::new();
798 let mut frame = Frame::new(20, 1, &mut pool);
799 Widget::render(&error, area, &mut frame);
800
801 let cell = frame.buffer.get(2, 0).unwrap(); assert_eq!(
804 cell.fg,
805 PackedRgba::rgb(100, 200, 50),
806 "message cell should use the custom fg color"
807 );
808 }
809
810 #[test]
811 fn icon_style_separate_from_message_style() {
812 let error = ValidationErrorDisplay::new("Error")
813 .with_icon("!")
814 .with_icon_style(Style::new().fg(PackedRgba::rgb(255, 255, 0)))
815 .with_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)));
816 let area = Rect::new(0, 0, 20, 1);
817 let mut pool = GraphemePool::new();
818 let mut frame = Frame::new(20, 1, &mut pool);
819 Widget::render(&error, area, &mut frame);
820
821 let icon_cell = frame.buffer.get(0, 0).unwrap();
822 let msg_cell = frame.buffer.get(2, 0).unwrap();
823 assert_ne!(icon_cell.fg, msg_cell.fg);
825 }
826}