Skip to main content

a3s_tui/
element_program.rs

1//! Element-based program runner with Flexbox layout and incremental rendering.
2//!
3//! This module provides [`ElementProgramBuilder`] for running applications that
4//! use the [`ElementModel`] trait with automatic layout computation and diff-based
5//! terminal rendering.
6
7use crate::cmd::{Cmd, CmdResult};
8use crate::diff::DiffRenderer;
9use crate::event::Event;
10use crate::layout_engine::LayoutEngine;
11use crate::model::ElementModel;
12use crate::paint;
13use crate::terminal::{Terminal, TerminalOptions};
14
15use crossterm::event::EventStream;
16use futures_util::StreamExt;
17use std::io;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::Arc;
20use std::time::{Duration, Instant};
21use tokio::sync::{mpsc, Notify};
22
23fn signal_quit(quit: &Arc<AtomicBool>, quit_notify: &Arc<Notify>) {
24    quit.store(true, Ordering::Relaxed);
25    quit_notify.notify_one();
26}
27
28fn frame_delay(last_render: Instant, frame_duration: Duration) -> Duration {
29    frame_duration.saturating_sub(last_render.elapsed())
30}
31
32fn resize_dimensions(event: &crossterm::event::Event) -> Option<(u16, u16)> {
33    match event {
34        crossterm::event::Event::Resize(width, height) => Some((*width, *height)),
35        _ => None,
36    }
37}
38
39/// Builder for configuring and running an element-based TUI program.
40///
41/// ```rust,no_run
42/// # use a3s_tui::{ElementProgramBuilder, ElementModel, Element, Event, cmd::Cmd};
43/// # struct App;
44/// # enum Msg {}
45/// # impl From<Event> for Msg { fn from(_: Event) -> Self { todo!() } }
46/// # impl ElementModel for App {
47/// #     type Msg = Msg;
48/// #     fn update(&mut self, _: Msg) -> Option<Cmd<Msg>> { None }
49/// #     fn view(&self) -> Element<Msg> { todo!() }
50/// # }
51/// # async fn run() -> std::io::Result<()> {
52/// ElementProgramBuilder::new(App)
53///     .with_alt_screen()
54///     .with_fps(30)
55///     .run()
56///     .await
57/// # }
58/// ```
59pub struct ElementProgramBuilder<M: ElementModel> {
60    model: M,
61    alt_screen: bool,
62    mouse_support: bool,
63    fps: u32,
64}
65
66impl<M: ElementModel> ElementProgramBuilder<M>
67where
68    M::Msg: From<Event>,
69{
70    pub fn new(model: M) -> Self {
71        Self {
72            model,
73            alt_screen: true,
74            mouse_support: false,
75            fps: 60,
76        }
77    }
78
79    pub fn with_alt_screen(mut self) -> Self {
80        self.alt_screen = true;
81        self
82    }
83
84    pub fn without_alt_screen(mut self) -> Self {
85        self.alt_screen = false;
86        self
87    }
88
89    pub fn with_mouse_support(mut self) -> Self {
90        self.mouse_support = true;
91        self
92    }
93
94    pub fn with_fps(mut self, fps: u32) -> Self {
95        self.fps = fps.clamp(1, 120);
96        self
97    }
98
99    pub async fn run(self) -> io::Result<()> {
100        ElementProgram::run_inner(
101            self.model,
102            TerminalOptions {
103                alt_screen: self.alt_screen,
104                mouse_support: self.mouse_support,
105                raw_mode: true,
106            },
107            self.fps,
108        )
109        .await
110    }
111}
112
113pub struct ElementProgram;
114
115impl ElementProgram {
116    pub async fn run<M: ElementModel>(model: M) -> io::Result<()>
117    where
118        M::Msg: From<Event>,
119    {
120        Self::run_inner(model, TerminalOptions::default(), 60).await
121    }
122
123    async fn run_inner<M: ElementModel>(
124        mut model: M,
125        options: TerminalOptions,
126        fps: u32,
127    ) -> io::Result<()>
128    where
129        M::Msg: From<Event>,
130    {
131        let mut terminal = Terminal::new(&options)?;
132        terminal.enter()?;
133
134        let (msg_tx, mut msg_rx) = mpsc::unbounded_channel::<M::Msg>();
135        let quit_flag = Arc::new(AtomicBool::new(false));
136        let quit_notify = Arc::new(Notify::new());
137        let frame_duration = Duration::from_secs_f64(1.0 / fps as f64);
138        let mut dirty = false;
139
140        if let Some(cmd) = model.init() {
141            Self::dispatch_cmd(cmd, msg_tx.clone(), quit_flag.clone(), quit_notify.clone());
142        }
143
144        let mut event_stream = EventStream::new();
145        let mut layout_engine = LayoutEngine::new();
146        let mut diff_renderer = DiffRenderer::new();
147
148        let mut viewport_size = Terminal::size().unwrap_or((80, 24));
149
150        {
151            let (width, height) = viewport_size;
152            let element = model.view();
153            let layout = layout_engine.compute(&element, width, height);
154            let grid = paint::paint(&element, &layout, width, height);
155            diff_renderer.render(&mut terminal, grid)?;
156        }
157        let mut last_render = Instant::now();
158
159        loop {
160            if quit_flag.load(Ordering::Relaxed) {
161                break;
162            }
163
164            tokio::select! {
165                event = event_stream.next() => {
166                    match event {
167                        Some(Ok(ct_event)) => {
168                            if let Some(size) = resize_dimensions(&ct_event) {
169                                viewport_size = size;
170                            }
171                            if let Some(ev) = Event::from_crossterm(ct_event) {
172                                let msg: M::Msg = ev.into();
173                                if let Some(cmd) = model.update(msg) {
174                                    Self::dispatch_cmd(cmd, msg_tx.clone(), quit_flag.clone(), quit_notify.clone());
175                                }
176                                dirty = true;
177                            }
178                        }
179                        Some(Err(_)) => break,
180                        None => break,
181                    }
182                }
183                Some(msg) = msg_rx.recv() => {
184                    if let Some(cmd) = model.update(msg) {
185                        Self::dispatch_cmd(cmd, msg_tx.clone(), quit_flag.clone(), quit_notify.clone());
186                    }
187                    dirty = true;
188                }
189                _ = quit_notify.notified() => {
190                }
191                _ = tokio::time::sleep(frame_delay(last_render, frame_duration)), if dirty => {
192                }
193            }
194
195            if quit_flag.load(Ordering::Relaxed) {
196                break;
197            }
198
199            if dirty && last_render.elapsed() >= frame_duration {
200                let (w, h) = viewport_size;
201                let element = model.view();
202                let layout = layout_engine.compute(&element, w, h);
203                let grid = paint::paint(&element, &layout, w, h);
204                diff_renderer.render(&mut terminal, grid)?;
205                last_render = Instant::now();
206                dirty = false;
207            }
208        }
209
210        terminal.exit()?;
211        std::mem::forget(terminal);
212        Ok(())
213    }
214
215    fn dispatch_cmd<M: Send + 'static>(
216        cmd: Cmd<M>,
217        tx: mpsc::UnboundedSender<M>,
218        quit: Arc<AtomicBool>,
219        quit_notify: Arc<Notify>,
220    ) {
221        tokio::spawn(async move {
222            let result = cmd.await;
223            match result {
224                CmdResult::Quit => {
225                    signal_quit(&quit, &quit_notify);
226                }
227                CmdResult::Msg(m) => {
228                    let _ = tx.send(m);
229                }
230                CmdResult::Batch(cmds) => {
231                    for c in cmds {
232                        let tx2 = tx.clone();
233                        let quit2 = quit.clone();
234                        let quit_notify2 = quit_notify.clone();
235                        tokio::spawn(async move {
236                            let r = c.await;
237                            match r {
238                                CmdResult::Quit => signal_quit(&quit2, &quit_notify2),
239                                CmdResult::Msg(m) => {
240                                    let _ = tx2.send(m);
241                                }
242                                _ => {}
243                            }
244                        });
245                    }
246                }
247                CmdResult::None => {}
248            }
249        });
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn frame_delay_reaches_zero_after_deadline() {
259        let frame_duration = Duration::from_millis(16);
260        let last_render = Instant::now() - Duration::from_millis(20);
261
262        assert_eq!(frame_delay(last_render, frame_duration), Duration::ZERO);
263    }
264
265    #[test]
266    fn frame_delay_reports_remaining_frame_time() {
267        let frame_duration = Duration::from_millis(16);
268        let delay = frame_delay(Instant::now(), frame_duration);
269
270        assert!(delay <= frame_duration);
271        assert!(delay > Duration::ZERO);
272    }
273
274    #[test]
275    fn resize_dimensions_extracts_terminal_size() {
276        let resize = crossterm::event::Event::Resize(120, 40);
277        let focus = crossterm::event::Event::FocusGained;
278
279        assert_eq!(resize_dimensions(&resize), Some((120, 40)));
280        assert_eq!(resize_dimensions(&focus), None);
281    }
282}