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