1#![forbid(unsafe_code)]
2
3use 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
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]
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 #[must_use]
255 pub fn is_visible(&self) -> bool {
256 self.visible
257 }
258
259 #[must_use]
261 pub fn is_fully_visible(&self) -> bool {
262 self.visible && self.opacity >= 1.0
263 }
264
265 #[must_use]
267 pub fn opacity(&self) -> f32 {
268 self.opacity
269 }
270
271 pub fn take_just_shown(&mut self) -> bool {
273 std::mem::take(&mut self.just_shown)
274 }
275
276 #[must_use]
278 pub fn aria_id(&self) -> u32 {
279 self.aria_id
280 }
281
282 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 self.opacity = progress;
295 } else {
296 self.opacity = 1.0 - progress;
298 }
299
300 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 #[must_use]
310 pub fn is_animating(&self) -> bool {
311 self.animation_start.is_some()
312 }
313}
314
315impl 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 state.tick(self.animation_duration);
340
341 if state.opacity <= 0.0 && !state.visible {
343 return;
344 }
345
346 let deg = frame.buffer.degradation;
347
348 let effective_opacity = (state.opacity * 255.0) as u8;
350
351 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 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 if x < max_x && self.show_message && !self.message.is_empty() {
379 x = x.saturating_add(1);
380
381 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 let mut truncated = String::new();
390 let mut w = 0;
391 let limit = remaining_width.saturating_sub(1); 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 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 true
428 }
429}
430
431#[cfg(test)]
436mod tests {
437 use super::*;
438 use ftui_render::grapheme_pool::GraphemePool;
439
440 #[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 #[test]
477 fn min_width_icon_only() {
478 let error = ValidationErrorDisplay::new("Error").icon_only();
479 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; 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 #[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); }
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 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 #[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 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 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 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 }
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(); StatefulWidget::render(&error, area, &mut frame, &mut state);
666
667 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 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 #[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 #[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 let cell = frame.buffer.get(2, 0).unwrap(); 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 assert_ne!(icon_cell.fg, msg_cell.fg);
766 }
767}