Skip to main content

tui_dispatch_components/
runtime.rs

1use std::io;
2
3use ratatui::backend::Backend;
4use ratatui::layout::Rect;
5use ratatui::{Frame, Terminal};
6use tui_dispatch_core::runtime::EventBusRouting;
7use tui_dispatch_core::{
8    Action as ActionTrait, BindingContext, ComponentId, EffectContext, EventBus, EventContext,
9    EventRoutingState, Keybindings, NoEffect, RenderContext, Runtime, RuntimeStore, Store,
10};
11
12use crate::ComponentHost;
13
14#[doc(hidden)]
15/// Bus-routed runtime shape that can be paired with a [`ComponentHost`].
16pub type ComponentHostRuntime<S, A, E, Id, Ctx, St = Store<S, A, E>> =
17    Runtime<S, A, E, EventBusRouting<S, A, Id, Ctx>, St>;
18
19#[doc(hidden)]
20/// Owned pieces returned by [`HostedRuntime::into_parts`].
21pub struct HostedRuntimeParts<S, A, E, Id, Ctx, St = Store<S, A, E>>
22where
23    A: ActionTrait,
24    Id: ComponentId + 'static,
25    Ctx: BindingContext + 'static,
26    S: EventRoutingState<Id, Ctx>,
27    St: RuntimeStore<S, A, E>,
28{
29    pub runtime: ComponentHostRuntime<S, A, E, Id, Ctx, St>,
30    pub host: ComponentHost<S, A, Id, Ctx>,
31}
32
33/// Runtime wrapper that keeps [`ComponentHost`] area synchronization out of app loops.
34pub struct HostedRuntime<S, A, E, Id, Ctx, St = Store<S, A, E>>
35where
36    A: ActionTrait,
37    Id: ComponentId + 'static,
38    Ctx: BindingContext + 'static,
39    S: EventRoutingState<Id, Ctx>,
40    St: RuntimeStore<S, A, E>,
41{
42    runtime: ComponentHostRuntime<S, A, E, Id, Ctx, St>,
43    host: ComponentHost<S, A, Id, Ctx>,
44}
45
46/// Extension trait for attaching a [`ComponentHost`] to a bus-routed runtime.
47pub trait RuntimeHostExt<H> {
48    type Output;
49
50    fn with_component_host(self, host: H) -> Self::Output;
51}
52
53impl<S, A, E, Id, Ctx, St> RuntimeHostExt<ComponentHost<S, A, Id, Ctx>>
54    for Runtime<S, A, E, EventBusRouting<S, A, Id, Ctx>, St>
55where
56    S: 'static + EventRoutingState<Id, Ctx>,
57    A: ActionTrait,
58    Id: ComponentId + 'static,
59    Ctx: BindingContext + 'static,
60    St: RuntimeStore<S, A, E>,
61{
62    type Output = HostedRuntime<S, A, E, Id, Ctx, St>;
63
64    fn with_component_host(self, host: ComponentHost<S, A, Id, Ctx>) -> Self::Output {
65        HostedRuntime {
66            runtime: self,
67            host,
68        }
69    }
70}
71
72impl<S, A, E, Id, Ctx, St> HostedRuntime<S, A, E, Id, Ctx, St>
73where
74    S: 'static + EventRoutingState<Id, Ctx>,
75    A: ActionTrait,
76    Id: ComponentId + 'static,
77    Ctx: BindingContext + 'static,
78    St: RuntimeStore<S, A, E>,
79{
80    /// Access the hosted component host.
81    pub fn host(&self) -> &ComponentHost<S, A, Id, Ctx> {
82        &self.host
83    }
84
85    /// Access the hosted component host mutably.
86    pub fn host_mut(&mut self) -> &mut ComponentHost<S, A, Id, Ctx> {
87        &mut self.host
88    }
89
90    /// Access the wrapped runtime.
91    pub fn runtime(&self) -> &ComponentHostRuntime<S, A, E, Id, Ctx, St> {
92        &self.runtime
93    }
94
95    /// Access the wrapped runtime mutably.
96    pub fn runtime_mut(&mut self) -> &mut ComponentHostRuntime<S, A, E, Id, Ctx, St> {
97        &mut self.runtime
98    }
99
100    /// Split the wrapper back into its runtime and host.
101    pub fn into_parts(self) -> HostedRuntimeParts<S, A, E, Id, Ctx, St> {
102        HostedRuntimeParts {
103            runtime: self.runtime,
104            host: self.host,
105        }
106    }
107
108    /// Access the event bus.
109    pub fn bus(&self) -> &EventBus<S, A, Id, Ctx> {
110        self.runtime.bus()
111    }
112
113    /// Access the event bus mutably.
114    pub fn bus_mut(&mut self) -> &mut EventBus<S, A, Id, Ctx> {
115        self.runtime.bus_mut()
116    }
117
118    /// Access keybindings.
119    pub fn keybindings(&self) -> &Keybindings<Ctx> {
120        self.runtime.keybindings()
121    }
122
123    /// Access keybindings mutably.
124    pub fn keybindings_mut(&mut self) -> &mut Keybindings<Ctx> {
125        self.runtime.keybindings_mut()
126    }
127
128    /// Subscribe to action name broadcasts.
129    pub fn subscribe_actions(&self) -> tokio::sync::broadcast::Receiver<String> {
130        self.runtime.subscribe_actions()
131    }
132
133    /// Send an action into the runtime queue.
134    pub fn enqueue(&self, action: A) {
135        self.runtime.enqueue(action);
136    }
137
138    /// Clone the action sender.
139    pub fn action_tx(&self) -> tokio::sync::mpsc::UnboundedSender<A> {
140        self.runtime.action_tx()
141    }
142
143    /// Access the current state.
144    pub fn state(&self) -> &S {
145        self.runtime.state()
146    }
147
148    /// Access the task manager.
149    #[cfg(feature = "tasks")]
150    pub fn tasks(&mut self) -> &mut tui_dispatch_core::TaskManager<A> {
151        self.runtime.tasks()
152    }
153
154    /// Access subscriptions.
155    #[cfg(feature = "subscriptions")]
156    pub fn subscriptions(&mut self) -> &mut tui_dispatch_core::Subscriptions<A> {
157        self.runtime.subscriptions()
158    }
159}
160
161impl<S, A, Id, Ctx, St> HostedRuntime<S, A, NoEffect, Id, Ctx, St>
162where
163    S: 'static + EventRoutingState<Id, Ctx>,
164    A: ActionTrait,
165    Id: ComponentId + 'static,
166    Ctx: BindingContext + 'static,
167    St: RuntimeStore<S, A, NoEffect>,
168{
169    /// Run the event/action loop and synchronize host-rendered areas after each frame.
170    pub async fn run<B, FRender, FQuit>(
171        &mut self,
172        terminal: &mut Terminal<B>,
173        render: FRender,
174        should_quit: FQuit,
175    ) -> io::Result<()>
176    where
177        B: Backend,
178        B::Error: Send + Sync + 'static,
179        FRender: FnMut(&mut Frame, Rect, &S, RenderContext, &mut EventContext<Id>),
180        FQuit: FnMut(&A) -> bool,
181    {
182        self.run_with_hooks(terminal, render, should_quit, |_, _| {})
183            .await
184    }
185
186    /// Run the loop with a post-render hook after host area synchronization.
187    pub async fn run_with_hooks<B, FRender, FQuit, FAfter>(
188        &mut self,
189        terminal: &mut Terminal<B>,
190        render: FRender,
191        should_quit: FQuit,
192        mut after_render: FAfter,
193    ) -> io::Result<()>
194    where
195        B: Backend,
196        B::Error: Send + Sync + 'static,
197        FRender: FnMut(&mut Frame, Rect, &S, RenderContext, &mut EventContext<Id>),
198        FQuit: FnMut(&A) -> bool,
199        FAfter: FnMut(&mut EventBus<S, A, Id, Ctx>, &S),
200    {
201        let host = self.host.clone();
202        self.runtime
203            .run_with_hooks(terminal, render, should_quit, move |bus, state| {
204                host.sync_areas(bus);
205                after_render(bus, state);
206            })
207            .await
208    }
209}
210
211impl<S, A, E, Id, Ctx, St> HostedRuntime<S, A, E, Id, Ctx, St>
212where
213    S: 'static + EventRoutingState<Id, Ctx>,
214    A: ActionTrait,
215    Id: ComponentId + 'static,
216    Ctx: BindingContext + 'static,
217    St: RuntimeStore<S, A, E>,
218{
219    /// Run the event/action loop with effects and host area synchronization.
220    pub async fn run_with_effects<B, FRender, FQuit, FEffect>(
221        &mut self,
222        terminal: &mut Terminal<B>,
223        render: FRender,
224        should_quit: FQuit,
225        handle_effect: FEffect,
226    ) -> io::Result<()>
227    where
228        B: Backend,
229        B::Error: Send + Sync + 'static,
230        FRender: FnMut(&mut Frame, Rect, &S, RenderContext, &mut EventContext<Id>),
231        FQuit: FnMut(&A) -> bool,
232        FEffect: FnMut(E, &mut EffectContext<A>),
233    {
234        self.run_with_effect_hooks(terminal, render, should_quit, handle_effect, |_, _| {})
235            .await
236    }
237
238    /// Run the loop with effects and a post-render hook after host area synchronization.
239    pub async fn run_with_effect_hooks<B, FRender, FQuit, FEffect, FAfter>(
240        &mut self,
241        terminal: &mut Terminal<B>,
242        render: FRender,
243        should_quit: FQuit,
244        handle_effect: FEffect,
245        mut after_render: FAfter,
246    ) -> io::Result<()>
247    where
248        B: Backend,
249        B::Error: Send + Sync + 'static,
250        FRender: FnMut(&mut Frame, Rect, &S, RenderContext, &mut EventContext<Id>),
251        FQuit: FnMut(&A) -> bool,
252        FEffect: FnMut(E, &mut EffectContext<A>),
253        FAfter: FnMut(&mut EventBus<S, A, Id, Ctx>, &S),
254    {
255        let host = self.host.clone();
256        self.runtime
257            .run_with_effect_hooks(
258                terminal,
259                render,
260                should_quit,
261                handle_effect,
262                move |bus, state| {
263                    host.sync_areas(bus);
264                    after_render(bus, state);
265                },
266            )
267            .await
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use ratatui::backend::TestBackend;
274    use ratatui::widgets::Paragraph;
275    use tui_dispatch_core::{
276        Action, DefaultBindingContext, ReducerResult, Runtime, SimpleEventBus,
277    };
278
279    use super::*;
280    use crate::{ComponentDebugState, InteractiveComponent};
281
282    #[derive(Clone, Debug, PartialEq, Eq)]
283    enum TestAction {
284        Quit,
285    }
286
287    impl Action for TestAction {
288        fn name(&self) -> &'static str {
289            "quit"
290        }
291    }
292
293    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
294    enum TestId {
295        Main,
296    }
297
298    impl ComponentId for TestId {
299        fn name(&self) -> &'static str {
300            "main"
301        }
302    }
303
304    #[derive(Default)]
305    struct TestState {
306        focused: Option<TestId>,
307    }
308
309    impl EventRoutingState<TestId, DefaultBindingContext> for TestState {
310        fn focused(&self) -> Option<TestId> {
311            self.focused
312        }
313
314        fn modal(&self) -> Option<TestId> {
315            None
316        }
317
318        fn binding_context(&self, _id: TestId) -> DefaultBindingContext {
319            DefaultBindingContext
320        }
321
322        fn default_context(&self) -> DefaultBindingContext {
323            DefaultBindingContext
324        }
325    }
326
327    struct TestComponent;
328
329    impl ComponentDebugState for TestComponent {}
330
331    impl InteractiveComponent<TestAction> for TestComponent {
332        type Props<'a> = ();
333
334        fn render(&mut self, frame: &mut Frame, area: Rect, _props: Self::Props<'_>) {
335            frame.render_widget(Paragraph::new("hosted"), area);
336        }
337    }
338
339    fn reducer(_state: &mut TestState, _action: TestAction) -> ReducerResult {
340        ReducerResult::unchanged()
341    }
342
343    fn unit_props(_state: &TestState) {}
344
345    #[tokio::test]
346    async fn hosted_runtime_syncs_component_areas_after_render() {
347        let host = ComponentHost::<TestState, TestAction, TestId, DefaultBindingContext>::new();
348        let mounted = host.mount::<TestComponent, _>(|| TestComponent, unit_props);
349
350        let mut bus = SimpleEventBus::<TestState, TestAction, TestId>::new();
351        host.bind(&mut bus, TestId::Main, mounted);
352
353        let mut runtime = Runtime::new(
354            TestState {
355                focused: Some(TestId::Main),
356            },
357            reducer,
358        )
359        .with_event_bus(bus, Keybindings::new())
360        .with_component_host(host.clone());
361
362        runtime.enqueue(TestAction::Quit);
363
364        let backend = TestBackend::new(8, 1);
365        let mut terminal = Terminal::new(backend).expect("test backend should initialize");
366
367        runtime
368            .run(
369                &mut terminal,
370                |frame, area, state, _render_ctx, _event_ctx| {
371                    host.render(mounted, frame, area, state);
372                },
373                |action| matches!(action, TestAction::Quit),
374            )
375            .await
376            .expect("runtime should exit on queued quit action");
377
378        assert_eq!(
379            runtime.bus().context().component_areas.get(&TestId::Main),
380            Some(&Rect::new(0, 0, 8, 1))
381        );
382    }
383}