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        FRender: FnMut(&mut Frame, Rect, &S, RenderContext, &mut EventContext<Id>),
179        FQuit: FnMut(&A) -> bool,
180    {
181        self.run_with_hooks(terminal, render, should_quit, |_, _| {})
182            .await
183    }
184
185    /// Run the loop with a post-render hook after host area synchronization.
186    pub async fn run_with_hooks<B, FRender, FQuit, FAfter>(
187        &mut self,
188        terminal: &mut Terminal<B>,
189        render: FRender,
190        should_quit: FQuit,
191        mut after_render: FAfter,
192    ) -> io::Result<()>
193    where
194        B: Backend,
195        FRender: FnMut(&mut Frame, Rect, &S, RenderContext, &mut EventContext<Id>),
196        FQuit: FnMut(&A) -> bool,
197        FAfter: FnMut(&mut EventBus<S, A, Id, Ctx>, &S),
198    {
199        let host = self.host.clone();
200        self.runtime
201            .run_with_hooks(terminal, render, should_quit, move |bus, state| {
202                host.sync_areas(bus);
203                after_render(bus, state);
204            })
205            .await
206    }
207}
208
209impl<S, A, E, Id, Ctx, St> HostedRuntime<S, A, E, Id, Ctx, St>
210where
211    S: 'static + EventRoutingState<Id, Ctx>,
212    A: ActionTrait,
213    Id: ComponentId + 'static,
214    Ctx: BindingContext + 'static,
215    St: RuntimeStore<S, A, E>,
216{
217    /// Run the event/action loop with effects and host area synchronization.
218    pub async fn run_with_effects<B, FRender, FQuit, FEffect>(
219        &mut self,
220        terminal: &mut Terminal<B>,
221        render: FRender,
222        should_quit: FQuit,
223        handle_effect: FEffect,
224    ) -> io::Result<()>
225    where
226        B: Backend,
227        FRender: FnMut(&mut Frame, Rect, &S, RenderContext, &mut EventContext<Id>),
228        FQuit: FnMut(&A) -> bool,
229        FEffect: FnMut(E, &mut EffectContext<A>),
230    {
231        self.run_with_effect_hooks(terminal, render, should_quit, handle_effect, |_, _| {})
232            .await
233    }
234
235    /// Run the loop with effects and a post-render hook after host area synchronization.
236    pub async fn run_with_effect_hooks<B, FRender, FQuit, FEffect, FAfter>(
237        &mut self,
238        terminal: &mut Terminal<B>,
239        render: FRender,
240        should_quit: FQuit,
241        handle_effect: FEffect,
242        mut after_render: FAfter,
243    ) -> io::Result<()>
244    where
245        B: Backend,
246        FRender: FnMut(&mut Frame, Rect, &S, RenderContext, &mut EventContext<Id>),
247        FQuit: FnMut(&A) -> bool,
248        FEffect: FnMut(E, &mut EffectContext<A>),
249        FAfter: FnMut(&mut EventBus<S, A, Id, Ctx>, &S),
250    {
251        let host = self.host.clone();
252        self.runtime
253            .run_with_effect_hooks(
254                terminal,
255                render,
256                should_quit,
257                handle_effect,
258                move |bus, state| {
259                    host.sync_areas(bus);
260                    after_render(bus, state);
261                },
262            )
263            .await
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use ratatui::backend::TestBackend;
270    use ratatui::widgets::Paragraph;
271    use tui_dispatch_core::{
272        Action, DefaultBindingContext, ReducerResult, Runtime, SimpleEventBus,
273    };
274
275    use super::*;
276    use crate::{ComponentDebugState, InteractiveComponent};
277
278    #[derive(Clone, Debug, PartialEq, Eq)]
279    enum TestAction {
280        Quit,
281    }
282
283    impl Action for TestAction {
284        fn name(&self) -> &'static str {
285            "quit"
286        }
287    }
288
289    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
290    enum TestId {
291        Main,
292    }
293
294    impl ComponentId for TestId {
295        fn name(&self) -> &'static str {
296            "main"
297        }
298    }
299
300    #[derive(Default)]
301    struct TestState {
302        focused: Option<TestId>,
303    }
304
305    impl EventRoutingState<TestId, DefaultBindingContext> for TestState {
306        fn focused(&self) -> Option<TestId> {
307            self.focused
308        }
309
310        fn modal(&self) -> Option<TestId> {
311            None
312        }
313
314        fn binding_context(&self, _id: TestId) -> DefaultBindingContext {
315            DefaultBindingContext
316        }
317
318        fn default_context(&self) -> DefaultBindingContext {
319            DefaultBindingContext
320        }
321    }
322
323    struct TestComponent;
324
325    impl ComponentDebugState for TestComponent {}
326
327    impl InteractiveComponent<TestAction> for TestComponent {
328        type Props<'a> = ();
329
330        fn render(&mut self, frame: &mut Frame, area: Rect, _props: Self::Props<'_>) {
331            frame.render_widget(Paragraph::new("hosted"), area);
332        }
333    }
334
335    fn reducer(_state: &mut TestState, _action: TestAction) -> ReducerResult {
336        ReducerResult::unchanged()
337    }
338
339    fn unit_props(_state: &TestState) {}
340
341    #[tokio::test]
342    async fn hosted_runtime_syncs_component_areas_after_render() {
343        let host = ComponentHost::<TestState, TestAction, TestId, DefaultBindingContext>::new();
344        let mounted = host.mount::<TestComponent, _>(|| TestComponent, unit_props);
345
346        let mut bus = SimpleEventBus::<TestState, TestAction, TestId>::new();
347        host.bind(&mut bus, TestId::Main, mounted);
348
349        let mut runtime = Runtime::new(
350            TestState {
351                focused: Some(TestId::Main),
352            },
353            reducer,
354        )
355        .with_event_bus(bus, Keybindings::new())
356        .with_component_host(host.clone());
357
358        runtime.enqueue(TestAction::Quit);
359
360        let backend = TestBackend::new(8, 1);
361        let mut terminal = Terminal::new(backend).expect("test backend should initialize");
362
363        runtime
364            .run(
365                &mut terminal,
366                |frame, area, state, _render_ctx, _event_ctx| {
367                    host.render(mounted, frame, area, state);
368                },
369                |action| matches!(action, TestAction::Quit),
370            )
371            .await
372            .expect("runtime should exit on queued quit action");
373
374        assert_eq!(
375            runtime.bus().context().component_areas.get(&TestId::Main),
376            Some(&Rect::new(0, 0, 8, 1))
377        );
378    }
379}