Skip to main content

ratatui_zonekit/
plugin.rs

1//! Plugin trait — the contract between host and plugin.
2//!
3//! A [`ZonePlugin`] declares what zones it wants and how to render them.
4//! The host calls `zones()` once at registration, then `render()` every
5//! frame for each visible zone the plugin owns.
6//!
7//! Plugins receive a [`RenderContext`] with metadata — they never
8//! access the `Frame` directly (Model B safety).
9
10use ratatui::buffer::Buffer;
11use ratatui::layout::Rect;
12use ratatui::style::Style;
13
14use crate::zone::{ZoneId, ZoneRequest};
15
16/// Context passed to a plugin during rendering.
17///
18/// Contains frame metadata and the base style the host wants the plugin
19/// to use. Plugins should respect `base_style` for theme consistency,
20/// but it's not enforced at the type level — this is a convention.
21///
22/// The context is **theme-agnostic**: the host sets `base_style` from
23/// whatever styling system it uses (themekit, raw Style, custom).
24#[derive(Debug, Clone)]
25pub struct RenderContext {
26    /// Base style (background + foreground) from the host's theme.
27    ///
28    /// Plugins should apply this as the default style for their zone
29    /// to maintain visual consistency with the rest of the application.
30    pub base_style: Style,
31    /// Whether this zone currently has keyboard focus.
32    pub focused: bool,
33    /// Terminal width (for responsive rendering decisions).
34    pub terminal_width: u16,
35    /// Terminal height.
36    pub terminal_height: u16,
37    /// Current tick count (for animations).
38    pub tick: usize,
39}
40
41impl RenderContext {
42    /// Creates a new render context with the given base style.
43    #[must_use]
44    pub fn new(base_style: Style, terminal_width: u16, terminal_height: u16) -> Self {
45        Self {
46            base_style,
47            focused: false,
48            terminal_width,
49            terminal_height,
50            tick: 0,
51        }
52    }
53
54    /// Sets the focused state.
55    #[must_use]
56    pub fn with_focus(mut self, focused: bool) -> Self {
57        self.focused = focused;
58        self
59    }
60
61    /// Sets the tick count.
62    #[must_use]
63    pub fn with_tick(mut self, tick: usize) -> Self {
64        self.tick = tick;
65        self
66    }
67}
68
69/// A plugin that owns one or more zones in the TUI.
70///
71/// Implement this trait to contribute UI to a ratatui application.
72/// The host calls methods in this order:
73///
74/// 1. `id()` + `zones()` — once at registration
75/// 2. `on_register(zone_id)` — once per granted zone
76/// 3. `render(zone_id, ctx, area, buf)` — every frame per visible zone
77/// 4. `on_event(zone_id, event)` — when a relevant event occurs
78///
79/// # Theme Agnosticism
80///
81/// The `RenderContext.base_style` carries the host's theme as a plain
82/// `ratatui::style::Style`. This works with any styling system:
83///
84/// - **ratatui-themekit**: host sets `base_style = theme.style_base()`
85/// - **Raw styles**: host sets `base_style = Style::default().bg(Color::Rgb(...))`
86/// - **Custom**: any `Style` value
87///
88/// Plugins that want to use themekit directly can depend on it
89/// themselves — zonekit does not require or prevent this.
90pub trait ZonePlugin: Send + Sync {
91    /// Unique plugin identifier (e.g., `"official.bmad"`).
92    fn id(&self) -> &str;
93
94    /// Zone requests — what zones this plugin wants to own.
95    ///
96    /// Called once during registration. Each request is evaluated by
97    /// the host, which may grant or deny based on available space.
98    fn zones(&self) -> Vec<ZoneRequest> {
99        vec![]
100    }
101
102    /// Called when the host grants a zone to this plugin.
103    ///
104    /// Uses `&self` — plugins that need to store the zone ID should
105    /// use interior mutability (e.g., `Cell`, `Mutex`).
106    fn on_register(&self, _zone_id: ZoneId) {}
107
108    /// Renders the plugin's content into the granted zone.
109    ///
110    /// `area` is the allocated `Rect` — plugin MUST NOT write outside it.
111    /// `buf` is the buffer slice for this area.
112    /// `ctx` carries theme style, focus state, and terminal dimensions.
113    ///
114    /// Return `false` if the zone has nothing to show (host may hide it).
115    fn render(&self, zone_id: ZoneId, ctx: &RenderContext, area: Rect, buf: &mut Buffer) -> bool;
116
117    /// Called when a keyboard or mouse event occurs in this zone.
118    ///
119    /// Return `true` if the event was handled (prevents propagation).
120    fn on_event(&self, _zone_id: ZoneId, _event: &ZoneEvent) -> bool {
121        false
122    }
123}
124
125/// Events delivered to zone plugins.
126#[derive(Debug, Clone)]
127pub enum ZoneEvent {
128    /// A key was pressed while this zone was focused.
129    Key {
130        /// Key code.
131        code: ratatui::crossterm::event::KeyCode,
132        /// Key modifiers.
133        modifiers: ratatui::crossterm::event::KeyModifiers,
134    },
135    /// Mouse click inside this zone.
136    Click {
137        /// Column relative to zone's left edge.
138        x: u16,
139        /// Row relative to zone's top edge.
140        y: u16,
141    },
142    /// Mouse scroll inside this zone.
143    Scroll {
144        /// Positive = down, negative = up.
145        delta: i8,
146    },
147    /// Zone was focused.
148    FocusGained,
149    /// Zone lost focus.
150    FocusLost,
151    /// Terminal was resized.
152    Resize {
153        /// New terminal width.
154        width: u16,
155        /// New terminal height.
156        height: u16,
157    },
158}
159
160#[cfg(test)]
161#[allow(clippy::unnecessary_literal_bound)]
162mod tests {
163    use super::*;
164
165    struct TestPlugin;
166
167    impl ZonePlugin for TestPlugin {
168        fn id(&self) -> &str {
169            "test"
170        }
171
172        fn render(&self, _: ZoneId, ctx: &RenderContext, area: Rect, buf: &mut Buffer) -> bool {
173            use ratatui::widgets::{Paragraph, Widget};
174            let text = if ctx.focused { "FOCUSED" } else { "normal" };
175            Paragraph::new(text).style(ctx.base_style).render(area, buf);
176            true
177        }
178    }
179
180    #[test]
181    fn plugin_id_is_accessible() {
182        let p = TestPlugin;
183        assert_eq!(p.id(), "test");
184    }
185
186    #[test]
187    fn default_zones_is_empty() {
188        let p = TestPlugin;
189        assert!(p.zones().is_empty());
190    }
191
192    #[test]
193    fn render_context_builder() {
194        let ctx = RenderContext::new(Style::default(), 120, 40)
195            .with_focus(true)
196            .with_tick(42);
197        assert!(ctx.focused);
198        assert_eq!(ctx.tick, 42);
199        assert_eq!(ctx.terminal_width, 120);
200    }
201
202    #[test]
203    fn plugin_renders_into_buffer() {
204        let p = TestPlugin;
205        let area = Rect::new(0, 0, 20, 1);
206        let mut buf = Buffer::empty(area);
207        let ctx = RenderContext::new(Style::default(), 80, 24);
208        let rendered = p.render(ZoneId::new(1), &ctx, area, &mut buf);
209        assert!(rendered);
210        let content: String = buf
211            .content()
212            .iter()
213            .map(|c| c.symbol().to_string())
214            .collect();
215        assert!(content.contains("normal"));
216    }
217
218    #[test]
219    fn plugin_renders_focused() {
220        let p = TestPlugin;
221        let area = Rect::new(0, 0, 20, 1);
222        let mut buf = Buffer::empty(area);
223        let ctx = RenderContext::new(Style::default(), 80, 24).with_focus(true);
224        p.render(ZoneId::new(1), &ctx, area, &mut buf);
225        let content: String = buf
226            .content()
227            .iter()
228            .map(|c| c.symbol().to_string())
229            .collect();
230        assert!(content.contains("FOCUSED"));
231    }
232
233    #[test]
234    fn default_on_event_returns_false() {
235        let p = TestPlugin;
236        let handled = p.on_event(
237            ZoneId::new(1),
238            &ZoneEvent::Key {
239                code: ratatui::crossterm::event::KeyCode::Char('a'),
240                modifiers: ratatui::crossterm::event::KeyModifiers::NONE,
241            },
242        );
243        assert!(!handled);
244    }
245}