tui_dispatch_core/
runtime.rs

1//! Runtime helpers for tui-dispatch apps.
2//!
3//! These helpers wrap the common event/action/render loop while keeping
4//! the same behavior as the manual wiring shown in the examples.
5
6use std::io;
7use std::time::Duration;
8
9use ratatui::backend::Backend;
10use ratatui::layout::Rect;
11use ratatui::{Frame, Terminal};
12use tokio::sync::mpsc;
13use tokio_util::sync::CancellationToken;
14
15use crate::bus::{process_raw_event, spawn_event_poller, RawEvent};
16use crate::debug::{DebugLayer, DebugState};
17use crate::effect::{DispatchResult, EffectStore, EffectStoreWithMiddleware};
18use crate::event::EventKind;
19use crate::store::{Middleware, Reducer, Store, StoreWithMiddleware};
20use crate::{Action, ActionParams};
21
22#[cfg(feature = "subscriptions")]
23use crate::subscriptions::Subscriptions;
24#[cfg(feature = "tasks")]
25use crate::tasks::TaskManager;
26
27/// Configuration for the event poller.
28#[derive(Debug, Clone, Copy)]
29pub struct PollerConfig {
30    /// Timeout passed to each `crossterm::event::poll` call.
31    pub poll_timeout: Duration,
32    /// Sleep between poll cycles.
33    pub loop_sleep: Duration,
34}
35
36impl Default for PollerConfig {
37    fn default() -> Self {
38        Self {
39            poll_timeout: Duration::from_millis(10),
40            loop_sleep: Duration::from_millis(16),
41        }
42    }
43}
44
45/// Result of mapping an event into actions plus an optional render hint.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct EventOutcome<A> {
48    /// Actions to enqueue.
49    pub actions: Vec<A>,
50    /// Whether to force a re-render.
51    pub needs_render: bool,
52}
53
54/// Context passed to render closures.
55#[derive(Debug, Clone, Copy, Default)]
56pub struct RenderContext {
57    /// Whether the debug overlay is currently active.
58    pub debug_enabled: bool,
59}
60
61impl RenderContext {
62    /// Whether the app should treat input focus as active.
63    pub fn is_focused(self) -> bool {
64        !self.debug_enabled
65    }
66}
67
68impl<A> EventOutcome<A> {
69    /// No actions and no render.
70    pub fn ignored() -> Self {
71        Self {
72            actions: Vec::new(),
73            needs_render: false,
74        }
75    }
76
77    /// No actions, but request a render.
78    pub fn needs_render() -> Self {
79        Self {
80            actions: Vec::new(),
81            needs_render: true,
82        }
83    }
84
85    /// Wrap a single action.
86    pub fn action(action: A) -> Self {
87        Self {
88            actions: vec![action],
89            needs_render: false,
90        }
91    }
92
93    /// Wrap multiple actions.
94    pub fn actions<I>(actions: I) -> Self
95    where
96        I: IntoIterator<Item = A>,
97    {
98        Self {
99            actions: actions.into_iter().collect(),
100            needs_render: false,
101        }
102    }
103
104    /// Mark that a render is needed.
105    pub fn with_render(mut self) -> Self {
106        self.needs_render = true;
107        self
108    }
109}
110
111impl<A> Default for EventOutcome<A> {
112    fn default() -> Self {
113        Self::ignored()
114    }
115}
116
117impl<A> From<A> for EventOutcome<A> {
118    fn from(action: A) -> Self {
119        Self::action(action)
120    }
121}
122
123impl<A> From<Vec<A>> for EventOutcome<A> {
124    fn from(actions: Vec<A>) -> Self {
125        Self {
126            actions,
127            needs_render: false,
128        }
129    }
130}
131
132impl<A> From<Option<A>> for EventOutcome<A> {
133    fn from(action: Option<A>) -> Self {
134        match action {
135            Some(action) => Self::action(action),
136            None => Self::ignored(),
137        }
138    }
139}
140
141trait DebugAdapter<S, A>: 'static {
142    fn render(
143        &mut self,
144        frame: &mut Frame,
145        state: &S,
146        render_ctx: RenderContext,
147        render_fn: &mut dyn FnMut(&mut Frame, Rect, &S, RenderContext),
148    );
149
150    fn handle_event(
151        &mut self,
152        event: &EventKind,
153        state: &S,
154        action_tx: &mpsc::UnboundedSender<A>,
155    ) -> Option<bool>;
156
157    fn log_action(&mut self, action: &A);
158    fn is_enabled(&self) -> bool;
159}
160
161impl<S, A> DebugAdapter<S, A> for DebugLayer<A>
162where
163    S: DebugState,
164    A: Action + ActionParams,
165{
166    fn render(
167        &mut self,
168        frame: &mut Frame,
169        state: &S,
170        render_ctx: RenderContext,
171        render_fn: &mut dyn FnMut(&mut Frame, Rect, &S, RenderContext),
172    ) {
173        self.render_state(frame, state, |f, area| {
174            render_fn(f, area, state, render_ctx);
175        });
176    }
177
178    fn handle_event(
179        &mut self,
180        event: &EventKind,
181        state: &S,
182        action_tx: &mpsc::UnboundedSender<A>,
183    ) -> Option<bool> {
184        self.handle_event_with_state(event, state)
185            .dispatch_queued(|action| {
186                let _ = action_tx.send(action);
187            })
188    }
189
190    fn log_action(&mut self, action: &A) {
191        DebugLayer::log_action(self, action);
192    }
193
194    fn is_enabled(&self) -> bool {
195        DebugLayer::is_enabled(self)
196    }
197}
198
199/// Store interface used by `DispatchRuntime`.
200pub trait DispatchStore<S, A: Action> {
201    /// Dispatch an action and return whether the state changed.
202    fn dispatch(&mut self, action: A) -> bool;
203    /// Get the current state.
204    fn state(&self) -> &S;
205}
206
207impl<S, A: Action> DispatchStore<S, A> for Store<S, A> {
208    fn dispatch(&mut self, action: A) -> bool {
209        Store::dispatch(self, action)
210    }
211
212    fn state(&self) -> &S {
213        Store::state(self)
214    }
215}
216
217impl<S, A: Action, M: Middleware<A>> DispatchStore<S, A> for StoreWithMiddleware<S, A, M> {
218    fn dispatch(&mut self, action: A) -> bool {
219        StoreWithMiddleware::dispatch(self, action)
220    }
221
222    fn state(&self) -> &S {
223        StoreWithMiddleware::state(self)
224    }
225}
226
227/// Effect store interface used by `EffectRuntime`.
228pub trait EffectStoreLike<S, A: Action, E> {
229    /// Dispatch an action and return state changes plus effects.
230    fn dispatch(&mut self, action: A) -> DispatchResult<E>;
231    /// Get the current state.
232    fn state(&self) -> &S;
233}
234
235impl<S, A: Action, E> EffectStoreLike<S, A, E> for EffectStore<S, A, E> {
236    fn dispatch(&mut self, action: A) -> DispatchResult<E> {
237        EffectStore::dispatch(self, action)
238    }
239
240    fn state(&self) -> &S {
241        EffectStore::state(self)
242    }
243}
244
245impl<S, A: Action, E, M: Middleware<A>> EffectStoreLike<S, A, E>
246    for EffectStoreWithMiddleware<S, A, E, M>
247{
248    fn dispatch(&mut self, action: A) -> DispatchResult<E> {
249        EffectStoreWithMiddleware::dispatch(self, action)
250    }
251
252    fn state(&self) -> &S {
253        EffectStoreWithMiddleware::state(self)
254    }
255}
256
257/// Runtime helper for simple stores (no effects).
258pub struct DispatchRuntime<S, A: Action, St: DispatchStore<S, A> = Store<S, A>> {
259    store: St,
260    action_tx: mpsc::UnboundedSender<A>,
261    action_rx: mpsc::UnboundedReceiver<A>,
262    poller_config: PollerConfig,
263    debug: Option<Box<dyn DebugAdapter<S, A>>>,
264    should_render: bool,
265    _state: std::marker::PhantomData<S>,
266}
267
268impl<S: 'static, A: Action> DispatchRuntime<S, A, Store<S, A>> {
269    /// Create a runtime from state + reducer.
270    pub fn new(state: S, reducer: Reducer<S, A>) -> Self {
271        Self::from_store(Store::new(state, reducer))
272    }
273}
274
275impl<S: 'static, A: Action, St: DispatchStore<S, A>> DispatchRuntime<S, A, St> {
276    /// Create a runtime from an existing store.
277    pub fn from_store(store: St) -> Self {
278        let (action_tx, action_rx) = mpsc::unbounded_channel();
279        Self {
280            store,
281            action_tx,
282            action_rx,
283            poller_config: PollerConfig::default(),
284            debug: None,
285            should_render: true,
286            _state: std::marker::PhantomData,
287        }
288    }
289
290    /// Attach a debug layer.
291    pub fn with_debug(mut self, debug: DebugLayer<A>) -> Self
292    where
293        S: DebugState,
294        A: ActionParams,
295    {
296        let adapter: Box<dyn DebugAdapter<S, A>> = Box::new(debug);
297        self.debug = Some(adapter);
298        self
299    }
300
301    /// Configure event polling behavior.
302    pub fn with_event_poller(mut self, config: PollerConfig) -> Self {
303        self.poller_config = config;
304        self
305    }
306
307    /// Send an action into the runtime queue.
308    pub fn enqueue(&self, action: A) {
309        let _ = self.action_tx.send(action);
310    }
311
312    /// Clone the action sender.
313    pub fn action_tx(&self) -> mpsc::UnboundedSender<A> {
314        self.action_tx.clone()
315    }
316
317    /// Access the current state.
318    pub fn state(&self) -> &S {
319        self.store.state()
320    }
321
322    /// Run the event/action loop until quit.
323    pub async fn run<B, FRender, FEvent, FQuit, R>(
324        &mut self,
325        terminal: &mut Terminal<B>,
326        mut render: FRender,
327        mut map_event: FEvent,
328        mut should_quit: FQuit,
329    ) -> io::Result<()>
330    where
331        B: Backend,
332        FRender: FnMut(&mut Frame, Rect, &S, RenderContext),
333        FEvent: FnMut(&EventKind, &S) -> R,
334        R: Into<EventOutcome<A>>,
335        FQuit: FnMut(&A) -> bool,
336    {
337        let (event_tx, mut event_rx) = mpsc::unbounded_channel::<RawEvent>();
338        let cancel_token = CancellationToken::new();
339        let _handle = spawn_event_poller(
340            event_tx,
341            self.poller_config.poll_timeout,
342            self.poller_config.loop_sleep,
343            cancel_token.clone(),
344        );
345
346        loop {
347            if self.should_render {
348                let state = self.store.state();
349                let render_ctx = RenderContext {
350                    debug_enabled: self
351                        .debug
352                        .as_ref()
353                        .map(|debug| debug.is_enabled())
354                        .unwrap_or(false),
355                };
356                terminal.draw(|frame| {
357                    if let Some(debug) = self.debug.as_mut() {
358                        let mut render_fn =
359                            |f: &mut Frame, area: Rect, state: &S, ctx: RenderContext| {
360                                render(f, area, state, ctx);
361                            };
362                        debug.render(frame, state, render_ctx, &mut render_fn);
363                    } else {
364                        render(frame, frame.area(), state, render_ctx);
365                    }
366                })?;
367                self.should_render = false;
368            }
369
370            tokio::select! {
371                Some(raw_event) = event_rx.recv() => {
372                    let event = process_raw_event(raw_event);
373
374                    if let Some(debug) = self.debug.as_mut() {
375                        if let Some(needs_render) =
376                            debug.handle_event(&event, self.store.state(), &self.action_tx)
377                        {
378                            self.should_render = needs_render;
379                            continue;
380                        }
381                    }
382
383                    let outcome: EventOutcome<A> = map_event(&event, self.store.state()).into();
384                    if outcome.needs_render {
385                        self.should_render = true;
386                    }
387                    for action in outcome.actions {
388                        let _ = self.action_tx.send(action);
389                    }
390                }
391
392                Some(action) = self.action_rx.recv() => {
393                    if should_quit(&action) {
394                        break;
395                    }
396
397                    if let Some(debug) = self.debug.as_mut() {
398                        debug.log_action(&action);
399                    }
400
401                    self.should_render = self.store.dispatch(action);
402                }
403
404                else => {
405                    break;
406                }
407            }
408        }
409
410        cancel_token.cancel();
411        Ok(())
412    }
413}
414
415/// Context passed to effect handlers.
416pub struct EffectContext<'a, A: Action> {
417    action_tx: &'a mpsc::UnboundedSender<A>,
418    #[cfg(feature = "tasks")]
419    tasks: &'a mut TaskManager<A>,
420    #[cfg(feature = "subscriptions")]
421    subscriptions: &'a mut Subscriptions<A>,
422}
423
424impl<'a, A: Action> EffectContext<'a, A> {
425    /// Send an action directly.
426    pub fn emit(&self, action: A) {
427        let _ = self.action_tx.send(action);
428    }
429
430    /// Access the action sender.
431    pub fn action_tx(&self) -> &mpsc::UnboundedSender<A> {
432        self.action_tx
433    }
434
435    /// Access the task manager.
436    #[cfg(feature = "tasks")]
437    pub fn tasks(&mut self) -> &mut TaskManager<A> {
438        self.tasks
439    }
440
441    /// Access subscriptions.
442    #[cfg(feature = "subscriptions")]
443    pub fn subscriptions(&mut self) -> &mut Subscriptions<A> {
444        self.subscriptions
445    }
446}
447
448/// Runtime helper for effect-based stores.
449pub struct EffectRuntime<S, A: Action, E, St: EffectStoreLike<S, A, E> = EffectStore<S, A, E>> {
450    store: St,
451    action_tx: mpsc::UnboundedSender<A>,
452    action_rx: mpsc::UnboundedReceiver<A>,
453    poller_config: PollerConfig,
454    debug: Option<Box<dyn DebugAdapter<S, A>>>,
455    should_render: bool,
456    #[cfg(feature = "tasks")]
457    tasks: TaskManager<A>,
458    #[cfg(feature = "subscriptions")]
459    subscriptions: Subscriptions<A>,
460    _state: std::marker::PhantomData<S>,
461    _effect: std::marker::PhantomData<E>,
462}
463
464impl<S: 'static, A: Action, E> EffectRuntime<S, A, E, EffectStore<S, A, E>> {
465    /// Create a runtime from state + effect reducer.
466    pub fn new(state: S, reducer: crate::effect::EffectReducer<S, A, E>) -> Self {
467        Self::from_store(EffectStore::new(state, reducer))
468    }
469}
470
471impl<S: 'static, A: Action, E, St: EffectStoreLike<S, A, E>> EffectRuntime<S, A, E, St> {
472    /// Create a runtime from an existing effect store.
473    pub fn from_store(store: St) -> Self {
474        let (action_tx, action_rx) = mpsc::unbounded_channel();
475
476        #[cfg(feature = "tasks")]
477        let tasks = TaskManager::new(action_tx.clone());
478        #[cfg(feature = "subscriptions")]
479        let subscriptions = Subscriptions::new(action_tx.clone());
480
481        Self {
482            store,
483            action_tx,
484            action_rx,
485            poller_config: PollerConfig::default(),
486            debug: None,
487            should_render: true,
488            #[cfg(feature = "tasks")]
489            tasks,
490            #[cfg(feature = "subscriptions")]
491            subscriptions,
492            _state: std::marker::PhantomData,
493            _effect: std::marker::PhantomData,
494        }
495    }
496
497    /// Attach a debug layer (auto-wires tasks/subscriptions when available).
498    pub fn with_debug(mut self, debug: DebugLayer<A>) -> Self
499    where
500        S: DebugState,
501        A: ActionParams,
502    {
503        let debug = {
504            let debug = debug;
505            #[cfg(feature = "tasks")]
506            let debug = debug.with_task_manager(&self.tasks);
507            #[cfg(feature = "subscriptions")]
508            let debug = debug.with_subscriptions(&self.subscriptions);
509            debug
510        };
511        let adapter: Box<dyn DebugAdapter<S, A>> = Box::new(debug);
512        self.debug = Some(adapter);
513        self
514    }
515
516    /// Configure event polling behavior.
517    pub fn with_event_poller(mut self, config: PollerConfig) -> Self {
518        self.poller_config = config;
519        self
520    }
521
522    /// Send an action into the runtime queue.
523    pub fn enqueue(&self, action: A) {
524        let _ = self.action_tx.send(action);
525    }
526
527    /// Clone the action sender.
528    pub fn action_tx(&self) -> mpsc::UnboundedSender<A> {
529        self.action_tx.clone()
530    }
531
532    /// Access the current state.
533    pub fn state(&self) -> &S {
534        self.store.state()
535    }
536
537    /// Access the task manager.
538    #[cfg(feature = "tasks")]
539    pub fn tasks(&mut self) -> &mut TaskManager<A> {
540        &mut self.tasks
541    }
542
543    /// Access subscriptions.
544    #[cfg(feature = "subscriptions")]
545    pub fn subscriptions(&mut self) -> &mut Subscriptions<A> {
546        &mut self.subscriptions
547    }
548
549    #[cfg(all(feature = "tasks", feature = "subscriptions"))]
550    fn effect_context(&mut self) -> EffectContext<'_, A> {
551        EffectContext {
552            action_tx: &self.action_tx,
553            tasks: &mut self.tasks,
554            subscriptions: &mut self.subscriptions,
555        }
556    }
557
558    #[cfg(all(feature = "tasks", not(feature = "subscriptions")))]
559    fn effect_context(&mut self) -> EffectContext<'_, A> {
560        EffectContext {
561            action_tx: &self.action_tx,
562            tasks: &mut self.tasks,
563        }
564    }
565
566    #[cfg(all(not(feature = "tasks"), feature = "subscriptions"))]
567    fn effect_context(&mut self) -> EffectContext<'_, A> {
568        EffectContext {
569            action_tx: &self.action_tx,
570            subscriptions: &mut self.subscriptions,
571        }
572    }
573
574    #[cfg(all(not(feature = "tasks"), not(feature = "subscriptions")))]
575    fn effect_context(&mut self) -> EffectContext<'_, A> {
576        EffectContext {
577            action_tx: &self.action_tx,
578        }
579    }
580
581    /// Run the event/action loop until quit.
582    pub async fn run<B, FRender, FEvent, FQuit, FEffect, R>(
583        &mut self,
584        terminal: &mut Terminal<B>,
585        mut render: FRender,
586        mut map_event: FEvent,
587        mut should_quit: FQuit,
588        mut handle_effect: FEffect,
589    ) -> io::Result<()>
590    where
591        B: Backend,
592        FRender: FnMut(&mut Frame, Rect, &S, RenderContext),
593        FEvent: FnMut(&EventKind, &S) -> R,
594        R: Into<EventOutcome<A>>,
595        FQuit: FnMut(&A) -> bool,
596        FEffect: FnMut(E, &mut EffectContext<A>),
597    {
598        let (event_tx, mut event_rx) = mpsc::unbounded_channel::<RawEvent>();
599        let cancel_token = CancellationToken::new();
600        let _handle = spawn_event_poller(
601            event_tx,
602            self.poller_config.poll_timeout,
603            self.poller_config.loop_sleep,
604            cancel_token.clone(),
605        );
606
607        loop {
608            if self.should_render {
609                let state = self.store.state();
610                let render_ctx = RenderContext {
611                    debug_enabled: self
612                        .debug
613                        .as_ref()
614                        .map(|debug| debug.is_enabled())
615                        .unwrap_or(false),
616                };
617                terminal.draw(|frame| {
618                    if let Some(debug) = self.debug.as_mut() {
619                        let mut render_fn =
620                            |f: &mut Frame, area: Rect, state: &S, ctx: RenderContext| {
621                                render(f, area, state, ctx);
622                            };
623                        debug.render(frame, state, render_ctx, &mut render_fn);
624                    } else {
625                        render(frame, frame.area(), state, render_ctx);
626                    }
627                })?;
628                self.should_render = false;
629            }
630
631            tokio::select! {
632                Some(raw_event) = event_rx.recv() => {
633                    let event = process_raw_event(raw_event);
634
635                    if let Some(debug) = self.debug.as_mut() {
636                        if let Some(needs_render) =
637                            debug.handle_event(&event, self.store.state(), &self.action_tx)
638                        {
639                            self.should_render = needs_render;
640                            continue;
641                        }
642                    }
643
644                    let outcome: EventOutcome<A> = map_event(&event, self.store.state()).into();
645                    if outcome.needs_render {
646                        self.should_render = true;
647                    }
648                    for action in outcome.actions {
649                        let _ = self.action_tx.send(action);
650                    }
651                }
652
653                Some(action) = self.action_rx.recv() => {
654                    if should_quit(&action) {
655                        break;
656                    }
657
658                    if let Some(debug) = self.debug.as_mut() {
659                        debug.log_action(&action);
660                    }
661
662                    let result = self.store.dispatch(action);
663                    if result.has_effects() {
664                        let mut ctx = self.effect_context();
665                        for effect in result.effects {
666                            handle_effect(effect, &mut ctx);
667                        }
668                    }
669                    self.should_render = result.changed;
670                }
671
672                else => {
673                    break;
674                }
675            }
676        }
677
678        cancel_token.cancel();
679        #[cfg(feature = "subscriptions")]
680        self.subscriptions.cancel_all();
681        #[cfg(feature = "tasks")]
682        self.tasks.cancel_all();
683
684        Ok(())
685    }
686}