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, 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 state.tick(self.animation_duration);
341
342 if state.opacity <= 0.0 && !state.visible {
344 return;
345 }
346
347 let deg = frame.buffer.degradation;
348
349 let effective_opacity = (state.opacity * 255.0) as u8;
351
352 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 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 if x < max_x && self.show_message && !self.message.is_empty() {
380 x = x.saturating_add(1);
381
382 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 let mut truncated = String::new();
391 let mut w = 0;
392 let limit = remaining_width.saturating_sub(1); 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 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 true
429 }
430}
431
432#[cfg(test)]
437mod tests {
438 use super::*;
439 use ftui_render::grapheme_pool::GraphemePool;
440
441 #[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 #[test]
478 fn min_width_icon_only() {
479 let error = ValidationErrorDisplay::new("Error").icon_only();
480 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; 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 #[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); }
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 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 #[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 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 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 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 }
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(); StatefulWidget::render(&error, area, &mut frame, &mut state);
667
668 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 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 #[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 #[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 let cell = frame.buffer.get(2, 0).unwrap(); 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 assert_ne!(icon_cell.fg, msg_cell.fg);
767 }
768}