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    set_style_area(&mut frame.buffer, area, Style::new().bg(error_bg));
239
240    if area.width < 3 || area.height < 1 {
241        // Too small for border, just show "!"
242        if area.width >= 1 && area.height >= 1 {
243            let mut cell = Cell::from_char('!');
244            apply_style(&mut cell, error_style);
245            frame.buffer.set_fast(area.x, area.y, cell);
246        }
247        return;
248    }
249
250    let top = area.y;
251    let bottom = area.y.saturating_add(area.height).saturating_sub(1);
252    let left = area.x;
253    let right = area.x.saturating_add(area.width).saturating_sub(1);
254
255    // Top border
256    for x in left..=right {
257        let c = if x == left && area.height > 1 {
258            '┌'
259        } else if x == right && area.height > 1 {
260            '┐'
261        } else {
262            '─'
263        };
264        let mut cell = Cell::from_char(c);
265        apply_style(&mut cell, border_style);
266        frame.buffer.set_fast(x, top, cell);
267    }
268
269    // Bottom border
270    if area.height > 1 {
271        for x in left..=right {
272            let c = if x == left {
273                '└'
274            } else if x == right {
275                '┘'
276            } else {
277                '─'
278            };
279            let mut cell = Cell::from_char(c);
280            apply_style(&mut cell, border_style);
281            frame.buffer.set_fast(x, bottom, cell);
282        }
283    }
284
285    // Side borders
286    if area.height > 2 {
287        for y in (top + 1)..bottom {
288            let mut cell_l = Cell::from_char('│');
289            apply_style(&mut cell_l, border_style);
290            frame.buffer.set_fast(left, y, cell_l);
291
292            let mut cell_r = Cell::from_char('│');
293            apply_style(&mut cell_r, border_style);
294            frame.buffer.set_fast(right, y, cell_r);
295        }
296    }
297
298    // Title "[Error]" on top border
299    if area.width >= 9 {
300        let title_x = left.saturating_add(1);
301        draw_text_span(frame, title_x, top, "[Error]", border_style, right);
302    }
303
304    // Error message inside
305    if area.height >= 3 && area.width >= 5 {
306        let inner_left = left.saturating_add(2);
307        let inner_right = right;
308        let inner_y = top.saturating_add(1);
309        let max_chars = (inner_right.saturating_sub(inner_left)) as usize;
310
311        let msg: String = if display_width(error.message.as_str()) > max_chars.saturating_sub(2) {
312            let mut truncated = String::new();
313            let mut w = 0;
314            let limit = max_chars.saturating_sub(3);
315            for grapheme in error.message.graphemes(true) {
316                let gw = grapheme_width(grapheme);
317                if w + gw > limit {
318                    break;
319                }
320                truncated.push_str(grapheme);
321                w += gw;
322            }
323            format!("! {truncated}\u{2026}")
324        } else {
325            format!("! {}", error.message)
326        };
327
328        draw_text_span(frame, inner_left, inner_y, &msg, error_style, inner_right);
329
330        // Widget name on next line if space
331        if area.height >= 4 {
332            let name_msg = format!("  in: {}", error.widget_name);
333            let name_style = Style::new().fg(PackedRgba::rgb(180, 180, 180)).bg(error_bg);
334            draw_text_span(
335                frame,
336                inner_left,
337                inner_y.saturating_add(1),
338                &name_msg,
339                name_style,
340                inner_right,
341            );
342        }
343
344        // Retry hint on next available line
345        if area.height >= 5 {
346            let hint_style = Style::new().fg(PackedRgba::rgb(120, 120, 120)).bg(error_bg);
347            draw_text_span(
348                frame,
349                inner_left,
350                inner_y.saturating_add(2),
351                "  Press R to retry",
352                hint_style,
353                inner_right,
354            );
355        }
356    }
357}
358
359/// A standalone fallback widget for rendering error indicators.
360///
361/// Can be used independently of `ErrorBoundary` when you need to
362/// display an error state in a widget area.
363#[derive(Debug, Clone)]
364pub struct FallbackWidget {
365    error: CapturedError,
366    show_retry_hint: bool,
367}
368
369impl FallbackWidget {
370    /// Create a new fallback widget for the given error.
371    pub fn new(error: CapturedError) -> Self {
372        Self {
373            error,
374            show_retry_hint: true,
375        }
376    }
377
378    /// Create a fallback with a simple message and widget name.
379    pub fn from_message(message: impl Into<String>, widget_name: &'static str) -> Self {
380        Self::new(CapturedError {
381            message: message.into(),
382            widget_name,
383            area: Rect::default(),
384            timestamp: Instant::now(),
385        })
386    }
387
388    /// Disable the retry hint.
389    #[must_use]
390    pub fn without_retry_hint(mut self) -> Self {
391        self.show_retry_hint = false;
392        self
393    }
394}
395
396impl Widget for FallbackWidget {
397    fn render(&self, area: Rect, frame: &mut Frame) {
398        #[cfg(feature = "tracing")]
399        let _span = tracing::debug_span!(
400            "widget_render",
401            widget = "FallbackWidget",
402            x = area.x,
403            y = area.y,
404            w = area.width,
405            h = area.height
406        )
407        .entered();
408
409        if area.is_empty() {
410            return;
411        }
412        render_error_fallback(frame, area, &self.error);
413
414        // If retry hint is disabled, overwrite it with background
415        if !self.show_retry_hint && area.height >= 5 {
416            let error_bg = PackedRgba::rgb(40, 0, 0);
417            let bg_style = Style::new().bg(error_bg);
418            let inner_y = area.y.saturating_add(3);
419            let inner_left = area.x.saturating_add(2);
420            let inner_right = area.x.saturating_add(area.width).saturating_sub(1);
421            // Clear the retry hint line
422            for x in inner_left..inner_right {
423                if let Some(cell) = frame.buffer.get_mut(x, inner_y) {
424                    cell.content = ftui_render::cell::CellContent::from_char(' ');
425                    apply_style(cell, bg_style);
426                }
427            }
428        }
429    }
430}
431
432/// Type alias for custom fallback factory functions.
433pub type FallbackFactory = Box<dyn Fn(&CapturedError) -> FallbackWidget + Send + Sync>;
434
435/// A widget wrapper with custom fallback support.
436///
437/// Like `ErrorBoundary`, but accepts a custom factory for producing
438/// fallback widgets when the inner widget panics.
439pub struct CustomErrorBoundary<W> {
440    inner: W,
441    widget_name: &'static str,
442    max_recovery_attempts: u32,
443    fallback_factory: Option<FallbackFactory>,
444}
445
446impl<W: Widget> CustomErrorBoundary<W> {
447    /// Create with a custom fallback factory.
448    pub fn new(inner: W, widget_name: &'static str) -> Self {
449        Self {
450            inner,
451            widget_name,
452            max_recovery_attempts: 3,
453            fallback_factory: None,
454        }
455    }
456
457    /// Set the fallback factory.
458    #[must_use]
459    pub fn fallback_factory(
460        mut self,
461        factory: impl Fn(&CapturedError) -> FallbackWidget + Send + Sync + 'static,
462    ) -> Self {
463        self.fallback_factory = Some(Box::new(factory));
464        self
465    }
466
467    /// Set maximum recovery attempts.
468    #[must_use]
469    pub fn max_recovery_attempts(mut self, max: u32) -> Self {
470        self.max_recovery_attempts = max;
471        self
472    }
473}
474
475impl<W: Widget> StatefulWidget for CustomErrorBoundary<W> {
476    type State = ErrorBoundaryState;
477
478    fn render(&self, area: Rect, frame: &mut Frame, state: &mut ErrorBoundaryState) {
479        #[cfg(feature = "tracing")]
480        let _span = tracing::debug_span!(
481            "widget_render",
482            widget = "CustomErrorBoundary",
483            x = area.x,
484            y = area.y,
485            w = area.width,
486            h = area.height
487        )
488        .entered();
489
490        if area.is_empty() {
491            return;
492        }
493
494        match state {
495            ErrorBoundaryState::Healthy | ErrorBoundaryState::Recovering { .. } => {
496                let result = with_panic_cleanup_suppressed(|| {
497                    catch_unwind(AssertUnwindSafe(|| {
498                        self.inner.render(area, frame);
499                    }))
500                });
501
502                match result {
503                    Ok(()) => {
504                        if matches!(state, ErrorBoundaryState::Recovering { .. }) {
505                            *state = ErrorBoundaryState::Healthy;
506                        }
507                    }
508                    Err(payload) => {
509                        let error = CapturedError::from_panic(payload, self.widget_name, area);
510                        clear_area(frame, area);
511                        if let Some(factory) = &self.fallback_factory {
512                            let fallback = factory(&error);
513                            fallback.render(area, frame);
514                        } else {
515                            render_error_fallback(frame, area, &error);
516                        }
517                        *state = ErrorBoundaryState::Failed(error);
518                    }
519                }
520            }
521            ErrorBoundaryState::Failed(error) => {
522                if let Some(factory) = &self.fallback_factory {
523                    let fallback = factory(error);
524                    fallback.render(area, frame);
525                } else {
526                    render_error_fallback(frame, area, error);
527                }
528            }
529        }
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use ftui_render::grapheme_pool::GraphemePool;
537
538    struct PanickingWidget;
539
540    impl Widget for PanickingWidget {
541        fn render(&self, _area: Rect, _frame: &mut Frame) {
542            unreachable!("widget exploded");
543        }
544    }
545
546    struct GoodWidget;
547
548    impl Widget for GoodWidget {
549        fn render(&self, area: Rect, frame: &mut Frame) {
550            if area.width > 0 && area.height > 0 {
551                frame.buffer.set(area.x, area.y, Cell::from_char('G'));
552            }
553        }
554    }
555
556    #[test]
557    fn healthy_widget_renders_normally() {
558        let boundary = ErrorBoundary::new(GoodWidget, "good");
559        let mut state = ErrorBoundaryState::default();
560        let area = Rect::new(0, 0, 10, 5);
561        let mut pool = GraphemePool::new();
562        let mut frame = Frame::new(10, 5, &mut pool);
563
564        boundary.render(area, &mut frame, &mut state);
565
566        assert!(!state.is_failed());
567        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('G'));
568    }
569
570    #[test]
571    fn catches_panic_without_propagating() {
572        let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
573        let mut state = ErrorBoundaryState::default();
574        let area = Rect::new(0, 0, 30, 5);
575        let mut pool = GraphemePool::new();
576        let mut frame = Frame::new(30, 5, &mut pool);
577
578        boundary.render(area, &mut frame, &mut state);
579
580        assert!(state.is_failed());
581        let err = state.error().unwrap();
582        assert_eq!(err.message, "widget exploded");
583        assert_eq!(err.widget_name, "panicker");
584    }
585
586    #[test]
587    fn failed_state_shows_fallback_on_rerender() {
588        let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
589        let mut state = ErrorBoundaryState::default();
590        let area = Rect::new(0, 0, 30, 5);
591        let mut pool = GraphemePool::new();
592        let mut frame = Frame::new(30, 5, &mut pool);
593
594        boundary.render(area, &mut frame, &mut state);
595
596        // Second render shows fallback without re-trying
597        let mut pool2 = GraphemePool::new();
598        let mut frame2 = Frame::new(30, 5, &mut pool2);
599        boundary.render(area, &mut frame2, &mut state);
600
601        assert!(state.is_failed());
602        assert_eq!(
603            frame2.buffer.get(0, 0).unwrap().content.as_char(),
604            Some('┌')
605        );
606    }
607
608    #[test]
609    fn recovery_resets_on_success() {
610        let good = ErrorBoundary::new(GoodWidget, "good");
611        let mut state = ErrorBoundaryState::Failed(CapturedError {
612            message: "old error".to_string(),
613            widget_name: "old",
614            area: Rect::new(0, 0, 10, 5),
615            timestamp: Instant::now(),
616        });
617
618        assert!(state.try_recover(3));
619        assert!(matches!(state, ErrorBoundaryState::Recovering { .. }));
620
621        let area = Rect::new(0, 0, 10, 5);
622        let mut pool = GraphemePool::new();
623        let mut frame = Frame::new(10, 5, &mut pool);
624        good.render(area, &mut frame, &mut state);
625
626        assert!(!state.is_failed());
627        assert!(matches!(state, ErrorBoundaryState::Healthy));
628    }
629
630    #[test]
631    fn recovery_respects_max_attempts() {
632        let mut state = ErrorBoundaryState::Failed(CapturedError {
633            message: "error".to_string(),
634            widget_name: "w",
635            area: Rect::new(0, 0, 1, 1),
636            timestamp: Instant::now(),
637        });
638
639        assert!(state.try_recover(2));
640        assert!(matches!(
641            state,
642            ErrorBoundaryState::Recovering { attempts: 1, .. }
643        ));
644
645        assert!(state.try_recover(2));
646        assert!(matches!(
647            state,
648            ErrorBoundaryState::Recovering { attempts: 2, .. }
649        ));
650
651        assert!(!state.try_recover(2));
652        assert!(matches!(state, ErrorBoundaryState::Failed(_)));
653    }
654
655    #[test]
656    fn zero_max_recovery_denies_immediately() {
657        let mut state = ErrorBoundaryState::Failed(CapturedError {
658            message: "error".to_string(),
659            widget_name: "w",
660            area: Rect::new(0, 0, 1, 1),
661            timestamp: Instant::now(),
662        });
663
664        assert!(!state.try_recover(0));
665        assert!(matches!(state, ErrorBoundaryState::Failed(_)));
666    }
667
668    #[test]
669    fn reset_clears_error() {
670        let mut state = ErrorBoundaryState::Failed(CapturedError {
671            message: "error".to_string(),
672            widget_name: "w",
673            area: Rect::new(0, 0, 1, 1),
674            timestamp: Instant::now(),
675        });
676
677        state.reset();
678        assert!(!state.is_failed());
679        assert!(matches!(state, ErrorBoundaryState::Healthy));
680    }
681
682    #[test]
683    fn empty_area_is_noop() {
684        let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
685        let mut state = ErrorBoundaryState::default();
686        let area = Rect::new(0, 0, 0, 0);
687        let mut pool = GraphemePool::new();
688        let mut frame = Frame::new(1, 1, &mut pool);
689
690        boundary.render(area, &mut frame, &mut state);
691
692        assert!(!state.is_failed());
693    }
694
695    #[test]
696    fn small_area_shows_minimal_fallback() {
697        let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
698        let mut state = ErrorBoundaryState::default();
699        let area = Rect::new(0, 0, 2, 1);
700        let mut pool = GraphemePool::new();
701        let mut frame = Frame::new(2, 1, &mut pool);
702
703        boundary.render(area, &mut frame, &mut state);
704
705        assert!(state.is_failed());
706        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
707    }
708
709    #[test]
710    fn captured_error_extracts_string_panic() {
711        let payload: Box<dyn std::any::Any + Send> = Box::new("test error".to_string());
712        let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
713        assert_eq!(error.message, "test error");
714    }
715
716    #[test]
717    fn captured_error_extracts_str_panic() {
718        let payload: Box<dyn std::any::Any + Send> = Box::new("static error");
719        let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
720        assert_eq!(error.message, "static error");
721    }
722
723    #[test]
724    fn captured_error_handles_unknown_panic() {
725        let payload: Box<dyn std::any::Any + Send> = Box::new(42u32);
726        let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
727        assert_eq!(error.message, "unknown panic");
728    }
729
730    #[test]
731    fn failed_state_renders_fallback_directly() {
732        let boundary = ErrorBoundary::new(GoodWidget, "good");
733        let mut state = ErrorBoundaryState::Failed(CapturedError {
734            message: "previous error".to_string(),
735            widget_name: "other",
736            area: Rect::new(0, 0, 30, 5),
737            timestamp: Instant::now(),
738        });
739
740        let area = Rect::new(0, 0, 30, 5);
741        let mut pool = GraphemePool::new();
742        let mut frame = Frame::new(30, 5, &mut pool);
743        boundary.render(area, &mut frame, &mut state);
744
745        assert!(state.is_failed());
746        // Should see border, not 'G'
747        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
748    }
749
750    #[test]
751    fn fallback_widget_renders_standalone() {
752        let fallback = FallbackWidget::from_message("render failed", "my_widget");
753        let area = Rect::new(0, 0, 30, 5);
754        let mut pool = GraphemePool::new();
755        let mut frame = Frame::new(30, 5, &mut pool);
756        fallback.render(area, &mut frame);
757
758        // Should show error border
759        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
760    }
761
762    #[test]
763    fn fallback_widget_without_retry_hint() {
764        let fallback = FallbackWidget::from_message("error", "w").without_retry_hint();
765        let area = Rect::new(0, 0, 30, 6);
766        let mut pool = GraphemePool::new();
767        let mut frame = Frame::new(30, 6, &mut pool);
768        fallback.render(area, &mut frame);
769
770        // Retry hint line (y=3) should be blank spaces, not text
771        // The hint would be at inner_y + 2 = area.y + 3 = 3
772        let hint_cell = frame.buffer.get(4, 3).unwrap();
773        assert_eq!(hint_cell.content.as_char(), Some(' '));
774    }
775
776    #[test]
777    fn fallback_widget_empty_area() {
778        let fallback = FallbackWidget::from_message("error", "w");
779        let area = Rect::new(0, 0, 0, 0);
780        let mut pool = GraphemePool::new();
781        let mut frame = Frame::new(1, 1, &mut pool);
782        fallback.render(area, &mut frame);
783        // Should not panic
784    }
785
786    #[test]
787    fn custom_error_boundary_uses_factory() {
788        let boundary =
789            CustomErrorBoundary::new(PanickingWidget, "panicker").fallback_factory(|error| {
790                FallbackWidget::from_message(
791                    format!("CUSTOM: {}", error.message),
792                    error.widget_name,
793                )
794                .without_retry_hint()
795            });
796        let mut state = ErrorBoundaryState::default();
797        let area = Rect::new(0, 0, 40, 5);
798        let mut pool = GraphemePool::new();
799        let mut frame = Frame::new(40, 5, &mut pool);
800        boundary.render(area, &mut frame, &mut state);
801
802        assert!(state.is_failed());
803        // Should show the custom error (border should still appear)
804        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
805    }
806
807    #[test]
808    fn custom_error_boundary_default_fallback() {
809        let boundary = CustomErrorBoundary::new(PanickingWidget, "panicker");
810        let mut state = ErrorBoundaryState::default();
811        let area = Rect::new(0, 0, 30, 5);
812        let mut pool = GraphemePool::new();
813        let mut frame = Frame::new(30, 5, &mut pool);
814        boundary.render(area, &mut frame, &mut state);
815
816        assert!(state.is_failed());
817        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
818    }
819
820    #[test]
821    fn retry_hint_shows_in_tall_area() {
822        let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
823        let mut state = ErrorBoundaryState::default();
824        let area = Rect::new(0, 0, 30, 6);
825        let mut pool = GraphemePool::new();
826        let mut frame = Frame::new(30, 6, &mut pool);
827        boundary.render(area, &mut frame, &mut state);
828
829        assert!(state.is_failed());
830        // Retry hint at inner_y + 2 = 3
831        // The text "Press R to retry" starts at inner_left (x=2) + 2 spaces
832        // "  Press R to retry" -> 'P' at x=4
833        let p_cell = frame.buffer.get(4, 3).unwrap();
834        assert_eq!(p_cell.content.as_char(), Some('P'));
835    }
836
837    #[test]
838    fn error_in_sibling_does_not_affect_other() {
839        let bad = ErrorBoundary::new(PanickingWidget, "bad");
840        let good = ErrorBoundary::new(GoodWidget, "good");
841        let mut bad_state = ErrorBoundaryState::default();
842        let mut good_state = ErrorBoundaryState::default();
843
844        let mut pool = GraphemePool::new();
845        let mut frame = Frame::new(20, 5, &mut pool);
846        let area_a = Rect::new(0, 0, 10, 5);
847        let area_b = Rect::new(10, 0, 10, 5);
848
849        bad.render(area_a, &mut frame, &mut bad_state);
850        good.render(area_b, &mut frame, &mut good_state);
851
852        assert!(bad_state.is_failed());
853        assert!(!good_state.is_failed());
854        assert_eq!(
855            frame.buffer.get(10, 0).unwrap().content.as_char(),
856            Some('G')
857        );
858    }
859
860    #[test]
861    fn max_recovery_attempts_builder() {
862        let boundary = ErrorBoundary::new(GoodWidget, "good").max_recovery_attempts(5);
863        assert_eq!(boundary.max_recovery_attempts, 5);
864    }
865
866    #[test]
867    fn widget_name_accessor() {
868        let boundary = ErrorBoundary::new(GoodWidget, "my_widget");
869        assert_eq!(boundary.widget_name(), "my_widget");
870    }
871
872    #[test]
873    fn error_state_error_accessor_recovering() {
874        let err = CapturedError {
875            message: "fail".to_string(),
876            widget_name: "w",
877            area: Rect::new(0, 0, 1, 1),
878            timestamp: Instant::now(),
879        };
880        let state = ErrorBoundaryState::Recovering {
881            attempts: 2,
882            last_error: err,
883        };
884        assert!(state.is_failed());
885        assert_eq!(state.error().unwrap().message, "fail");
886    }
887
888    #[test]
889    fn try_recover_on_healthy_returns_true() {
890        let mut state = ErrorBoundaryState::Healthy;
891        assert!(state.try_recover(3));
892        assert!(matches!(state, ErrorBoundaryState::Healthy));
893    }
894
895    #[test]
896    fn captured_error_strips_unreachable_prefix() {
897        let msg = "internal error: entered unreachable code: widget exploded";
898        let payload: Box<dyn std::any::Any + Send> = Box::new(msg.to_string());
899        let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
900        assert_eq!(error.message, "widget exploded");
901    }
902
903    #[test]
904    fn default_state_is_healthy() {
905        let state = ErrorBoundaryState::default();
906        assert!(!state.is_failed());
907        assert!(state.error().is_none());
908    }
909
910    #[test]
911    fn custom_boundary_max_recovery_builder() {
912        let boundary = CustomErrorBoundary::new(GoodWidget, "good").max_recovery_attempts(7);
913        assert_eq!(boundary.max_recovery_attempts, 7);
914    }
915
916    #[test]
917    fn fallback_widget_new_directly() {
918        let err = CapturedError {
919            message: "direct error".to_string(),
920            widget_name: "direct",
921            area: Rect::new(0, 0, 10, 5),
922            timestamp: Instant::now(),
923        };
924        let fallback = FallbackWidget::new(err);
925        assert!(fallback.show_retry_hint);
926        assert_eq!(fallback.error.message, "direct error");
927    }
928
929    #[test]
930    fn recovering_state_panics_revert_to_failed() {
931        let boundary = ErrorBoundary::new(PanickingWidget, "bad");
932        let err = CapturedError {
933            message: "initial".to_string(),
934            widget_name: "bad",
935            area: Rect::new(0, 0, 30, 5),
936            timestamp: Instant::now(),
937        };
938        let mut state = ErrorBoundaryState::Recovering {
939            attempts: 1,
940            last_error: err,
941        };
942
943        let area = Rect::new(0, 0, 30, 5);
944        let mut pool = GraphemePool::new();
945        let mut frame = Frame::new(30, 5, &mut pool);
946        boundary.render(area, &mut frame, &mut state);
947
948        // Panic during recovery should set state to Failed.
949        assert!(matches!(state, ErrorBoundaryState::Failed(_)));
950    }
951}