Skip to main content

tpt_appfront_core/
plugin.rs

1//! A formal plugin API for AppFront applications (Phase 4 / `#47`).
2//!
3//! A [`Plugin`] is a self-contained unit of cross-cutting functionality that an
4//! app registers at startup. It has a typed state `S` (shared via the existing
5//! [`Context`][crate::context::Context] mechanism) and a set of *hooks* that
6//! run at well-defined points in the app lifecycle: before/after the tree is
7//! built, and around each render. This gives apps an extension point without
8//! baking backend-specific fields into [`UITree`][crate::UITree].
9//!
10//! ```ignore
11//! struct Analytics;
12//! impl Plugin for Analytics {
13//!     type State = ();
14//!     fn on_render(&self, _: &PluginCtx<Self::State>) {
15//!         // ... count renders ...
16//!     }
17//! }
18//!
19//! let mut registry = PluginRegistry::new();
20//! registry.register(Analytics);
21//! registry.run_render_hooks();
22//! ```
23//!
24//! The API is backend-agnostic: a plugin only ever sees lifecycle events and
25//! the shared app state, never a DOM/canvas/TUI handle, so the same plugin
26//! works on every backend.
27
28use crate::context::Context;
29use std::cell::Cell;
30use std::rc::Rc;
31
32/// A plugin's read-only view of app + plugin state during a hook.
33///
34/// `S` is the plugin's own state type (see [`Plugin::State`]); `App` is the
35/// application's shared state type, if any. A plugin can read its own state and
36/// any [`Context`][crate::context::Context] in scope, but cannot mutate the
37/// tree — mutation happens through `App`/`S` signals the plugin holds.
38pub struct PluginCtx<'a, S, App = ()> {
39    /// The plugin's own shared state.
40    pub state: &'a S,
41    /// The application-wide shared state, if the app registered one.
42    pub app: Option<&'a App>,
43    /// The number of renders that have happened so far (0-based before the
44    /// first render, 1-based inside an `on_render` hook for the first render).
45    pub render_count: u64,
46}
47
48impl<'a, S, App> PluginCtx<'a, S, App> {
49    /// Returns a reference to the app state, panicking if the app did not
50    /// register a state of type `App`. Use [`PluginCtx::app`] for the fallible
51    /// form.
52    pub fn app(&self) -> &'a App {
53        self.app.expect("plugin expected app state of type App")
54    }
55}
56
57/// A plugin: a named, self-contained extension with typed state.
58///
59/// Implementors decide what happens at each lifecycle point. All hooks have
60/// default no-op implementations, so a plugin only overrides the ones it cares
61/// about.
62pub trait Plugin {
63    /// The plugin's shared state type. Defaults to `()` (stateless plugins).
64    type State: 'static;
65
66    /// A stable name, surfaced in devtools/telemetry. Used as a key in the
67    /// registry; registering two plugins with the same name is an error.
68    fn name(&self) -> &'static str;
69
70    /// Called once when the plugin is registered, returning its initial state.
71    /// The default returns `()` (stateless).
72    fn init(&self) -> Self::State
73    where
74        Self::State: Default,
75    {
76        Self::State::default()
77    }
78
79    /// Called before the app builds its tree for a render. Use this to seed
80    /// data, reset per-render accumulators, etc. `A` is the app-wide shared
81    /// state type (usually `()` unless the host registered app state).
82    fn on_before_render<A: 'static>(&self, _ctx: &PluginCtx<Self::State, A>) {}
83
84    /// Called after the app has built its tree for a render. Use this for
85    /// post-processing, analytics, or inspecting the produced tree.
86    fn on_render<A: 'static>(&self, _ctx: &PluginCtx<Self::State, A>) {}
87
88    /// Called once when the app shuts down. Use this to flush logs, persist
89    /// state, or release native resources.
90    fn on_shutdown<A: 'static>(&self, _ctx: &PluginCtx<Self::State, A>) {}
91}
92
93/// A registered plugin plus its live state, held behind an `Rc` so the registry
94/// can be cheaply cloned into multiple backends/threads-of-render.
95struct Registered<P: Plugin + 'static> {
96    plugin: P,
97    state: P::State,
98}
99
100/// Type-erased plugin so the registry can store heterogeneous plugin types.
101/// `App` is the optional application-wide shared state type.
102trait AnyPlugin<App: 'static>: 'static {
103    fn name(&self) -> &'static str;
104    fn on_before_render(&self, app: Option<&App>, render_count: u64);
105    fn on_render(&self, app: Option<&App>, render_count: u64);
106    fn on_shutdown(&self, app: Option<&App>, render_count: u64);
107}
108
109impl<P: Plugin + 'static, App: 'static> AnyPlugin<App> for Registered<P> {
110    fn name(&self) -> &'static str {
111        self.plugin.name()
112    }
113    fn on_before_render(&self, app: Option<&App>, render_count: u64) {
114        let ctx: PluginCtx<'_, P::State, App> = PluginCtx {
115            state: &self.state,
116            app,
117            render_count,
118        };
119        self.plugin.on_before_render(&ctx);
120    }
121    fn on_render(&self, app: Option<&App>, render_count: u64) {
122        let ctx: PluginCtx<'_, P::State, App> = PluginCtx {
123            state: &self.state,
124            app,
125            render_count,
126        };
127        self.plugin.on_render(&ctx);
128    }
129    fn on_shutdown(&self, app: Option<&App>, render_count: u64) {
130        let ctx: PluginCtx<'_, P::State, App> = PluginCtx {
131            state: &self.state,
132            app,
133            render_count,
134        };
135        self.plugin.on_shutdown(&ctx);
136    }
137}
138
139/// Holds every registered [`Plugin`] and runs their hooks at the right times.
140///
141/// `App` is the optional application-wide shared state type; plugins may read
142/// it but never mutate it directly. A registry is cheap to clone (inner `Rc`).
143pub struct PluginRegistry<App: 'static = ()> {
144    plugins: Vec<Rc<dyn AnyPlugin<App>>>,
145}
146
147impl<App: 'static> PluginRegistry<App> {
148    /// Creates an empty registry.
149    pub fn new() -> Self {
150        PluginRegistry {
151            plugins: Vec::new(),
152        }
153    }
154
155    /// Registers a plugin, storing its initial state. Returns the plugin's name
156    /// so callers can wire up its [`Context`][crate::context::Context] if
157    /// desired. Panics if a plugin with the same name is already registered.
158    pub fn register<P: Plugin + 'static>(&mut self, plugin: P) -> &'static str
159    where
160        P::State: Default,
161    {
162        let name = plugin.name();
163        if self.plugins.iter().any(|p| p.name() == name) {
164            panic!("appfront plugin registry: duplicate plugin name `{name}`");
165        }
166        let registered: Rc<dyn AnyPlugin<App>> = Rc::new(Registered {
167            state: plugin.init(),
168            plugin,
169        });
170        self.plugins.push(registered);
171        name
172    }
173
174    /// Registers a plugin together with an existing shared state value.
175    pub fn register_with_state<P: Plugin + 'static>(&mut self, plugin: P, state: P::State) -> &'static str {
176        let name = plugin.name();
177        if self.plugins.iter().any(|p| p.name() == name) {
178            panic!("appfront plugin registry: duplicate plugin name `{name}`");
179        }
180        let registered: Rc<dyn AnyPlugin<App>> = Rc::new(Registered { state, plugin });
181        self.plugins.push(registered);
182        name
183    }
184
185    /// Runs every plugin's `on_before_render` hook.
186    pub fn run_before_render_hooks(&self, app: Option<&App>) {
187        for p in &self.plugins {
188            p.on_before_render(app, self.render_count());
189        }
190    }
191
192    /// Runs every plugin's `on_render` hook, then advances the render counter.
193    pub fn run_render_hooks(&self, app: Option<&App>) {
194        let count = self.render_count();
195        for p in &self.plugins {
196            p.on_render(app, count);
197        }
198    }
199
200    /// Runs every plugin's `on_shutdown` hook.
201    pub fn run_shutdown_hooks(&self, app: Option<&App>) {
202        for p in &self.plugins {
203            p.on_shutdown(app, self.render_count());
204        }
205    }
206
207    /// Number of registered plugins.
208    pub fn len(&self) -> usize {
209        self.plugins.len()
210    }
211
212    /// Whether the registry has no plugins.
213    pub fn is_empty(&self) -> bool {
214        self.plugins.is_empty()
215    }
216
217    fn render_count(&self) -> u64 {
218        RENDER_COUNT.with(|c| c.get())
219    }
220
221    /// Advances the internal render counter (called by the host once per
222    /// completed render). `run_render_hooks` does not do this itself so the
223    /// count is stable for the duration of a render.
224    pub fn bump_render_count(&self) {
225        RENDER_COUNT.with(|c| c.set(c.get() + 1));
226    }
227}
228
229thread_local! {
230    static RENDER_COUNT: Cell<u64> = const { Cell::new(0) };
231}
232
233impl<App: 'static> Default for PluginRegistry<App> {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239impl<App: 'static> Clone for PluginRegistry<App> {
240    fn clone(&self) -> Self {
241        PluginRegistry {
242            plugins: self.plugins.clone(),
243        }
244    }
245}
246
247/// Convenience: provides a plugin's state to a subtree as a
248/// [`Context`][crate::context::Context] so descendant components can read it.
249///
250/// Returns the [`Context`] so callers can keep a handle for updates. Wrap the
251/// builder closure in [`provide_context`][crate::context::provide_context] so
252/// the context is scoped to `scope`.
253pub fn context_for_plugin<S: Clone + 'static>(state: S) -> Context<S> {
254    Context::new(state)
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    struct Counter;
262    impl Plugin for Counter {
263        type State = Cell<u32>;
264        fn name(&self) -> &'static str {
265            "counter"
266        }
267        fn init(&self) -> Self::State {
268            Cell::new(0)
269        }
270        fn on_render<A: 'static>(&self, ctx: &PluginCtx<Self::State, A>) {
271            ctx.state.set(ctx.state.get() + 1);
272        }
273    }
274
275    struct Named {
276        name: &'static str,
277    }
278    impl Plugin for Named {
279        type State = ();
280        fn name(&self) -> &'static str {
281            self.name
282        }
283    }
284
285    #[derive(Debug, PartialEq)]
286    struct Theme {
287        dark: bool,
288    }
289
290    struct ThemePlugin;
291    impl Plugin for ThemePlugin {
292        type State = Theme;
293        fn name(&self) -> &'static str {
294            "theme"
295        }
296        fn init(&self) -> Self::State {
297            Theme { dark: false }
298        }
299    }
300
301    #[test]
302    fn registers_and_runs_render_hooks() {
303        let mut reg = PluginRegistry::<()>::new();
304        reg.register(Counter);
305        assert_eq!(reg.len(), 1);
306
307        reg.run_render_hooks(None);
308        reg.bump_render_count();
309        reg.run_render_hooks(None);
310        reg.bump_render_count();
311
312        // The cell is internal; we check render_count instead.
313        assert_eq!(reg.render_count(), 2);
314    }
315
316    #[test]
317    fn distinct_named_plugins_register_independently() {
318        let mut reg = PluginRegistry::<()>::new();
319        reg.register(Named { name: "a" });
320        reg.register(Named { name: "b" });
321        assert_eq!(reg.len(), 2);
322    }
323
324    #[test]
325    fn plugin_with_state_registers() {
326        let mut reg = PluginRegistry::<()>::new();
327        reg.register_with_state(ThemePlugin, Theme { dark: false });
328        assert!(!reg.is_empty());
329    }
330
331    #[test]
332    #[should_panic(expected = "duplicate plugin name")]
333    fn duplicate_names_panic() {
334        let mut reg = PluginRegistry::<()>::new();
335        reg.register(Named { name: "dup" });
336        reg.register(Named { name: "dup" });
337    }
338}