Skip to main content

ftui_widgets/
error_boundary.rs

1#![forbid(unsafe_code)]
2
3//! Widget error boundaries with panic recovery.
4//!
5//! Wraps any widget in a safety boundary that catches panics during rendering
6//! and displays a fallback error indicator instead of crashing the application.
7
8use std::panic::{AssertUnwindSafe, catch_unwind};
9use web_time::Instant;
10
11use ftui_core::geometry::Rect;
12use ftui_core::with_panic_cleanup_suppressed;
13use ftui_render::cell::{Cell, PackedRgba};
14use ftui_render::frame::Frame;
15use ftui_style::Style;
16use ftui_text::{display_width, grapheme_width};
17use unicode_segmentation::UnicodeSegmentation;
18
19use crate::{StatefulWidget, Widget, apply_style, draw_text_span, set_style_area};
20
21/// Captured error from a widget panic.
22#[derive(Debug, Clone)]
23pub struct CapturedError {
24    /// Error message extracted from the panic payload.
25    pub message: String,
26    /// Name of the widget that panicked.
27    pub widget_name: &'static str,
28    /// Area the widget was rendering into.
29    pub area: Rect,
30    /// When the error was captured.
31    pub timestamp: Instant,
32}
33
34impl CapturedError {
35    fn from_panic(
36        payload: Box<dyn std::any::Any + Send>,
37        widget_name: &'static str,
38        area: Rect,
39    ) -> Self {
40        let mut message = if let Some(s) = payload.downcast_ref::<&str>() {
41            (*s).to_string()
42        } else if let Some(s) = payload.downcast_ref::<String>() {
43            s.clone()
44        } else {
45            "unknown panic".to_string()
46        };
47        if let Some(stripped) = message.strip_prefix("internal error: entered unreachable code: ") {
48            message = stripped.to_string();
49        }
50
51        Self {
52            message,
53            widget_name,
54            area,
55            timestamp: Instant::now(),
56        }
57    }
58}
59
60/// State for an error boundary.
61#[derive(Debug, Clone, Default)]
62pub enum ErrorBoundaryState {
63    /// Widget is rendering normally.
64    #[default]
65    Healthy,
66    /// Widget panicked and is showing fallback.
67    Failed(CapturedError),
68    /// Attempting recovery after failure.
69    Recovering {
70        /// Number of recovery attempts so far.
71        attempts: u32,
72        /// The error that triggered recovery.
73        last_error: CapturedError,
74    },
75}
76
77impl ErrorBoundaryState {
78    /// Returns the current error, if any.
79    #[must_use = "use the returned error for diagnostics"]
80    pub fn error(&self) -> Option<&CapturedError> {
81        match self {
82            Self::Healthy => None,
83            Self::Failed(e) => Some(e),
84            Self::Recovering { last_error, .. } => Some(last_error),
85        }
86    }
87
88    /// Returns true if in a failed or recovering state.
89    pub fn is_failed(&self) -> bool {
90        !matches!(self, Self::Healthy)
91    }
92
93    /// Reset to healthy state.
94    pub fn reset(&mut self) {
95        *self = Self::Healthy;
96    }
97
98    /// Attempt recovery. Returns true if recovery attempt was initiated.
99    pub fn try_recover(&mut self, max_attempts: u32) -> bool {
100        match self {
101            Self::Failed(error) => {
102                if max_attempts > 0 {
103                    *self = Self::Recovering {
104                        attempts: 1,
105                        last_error: error.clone(),
106                    };
107                    true
108                } else {
109                    false
110                }
111            }
112            Self::Recovering {
113                attempts,
114                last_error,
115            } => {
116                if *attempts < max_attempts {
117                    *attempts += 1;
118                    true
119                } else {
120                    *self = Self::Failed(last_error.clone());
121                    false
122                }
123            }
124            Self::Healthy => true,
125        }
126    }
127}
128
129/// A widget wrapper that catches panics from an inner widget.
130///
131/// When the inner widget panics during rendering, the error boundary
132/// captures the panic and renders a fallback error indicator instead.
133///
134/// Uses `StatefulWidget` so the error state persists across renders.
135///
136/// # Example
137///
138/// ```ignore
139/// let boundary = ErrorBoundary::new(my_widget, "my_widget");
140/// let mut state = ErrorBoundaryState::default();
141/// boundary.render(area, &mut buf, &mut state);
142/// ```
143#[derive(Debug, Clone)]
144pub struct ErrorBoundary<W> {
145    inner: W,
146    widget_name: &'static str,
147    max_recovery_attempts: u32,
148}
149
150impl<W: Widget> ErrorBoundary<W> {
151    /// Create a new error boundary wrapping the given widget.
152    pub fn new(inner: W, widget_name: &'static str) -> Self {
153        Self {
154            inner,
155            widget_name,
156            max_recovery_attempts: 3,
157        }
158    }
159
160    /// Set maximum recovery attempts before permanent fallback.
161    #[must_use]
162    pub fn max_recovery_attempts(mut self, max: u32) -> Self {
163        self.max_recovery_attempts = max;
164        self
165    }
166
167    /// Get the widget name.
168    pub fn widget_name(&self) -> &'static str {
169        self.widget_name
170    }
171}
172
173impl<W: Widget> StatefulWidget for ErrorBoundary<W> {
174    type State = ErrorBoundaryState;
175
176    fn render(&self, area: Rect, frame: &mut Frame, state: &mut ErrorBoundaryState) {
177        #[cfg(feature = "tracing")]
178        let _span = tracing::debug_span!(
179            "widget_render",
180            widget = "ErrorBoundary",
181            x = area.x,
182            y = area.y,
183            w = area.width,
184            h = area.height
185        )
186        .entered();
187
188        if area.is_empty() {
189            return;
190        }
191
192        match state {
193            ErrorBoundaryState::Healthy | ErrorBoundaryState::Recovering { .. } => {
194                let result = with_panic_cleanup_suppressed(|| {
195                    catch_unwind(AssertUnwindSafe(|| {
196                        self.inner.render(area, frame);
197                    }))
198                });
199
200                match result {
201                    Ok(()) => {
202                        if matches!(state, ErrorBoundaryState::Recovering { .. }) {
203                            *state = ErrorBoundaryState::Healthy;
204                        }
205                    }
206                    Err(payload) => {
207                        let error = CapturedError::from_panic(payload, self.widget_name, area);
208                        clear_area(frame, area);
209                        render_error_fallback(frame, area, &error);
210                        *state = ErrorBoundaryState::Failed(error);
211                    }
212                }
213            }
214            ErrorBoundaryState::Failed(error) => {
215                render_error_fallback(frame, area, error);
216            }
217        }
218    }
219}
220
221/// Clear an area of the buffer to spaces.
222fn clear_area(frame: &mut Frame, area: Rect) {
223    let blank = Cell::from_char(' ');
224    for y in area.y..area.y.saturating_add(area.height) {
225        for x in area.x..area.x.saturating_add(area.width) {
226            frame.buffer.set_fast(x, y, blank);
227        }
228    }
229}
230
231/// Render a fallback error indicator in the given area.
232fn render_error_fallback(frame: &mut Frame, area: Rect, error: &CapturedError) {
233    let error_fg = PackedRgba::rgb(255, 60, 60);
234    let error_bg = PackedRgba::rgb(40, 0, 0);
235    let error_style = Style::new().fg(error_fg).bg(error_bg);
236    let border_style = Style::new().fg(error_fg);
237
238    // The fallback owns the full area. Clear any stale glyphs first so the
239    // error panel fully replaces prior widget content on re-render.
240    clear_area(frame, area);
241    set_style_area(&mut frame.buffer, area, Style::new().bg(error_bg));
242
243    if area.width < 3 || area.height < 1 {
244        // Too small for border, just show "!"
245        if area.width >= 1 && area.height >= 1 {
246            let mut cell = Cell::from_char('!');
247            apply_style(&mut cell, error_style);
248            frame.buffer.set_fast(area.x, area.y, cell);
249        }
250        return;
251    }
252
253    let top = area.y;
254    let bottom = area.y.saturating_add(area.height).saturating_sub(1);
255    let left = area.x;
256    let right = area.x.saturating_add(area.width).saturating_sub(1);
257
258    // Top border
259    for x in left..=right {
260        let c = if x == left && area.height > 1 {
261            '┌'
262        } else if x == right && area.height > 1 {
263            '┐'
264        } else {
265            '─'
266        };
267        let mut cell = Cell::from_char(c);
268        apply_style(&mut cell, border_style);
269        frame.buffer.set_fast(x, top, cell);
270    }
271
272    // Bottom border
273    if area.height > 1 {
274        for x in left..=right {
275            let c = if x == left {
276                '└'
277            } else if x == right {
278                '┘'
279            } else {
280                '─'
281            };
282            let mut cell = Cell::from_char(c);
283            apply_style(&mut cell, border_style);
284            frame.buffer.set_fast(x, bottom, cell);
285        }
286    }
287
288    // Side borders
289    if area.height > 2 {
290        for y in (top + 1)..bottom {
291            let mut cell_l = Cell::from_char('│');
292            apply_style(&mut cell_l, border_style);
293            frame.buffer.set_fast(left, y, cell_l);
294
295            let mut cell_r = Cell::from_char('│');
296            apply_style(&mut cell_r, border_style);
297            frame.buffer.set_fast(right, y, cell_r);
298        }
299    }
300
301    // Title "[Error]" on top border
302    if area.width >= 9 {
303        let title_x = left.saturating_add(1);
304        draw_text_span(frame, title_x, top, "[Error]", border_style, right);
305    }
306
307    // Error message inside
308    if area.height >= 3 && area.width >= 5 {
309        let inner_left = left.saturating_add(2);
310        let inner_right = right;
311        let inner_y = top.saturating_add(1);
312        let max_chars = (inner_right.saturating_sub(inner_left)) as usize;
313
314        let msg: String = if display_width(error.message.as_str()) > max_chars.saturating_sub(2) {
315            let mut truncated = String::new();
316            let mut w = 0;
317            let limit = max_chars.saturating_sub(3);
318            for grapheme in error.message.graphemes(true) {
319                let gw = grapheme_width(grapheme);
320                if w + gw > limit {
321                    break;
322                }
323                truncated.push_str(grapheme);
324                w += gw;
325            }
326            format!("! {truncated}\u{2026}")
327        } else {
328            format!("! {}", error.message)
329        };
330
331        draw_text_span(frame, inner_left, inner_y, &msg, error_style, inner_right);
332
333        // Widget name on next line if space
334        if area.height >= 4 {
335            let name_msg = format!("  in: {}", error.widget_name);
336            let name_style = Style::new().fg(PackedRgba::rgb(180, 180, 180)).bg(error_bg);
337            draw_text_span(
338                frame,
339                inner_left,
340                inner_y.saturating_add(1),
341                &name_msg,
342                name_style,
343                inner_right,
344            );
345        }
346
347        // Retry hint on next available line
348        if area.height >= 5 {
349            let hint_style = Style::new().fg(PackedRgba::rgb(120, 120, 120)).bg(error_bg);
350            draw_text_span(
351                frame,
352                inner_left,
353                inner_y.saturating_add(2),
354                "  Press R to retry",
355                hint_style,
356                inner_right,
357            );
358        }
359    }
360}
361
362/// A standalone fallback widget for rendering error indicators.
363///
364/// Can be used independently of `ErrorBoundary` when you need to
365/// display an error state in a widget area.
366#[derive(Debug, Clone)]
367pub struct FallbackWidget {
368    error: CapturedError,
369    show_retry_hint: bool,
370}
371
372impl FallbackWidget {
373    /// Create a new fallback widget for the given error.
374    pub fn new(error: CapturedError) -> Self {
375        Self {
376            error,
377            show_retry_hint: true,
378        }
379    }
380
381    /// Create a fallback with a simple message and widget name.
382    pub fn from_message(message: impl Into<String>, widget_name: &'static str) -> Self {
383        Self::new(CapturedError {
384            message: message.into(),
385            widget_name,
386            area: Rect::default(),
387            timestamp: Instant::now(),
388        })
389    }
390
391    /// Disable the retry hint.
392    #[must_use]
393    pub fn without_retry_hint(mut self) -> Self {
394        self.show_retry_hint = false;
395        self
396    }
397}
398
399impl Widget for FallbackWidget {
400    fn render(&self, area: Rect, frame: &mut Frame) {
401        #[cfg(feature = "tracing")]
402        let _span = tracing::debug_span!(
403            "widget_render",
404            widget = "FallbackWidget",
405            x = area.x,
406            y = area.y,
407            w = area.width,
408            h = area.height
409        )
410        .entered();
411
412        if area.is_empty() {
413            return;
414        }
415        render_error_fallback(frame, area, &self.error);
416
417        // If retry hint is disabled, overwrite it with background
418        if !self.show_retry_hint && area.height >= 5 {
419            let error_bg = PackedRgba::rgb(40, 0, 0);
420            let bg_style = Style::new().bg(error_bg);
421            let inner_y = area.y.saturating_add(3);
422            let inner_left = area.x.saturating_add(2);
423            let inner_right = area.x.saturating_add(area.width).saturating_sub(1);
424            // Clear the retry hint line
425            for x in inner_left..inner_right {
426                let mut cell = Cell::new(ftui_render::cell::CellContent::from_char(' '));
427                crate::apply_style(&mut cell, bg_style);
428                frame.buffer.set_fast(x, inner_y, cell);
429            }
430        }
431    }
432}
433
434/// Type alias for custom fallback factory functions.
435pub type FallbackFactory = Box<dyn Fn(&CapturedError) -> FallbackWidget + Send + Sync>;
436
437/// A widget wrapper with custom fallback support.
438///
439/// Like `ErrorBoundary`, but accepts a custom factory for producing
440/// fallback widgets when the inner widget panics.
441pub struct CustomErrorBoundary<W> {
442    inner: W,
443    widget_name: &'static str,
444    max_recovery_attempts: u32,
445    fallback_factory: Option<FallbackFactory>,
446}
447
448impl<W: Widget> CustomErrorBoundary<W> {
449    /// Create with a custom fallback factory.
450    pub fn new(inner: W, widget_name: &'static str) -> Self {
451        Self {
452            inner,
453            widget_name,
454            max_recovery_attempts: 3,
455            fallback_factory: None,
456        }
457    }
458
459    /// Set the fallback factory.
460    #[must_use]
461    pub fn fallback_factory(
462        mut self,
463        factory: impl Fn(&CapturedError) -> FallbackWidget + Send + Sync + 'static,
464    ) -> Self {
465        self.fallback_factory = Some(Box::new(factory));
466        self
467    }
468
469    /// Set maximum recovery attempts.
470    #[must_use]
471    pub fn max_recovery_attempts(mut self, max: u32) -> Self {
472        self.max_recovery_attempts = max;
473        self
474    }
475}
476
477impl<W: Widget> StatefulWidget for CustomErrorBoundary<W> {
478    type State = ErrorBoundaryState;
479
480    fn render(&self, area: Rect, frame: &mut Frame, state: &mut ErrorBoundaryState) {
481        #[cfg(feature = "tracing")]
482        let _span = tracing::debug_span!(
483            "widget_render",
484            widget = "CustomErrorBoundary",
485            x = area.x,
486            y = area.y,
487            w = area.width,
488            h = area.height
489        )
490        .entered();
491
492        if area.is_empty() {
493            return;
494        }
495
496        match state {
497            ErrorBoundaryState::Healthy | ErrorBoundaryState::Recovering { .. } => {
498                let result = with_panic_cleanup_suppressed(|| {
499                    catch_unwind(AssertUnwindSafe(|| {
500                        self.inner.render(area, frame);
501                    }))
502                });
503
504                match result {
505                    Ok(()) => {
506                        if matches!(state, ErrorBoundaryState::Recovering { .. }) {
507                            *state = ErrorBoundaryState::Healthy;
508                        }
509                    }
510                    Err(payload) => {
511                        let error = CapturedError::from_panic(payload, self.widget_name, area);
512                        clear_area(frame, area);
513                        if let Some(factory) = &self.fallback_factory {
514                            let fallback = factory(&error);
515                            fallback.render(area, frame);
516                        } else {
517                            render_error_fallback(frame, area, &error);
518                        }
519                        *state = ErrorBoundaryState::Failed(error);
520                    }
521                }
522            }
523            ErrorBoundaryState::Failed(error) => {
524                if let Some(factory) = &self.fallback_factory {
525                    let fallback = factory(error);
526                    fallback.render(area, frame);
527                } else {
528                    render_error_fallback(frame, area, error);
529                }
530            }
531        }
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use ftui_render::grapheme_pool::GraphemePool;
539
540    struct PanickingWidget;
541
542    impl Widget for PanickingWidget {
543        fn render(&self, _area: Rect, _frame: &mut Frame) {
544            unreachable!("widget exploded");
545        }
546    }
547
548    struct GoodWidget;
549
550    impl Widget for GoodWidget {
551        fn render(&self, area: Rect, frame: &mut Frame) {
552            if area.width > 0 && area.height > 0 {
553                frame.buffer.set(area.x, area.y, Cell::from_char('G'));
554            }
555        }
556    }
557
558    #[test]
559    fn healthy_widget_renders_normally() {
560        let boundary = ErrorBoundary::new(GoodWidget, "good");
561        let mut state = ErrorBoundaryState::default();
562        let area = Rect::new(0, 0, 10, 5);
563        let mut pool = GraphemePool::new();
564        let mut frame = Frame::new(10, 5, &mut pool);
565
566        boundary.render(area, &mut frame, &mut state);
567
568        assert!(!state.is_failed());
569        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('G'));
570    }
571
572    #[test]
573    fn catches_panic_without_propagating() {
574        let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
575        let mut state = ErrorBoundaryState::default();
576        let area = Rect::new(0, 0, 30, 5);
577        let mut pool = GraphemePool::new();
578        let mut frame = Frame::new(30, 5, &mut pool);
579
580        boundary.render(area, &mut frame, &mut state);
581
582        assert!(state.is_failed());
583        let err = state.error().unwrap();
584        assert_eq!(err.message, "widget exploded");
585        assert_eq!(err.widget_name, "panicker");
586    }
587
588    #[test]
589    fn failed_state_shows_fallback_on_rerender() {
590        let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
591        let mut state = ErrorBoundaryState::default();
592        let area = Rect::new(0, 0, 30, 5);
593        let mut pool = GraphemePool::new();
594        let mut frame = Frame::new(30, 5, &mut pool);
595
596        boundary.render(area, &mut frame, &mut state);
597
598        // Second render shows fallback without re-trying
599        let mut pool2 = GraphemePool::new();
600        let mut frame2 = Frame::new(30, 5, &mut pool2);
601        boundary.render(area, &mut frame2, &mut state);
602
603        assert!(state.is_failed());
604        assert_eq!(
605            frame2.buffer.get(0, 0).unwrap().content.as_char(),
606            Some('┌')
607        );
608    }
609
610    #[test]
611    fn recovery_resets_on_success() {
612        let good = ErrorBoundary::new(GoodWidget, "good");
613        let mut state = ErrorBoundaryState::Failed(CapturedError {
614            message: "old error".to_string(),
615            widget_name: "old",
616            area: Rect::new(0, 0, 10, 5),
617            timestamp: Instant::now(),
618        });
619
620        assert!(state.try_recover(3));
621        assert!(matches!(state, ErrorBoundaryState::Recovering { .. }));
622
623        let area = Rect::new(0, 0, 10, 5);
624        let mut pool = GraphemePool::new();
625        let mut frame = Frame::new(10, 5, &mut pool);
626        good.render(area, &mut frame, &mut state);
627
628        assert!(!state.is_failed());
629        assert!(matches!(state, ErrorBoundaryState::Healthy));
630    }
631
632    #[test]
633    fn recovery_respects_max_attempts() {
634        let mut state = ErrorBoundaryState::Failed(CapturedError {
635            message: "error".to_string(),
636            widget_name: "w",
637            area: Rect::new(0, 0, 1, 1),
638            timestamp: Instant::now(),
639        });
640
641        assert!(state.try_recover(2));
642        assert!(matches!(
643            state,
644            ErrorBoundaryState::Recovering { attempts: 1, .. }
645        ));
646
647        assert!(state.try_recover(2));
648        assert!(matches!(
649            state,
650            ErrorBoundaryState::Recovering { attempts: 2, .. }
651        ));
652
653        assert!(!state.try_recover(2));
654        assert!(matches!(state, ErrorBoundaryState::Failed(_)));
655    }
656
657    #[test]
658    fn zero_max_recovery_denies_immediately() {
659        let mut state = ErrorBoundaryState::Failed(CapturedError {
660            message: "error".to_string(),
661            widget_name: "w",
662            area: Rect::new(0, 0, 1, 1),
663            timestamp: Instant::now(),
664        });
665
666        assert!(!state.try_recover(0));
667        assert!(matches!(state, ErrorBoundaryState::Failed(_)));
668    }
669
670    #[test]
671    fn reset_clears_error() {
672        let mut state = ErrorBoundaryState::Failed(CapturedError {
673            message: "error".to_string(),
674            widget_name: "w",
675            area: Rect::new(0, 0, 1, 1),
676            timestamp: Instant::now(),
677        });
678
679        state.reset();
680        assert!(!state.is_failed());
681        assert!(matches!(state, ErrorBoundaryState::Healthy));
682    }
683
684    #[test]
685    fn empty_area_is_noop() {
686        let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
687        let mut state = ErrorBoundaryState::default();
688        let area = Rect::new(0, 0, 0, 0);
689        let mut pool = GraphemePool::new();
690        let mut frame = Frame::new(1, 1, &mut pool);
691
692        boundary.render(area, &mut frame, &mut state);
693
694        assert!(!state.is_failed());
695    }
696
697    #[test]
698    fn small_area_shows_minimal_fallback() {
699        let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
700        let mut state = ErrorBoundaryState::default();
701        let area = Rect::new(0, 0, 2, 1);
702        let mut pool = GraphemePool::new();
703        let mut frame = Frame::new(2, 1, &mut pool);
704
705        boundary.render(area, &mut frame, &mut state);
706
707        assert!(state.is_failed());
708        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
709    }
710
711    #[test]
712    fn captured_error_extracts_string_panic() {
713        let payload: Box<dyn std::any::Any + Send> = Box::new("test error".to_string());
714        let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
715        assert_eq!(error.message, "test error");
716    }
717
718    #[test]
719    fn captured_error_extracts_str_panic() {
720        let payload: Box<dyn std::any::Any + Send> = Box::new("static error");
721        let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
722        assert_eq!(error.message, "static error");
723    }
724
725    #[test]
726    fn captured_error_handles_unknown_panic() {
727        let payload: Box<dyn std::any::Any + Send> = Box::new(42u32);
728        let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
729        assert_eq!(error.message, "unknown panic");
730    }
731
732    #[test]
733    fn failed_state_renders_fallback_directly() {
734        let boundary = ErrorBoundary::new(GoodWidget, "good");
735        let mut state = ErrorBoundaryState::Failed(CapturedError {
736            message: "previous error".to_string(),
737            widget_name: "other",
738            area: Rect::new(0, 0, 30, 5),
739            timestamp: Instant::now(),
740        });
741
742        let area = Rect::new(0, 0, 30, 5);
743        let mut pool = GraphemePool::new();
744        let mut frame = Frame::new(30, 5, &mut pool);
745        boundary.render(area, &mut frame, &mut state);
746
747        assert!(state.is_failed());
748        // Should see border, not 'G'
749        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
750    }
751
752    #[test]
753    fn failed_state_rerender_clears_stale_content() {
754        let boundary = ErrorBoundary::new(GoodWidget, "good");
755        let mut state = ErrorBoundaryState::Failed(CapturedError {
756            message: "err".to_string(),
757            widget_name: "other",
758            area: Rect::new(0, 0, 30, 5),
759            timestamp: Instant::now(),
760        });
761
762        let area = Rect::new(0, 0, 30, 5);
763        let mut pool = GraphemePool::new();
764        let mut frame = Frame::new(30, 5, &mut pool);
765        for y in area.y..area.bottom() {
766            for x in area.x..area.right() {
767                frame.buffer.set(x, y, Cell::from_char('X'));
768            }
769        }
770
771        boundary.render(area, &mut frame, &mut state);
772
773        assert_eq!(
774            frame.buffer.get(20, 1).unwrap().content.as_char(),
775            Some(' ')
776        );
777    }
778
779    #[test]
780    fn fallback_widget_renders_standalone() {
781        let fallback = FallbackWidget::from_message("render failed", "my_widget");
782        let area = Rect::new(0, 0, 30, 5);
783        let mut pool = GraphemePool::new();
784        let mut frame = Frame::new(30, 5, &mut pool);
785        fallback.render(area, &mut frame);
786
787        // Should show error border
788        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
789    }
790
791    #[test]
792    fn fallback_widget_clears_stale_content() {
793        let fallback = FallbackWidget::from_message("err", "w");
794        let area = Rect::new(0, 0, 30, 5);
795        let mut pool = GraphemePool::new();
796        let mut frame = Frame::new(30, 5, &mut pool);
797        for y in area.y..area.bottom() {
798            for x in area.x..area.right() {
799                frame.buffer.set(x, y, Cell::from_char('X'));
800            }
801        }
802
803        fallback.render(area, &mut frame);
804
805        assert_eq!(
806            frame.buffer.get(20, 1).unwrap().content.as_char(),
807            Some(' ')
808        );
809    }
810
811    #[test]
812    fn fallback_widget_without_retry_hint() {
813        let fallback = FallbackWidget::from_message("error", "w").without_retry_hint();
814        let area = Rect::new(0, 0, 30, 6);
815        let mut pool = GraphemePool::new();
816        let mut frame = Frame::new(30, 6, &mut pool);
817        fallback.render(area, &mut frame);
818
819        // Retry hint line (y=3) should be blank spaces, not text
820        // The hint would be at inner_y + 2 = area.y + 3 = 3
821        let hint_cell = frame.buffer.get(4, 3).unwrap();
822        assert_eq!(hint_cell.content.as_char(), Some(' '));
823    }
824
825    #[test]
826    fn fallback_widget_empty_area() {
827        let fallback = FallbackWidget::from_message("error", "w");
828        let area = Rect::new(0, 0, 0, 0);
829        let mut pool = GraphemePool::new();
830        let mut frame = Frame::new(1, 1, &mut pool);
831        fallback.render(area, &mut frame);
832        // Should not panic
833    }
834
835    #[test]
836    fn custom_error_boundary_uses_factory() {
837        let boundary =
838            CustomErrorBoundary::new(PanickingWidget, "panicker").fallback_factory(|error| {
839                FallbackWidget::from_message(
840                    format!("CUSTOM: {}", error.message),
841                    error.widget_name,
842                )
843                .without_retry_hint()
844            });
845        let mut state = ErrorBoundaryState::default();
846        let area = Rect::new(0, 0, 40, 5);
847        let mut pool = GraphemePool::new();
848        let mut frame = Frame::new(40, 5, &mut pool);
849        boundary.render(area, &mut frame, &mut state);
850
851        assert!(state.is_failed());
852        // Should show the custom error (border should still appear)
853        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
854    }
855
856    #[test]
857    fn custom_error_boundary_default_fallback() {
858        let boundary = CustomErrorBoundary::new(PanickingWidget, "panicker");
859        let mut state = ErrorBoundaryState::default();
860        let area = Rect::new(0, 0, 30, 5);
861        let mut pool = GraphemePool::new();
862        let mut frame = Frame::new(30, 5, &mut pool);
863        boundary.render(area, &mut frame, &mut state);
864
865        assert!(state.is_failed());
866        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
867    }
868
869    #[test]
870    fn retry_hint_shows_in_tall_area() {
871        let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
872        let mut state = ErrorBoundaryState::default();
873        let area = Rect::new(0, 0, 30, 6);
874        let mut pool = GraphemePool::new();
875        let mut frame = Frame::new(30, 6, &mut pool);
876        boundary.render(area, &mut frame, &mut state);
877
878        assert!(state.is_failed());
879        // Retry hint at inner_y + 2 = 3
880        // The text "Press R to retry" starts at inner_left (x=2) + 2 spaces
881        // "  Press R to retry" -> 'P' at x=4
882        let p_cell = frame.buffer.get(4, 3).unwrap();
883        assert_eq!(p_cell.content.as_char(), Some('P'));
884    }
885
886    #[test]
887    fn error_in_sibling_does_not_affect_other() {
888        let bad = ErrorBoundary::new(PanickingWidget, "bad");
889        let good = ErrorBoundary::new(GoodWidget, "good");
890        let mut bad_state = ErrorBoundaryState::default();
891        let mut good_state = ErrorBoundaryState::default();
892
893        let mut pool = GraphemePool::new();
894        let mut frame = Frame::new(20, 5, &mut pool);
895        let area_a = Rect::new(0, 0, 10, 5);
896        let area_b = Rect::new(10, 0, 10, 5);
897
898        bad.render(area_a, &mut frame, &mut bad_state);
899        good.render(area_b, &mut frame, &mut good_state);
900
901        assert!(bad_state.is_failed());
902        assert!(!good_state.is_failed());
903        assert_eq!(
904            frame.buffer.get(10, 0).unwrap().content.as_char(),
905            Some('G')
906        );
907    }
908
909    #[test]
910    fn max_recovery_attempts_builder() {
911        let boundary = ErrorBoundary::new(GoodWidget, "good").max_recovery_attempts(5);
912        assert_eq!(boundary.max_recovery_attempts, 5);
913    }
914
915    #[test]
916    fn widget_name_accessor() {
917        let boundary = ErrorBoundary::new(GoodWidget, "my_widget");
918        assert_eq!(boundary.widget_name(), "my_widget");
919    }
920
921    #[test]
922    fn error_state_error_accessor_recovering() {
923        let err = CapturedError {
924            message: "fail".to_string(),
925            widget_name: "w",
926            area: Rect::new(0, 0, 1, 1),
927            timestamp: Instant::now(),
928        };
929        let state = ErrorBoundaryState::Recovering {
930            attempts: 2,
931            last_error: err,
932        };
933        assert!(state.is_failed());
934        assert_eq!(state.error().unwrap().message, "fail");
935    }
936
937    #[test]
938    fn try_recover_on_healthy_returns_true() {
939        let mut state = ErrorBoundaryState::Healthy;
940        assert!(state.try_recover(3));
941        assert!(matches!(state, ErrorBoundaryState::Healthy));
942    }
943
944    #[test]
945    fn captured_error_strips_unreachable_prefix() {
946        let msg = "internal error: entered unreachable code: widget exploded";
947        let payload: Box<dyn std::any::Any + Send> = Box::new(msg.to_string());
948        let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
949        assert_eq!(error.message, "widget exploded");
950    }
951
952    #[test]
953    fn default_state_is_healthy() {
954        let state = ErrorBoundaryState::default();
955        assert!(!state.is_failed());
956        assert!(state.error().is_none());
957    }
958
959    #[test]
960    fn custom_boundary_max_recovery_builder() {
961        let boundary = CustomErrorBoundary::new(GoodWidget, "good").max_recovery_attempts(7);
962        assert_eq!(boundary.max_recovery_attempts, 7);
963    }
964
965    #[test]
966    fn fallback_widget_new_directly() {
967        let err = CapturedError {
968            message: "direct error".to_string(),
969            widget_name: "direct",
970            area: Rect::new(0, 0, 10, 5),
971            timestamp: Instant::now(),
972        };
973        let fallback = FallbackWidget::new(err);
974        assert!(fallback.show_retry_hint);
975        assert_eq!(fallback.error.message, "direct error");
976    }
977
978    #[test]
979    fn recovering_state_panics_revert_to_failed() {
980        let boundary = ErrorBoundary::new(PanickingWidget, "bad");
981        let err = CapturedError {
982            message: "initial".to_string(),
983            widget_name: "bad",
984            area: Rect::new(0, 0, 30, 5),
985            timestamp: Instant::now(),
986        };
987        let mut state = ErrorBoundaryState::Recovering {
988            attempts: 1,
989            last_error: err,
990        };
991
992        let area = Rect::new(0, 0, 30, 5);
993        let mut pool = GraphemePool::new();
994        let mut frame = Frame::new(30, 5, &mut pool);
995        boundary.render(area, &mut frame, &mut state);
996
997        // Panic during recovery should set state to Failed.
998        assert!(matches!(state, ErrorBoundaryState::Failed(_)));
999    }
1000}