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}