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