Skip to main content

ratatui_zonekit/
render.rs

1//! Safe renderer — delegates rendering to plugins with panic isolation.
2//!
3//! Wraps each plugin's `render()` call in `catch_unwind` so a crashing
4//! plugin cannot take down the host application. If a plugin panics,
5//! its zone shows an error message instead.
6
7use std::sync::Arc;
8
9use ratatui::buffer::Buffer;
10use ratatui::layout::Rect;
11use ratatui::style::Style;
12use ratatui::widgets::{Paragraph, Widget};
13
14use crate::plugin::{RenderContext, ZonePlugin};
15use crate::zone::ZoneId;
16
17/// Safe renderer that isolates plugin panics.
18///
19/// Use this to render plugin zones instead of calling `plugin.render()`
20/// directly. If a plugin panics, the zone shows a crash message and
21/// the rest of the application continues normally.
22pub struct SafeRenderer;
23
24impl SafeRenderer {
25    /// Renders a plugin's zone with panic isolation.
26    ///
27    /// Returns `true` if the plugin rendered successfully, `false` if
28    /// it panicked or returned `false` (nothing to show).
29    pub fn render(
30        plugin: &Arc<dyn ZonePlugin>,
31        zone_id: ZoneId,
32        ctx: &RenderContext,
33        area: Rect,
34        buf: &mut Buffer,
35    ) -> bool {
36        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
37            plugin.render(zone_id, ctx, area, buf)
38        }));
39
40        if let Ok(rendered) = result {
41            rendered
42        } else {
43            // Plugin panicked — clear dirty cells, then render crash message.
44            for y in area.top()..area.bottom() {
45                for x in area.left()..area.right() {
46                    buf[(x, y)].reset();
47                }
48            }
49            let crash_msg = format!("[{} crashed]", plugin.id());
50            let style = Style::default().fg(ratatui::style::Color::Red);
51            Paragraph::new(crash_msg).style(style).render(area, buf);
52            false
53        }
54    }
55}
56
57#[cfg(test)]
58#[allow(clippy::unnecessary_literal_bound)]
59mod tests {
60    use super::*;
61
62    struct GoodPlugin;
63
64    impl ZonePlugin for GoodPlugin {
65        fn id(&self) -> &str {
66            "good"
67        }
68
69        fn render(&self, _: ZoneId, _: &RenderContext, area: Rect, buf: &mut Buffer) -> bool {
70            Paragraph::new("ok").render(area, buf);
71            true
72        }
73    }
74
75    struct CrashPlugin;
76
77    impl ZonePlugin for CrashPlugin {
78        fn id(&self) -> &str {
79            "crash"
80        }
81
82        fn render(&self, _: ZoneId, _: &RenderContext, _: Rect, _: &mut Buffer) -> bool {
83            panic!("plugin bug");
84        }
85    }
86
87    struct EmptyPlugin;
88
89    impl ZonePlugin for EmptyPlugin {
90        fn id(&self) -> &str {
91            "empty"
92        }
93
94        fn render(&self, _: ZoneId, _: &RenderContext, _: Rect, _: &mut Buffer) -> bool {
95            false
96        }
97    }
98
99    #[test]
100    fn good_plugin_renders() {
101        let plugin: Arc<dyn ZonePlugin> = Arc::new(GoodPlugin);
102        let area = Rect::new(0, 0, 20, 1);
103        let mut buf = Buffer::empty(area);
104        let ctx = RenderContext::new(Style::default(), 80, 24);
105        assert!(SafeRenderer::render(
106            &plugin,
107            ZoneId::new(1),
108            &ctx,
109            area,
110            &mut buf
111        ));
112        let content: String = buf
113            .content()
114            .iter()
115            .map(|c| c.symbol().to_string())
116            .collect();
117        assert!(content.contains("ok"));
118    }
119
120    #[test]
121    fn crash_plugin_is_caught() {
122        let plugin: Arc<dyn ZonePlugin> = Arc::new(CrashPlugin);
123        let area = Rect::new(0, 0, 30, 1);
124        let mut buf = Buffer::empty(area);
125        let ctx = RenderContext::new(Style::default(), 80, 24);
126        // Should NOT panic — crash is caught
127        let rendered = SafeRenderer::render(&plugin, ZoneId::new(1), &ctx, area, &mut buf);
128        assert!(!rendered);
129        let content: String = buf
130            .content()
131            .iter()
132            .map(|c| c.symbol().to_string())
133            .collect();
134        assert!(content.contains("[crash crashed]"));
135    }
136
137    #[test]
138    fn empty_plugin_returns_false() {
139        let plugin: Arc<dyn ZonePlugin> = Arc::new(EmptyPlugin);
140        let area = Rect::new(0, 0, 20, 1);
141        let mut buf = Buffer::empty(area);
142        let ctx = RenderContext::new(Style::default(), 80, 24);
143        assert!(!SafeRenderer::render(
144            &plugin,
145            ZoneId::new(1),
146            &ctx,
147            area,
148            &mut buf
149        ));
150    }
151}