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