Skip to main content

a3s_tui/
program.rs

1use crate::cmd::{Cmd, CmdResult};
2use crate::event::Event;
3use crate::model::Model;
4use crate::renderer::Renderer;
5use crate::terminal::{Terminal, TerminalOptions};
6
7use crossterm::event::EventStream;
8use futures_util::StreamExt;
9use std::io;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12use tokio::sync::{mpsc, Notify};
13
14fn signal_quit(quit: &Arc<AtomicBool>, quit_notify: &Arc<Notify>) {
15    quit.store(true, Ordering::Relaxed);
16    quit_notify.notify_one();
17}
18
19pub struct ProgramBuilder<M: Model> {
20    model: M,
21    alt_screen: bool,
22    mouse_support: bool,
23    fps: u32,
24}
25
26impl<M: Model> ProgramBuilder<M>
27where
28    M::Msg: From<Event>,
29{
30    pub fn new(model: M) -> Self {
31        Self {
32            model,
33            alt_screen: true,
34            mouse_support: false,
35            fps: 60,
36        }
37    }
38
39    pub fn with_alt_screen(mut self) -> Self {
40        self.alt_screen = true;
41        self
42    }
43
44    pub fn without_alt_screen(mut self) -> Self {
45        self.alt_screen = false;
46        self
47    }
48
49    pub fn with_mouse_support(mut self) -> Self {
50        self.mouse_support = true;
51        self
52    }
53
54    pub fn with_fps(mut self, fps: u32) -> Self {
55        self.fps = fps.clamp(1, 120);
56        self
57    }
58
59    pub async fn run(self) -> io::Result<()> {
60        Program::run_inner(
61            self.model,
62            TerminalOptions {
63                alt_screen: self.alt_screen,
64                mouse_support: self.mouse_support,
65                raw_mode: true,
66            },
67            self.fps,
68        )
69        .await
70    }
71}
72
73pub struct Program;
74
75impl Program {
76    pub async fn run<M: Model>(model: M) -> io::Result<()>
77    where
78        M::Msg: From<Event>,
79    {
80        Self::run_inner(model, TerminalOptions::default(), 60).await
81    }
82
83    async fn run_inner<M: Model>(mut model: M, options: TerminalOptions, fps: u32) -> io::Result<()>
84    where
85        M::Msg: From<Event>,
86    {
87        let mut terminal = Terminal::new(&options)?;
88        terminal.enter()?;
89
90        let (msg_tx, mut msg_rx) = mpsc::unbounded_channel::<M::Msg>();
91        let quit_flag = Arc::new(AtomicBool::new(false));
92        let quit_notify = Arc::new(Notify::new());
93
94        if let Some(cmd) = model.init() {
95            Self::dispatch_cmd(cmd, msg_tx.clone(), quit_flag.clone(), quit_notify.clone());
96        }
97
98        let mut event_stream = EventStream::new();
99        let mut renderer = Renderer::new(fps);
100        let mut dirty = false;
101
102        let view = model.view();
103        renderer.render(&mut terminal, &view)?;
104        // Place the cursor on the first frame too, so the input is focused
105        // immediately (not only after the first key event).
106        match model.cursor() {
107            Some((col, row)) => terminal.show_cursor_at(col, row)?,
108            None => terminal.hide_cursor()?,
109        }
110
111        loop {
112            if quit_flag.load(Ordering::Relaxed) {
113                break;
114            }
115
116            // Terminal events (keystrokes) render immediately for responsive
117            // input echo; internal messages (e.g. streaming deltas) stay
118            // frame-throttled to avoid flicker.
119            let mut immediate = false;
120            tokio::select! {
121                event = event_stream.next() => {
122                    match event {
123                        Some(Ok(ct_event)) => {
124                            // A resize shifts every row — force a full clear+redraw.
125                            if matches!(ct_event, crossterm::event::Event::Resize(_, _)) {
126                                renderer.invalidate();
127                            }
128                            if let Some(ev) = Event::from_crossterm(ct_event) {
129                                immediate = true;
130                                let msg: M::Msg = ev.into();
131                                if let Some(cmd) = model.update(msg) {
132                                    Self::dispatch_cmd(cmd, msg_tx.clone(), quit_flag.clone(), quit_notify.clone());
133                                }
134                                dirty = true;
135                            }
136                        }
137                        Some(Err(_)) => break,
138                        None => break,
139                    }
140                }
141                Some(msg) = msg_rx.recv() => {
142                    if let Some(cmd) = model.update(msg) {
143                        Self::dispatch_cmd(cmd, msg_tx.clone(), quit_flag.clone(), quit_notify.clone());
144                    }
145                    dirty = true;
146                }
147                _ = quit_notify.notified() => {
148                }
149                _ = tokio::time::sleep(renderer.time_until_next_frame()), if dirty => {
150                }
151            }
152
153            if quit_flag.load(Ordering::Relaxed) {
154                break;
155            }
156
157            if immediate {
158                let view = model.view();
159                renderer.render(&mut terminal, &view)?;
160                dirty = false;
161            } else if dirty && renderer.is_frame_due() {
162                let view = model.view();
163                if renderer.is_changed(&view) {
164                    renderer.render(&mut terminal, &view)?;
165                }
166                dirty = false;
167            }
168            // Place the real terminal cursor at the model's insertion point (or
169            // hide it). Done after rendering so it sits on top of the content.
170            match model.cursor() {
171                Some((col, row)) => terminal.show_cursor_at(col, row)?,
172                None => terminal.hide_cursor()?,
173            }
174        }
175
176        terminal.exit()?;
177        std::mem::forget(terminal);
178        Ok(())
179    }
180
181    fn dispatch_cmd<M: Send + 'static>(
182        cmd: Cmd<M>,
183        tx: mpsc::UnboundedSender<M>,
184        quit: Arc<AtomicBool>,
185        quit_notify: Arc<Notify>,
186    ) {
187        tokio::spawn(async move {
188            let result = cmd.await;
189            match result {
190                CmdResult::Quit => {
191                    signal_quit(&quit, &quit_notify);
192                }
193                CmdResult::Msg(m) => {
194                    let _ = tx.send(m);
195                }
196                CmdResult::Batch(cmds) => {
197                    for c in cmds {
198                        let tx2 = tx.clone();
199                        let quit2 = quit.clone();
200                        let quit_notify2 = quit_notify.clone();
201                        tokio::spawn(async move {
202                            let r = c.await;
203                            match r {
204                                CmdResult::Quit => {
205                                    signal_quit(&quit2, &quit_notify2);
206                                }
207                                CmdResult::Msg(m) => {
208                                    let _ = tx2.send(m);
209                                }
210                                CmdResult::Batch(inner_cmds) => {
211                                    for ic in inner_cmds {
212                                        let tx3 = tx2.clone();
213                                        let quit3 = quit2.clone();
214                                        let quit_notify3 = quit_notify2.clone();
215                                        tokio::spawn(async move {
216                                            let r = ic.await;
217                                            match r {
218                                                CmdResult::Quit => {
219                                                    signal_quit(&quit3, &quit_notify3);
220                                                }
221                                                CmdResult::Msg(m) => {
222                                                    let _ = tx3.send(m);
223                                                }
224                                                _ => {}
225                                            }
226                                        });
227                                    }
228                                }
229                                CmdResult::None => {}
230                            }
231                        });
232                    }
233                }
234                CmdResult::None => {}
235            }
236        });
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use std::time::Duration;
244
245    #[tokio::test]
246    async fn signal_quit_sets_flag_and_wakes_late_waiter() {
247        let quit = Arc::new(AtomicBool::new(false));
248        let notify = Arc::new(Notify::new());
249
250        signal_quit(&quit, &notify);
251
252        assert!(quit.load(Ordering::Relaxed));
253        tokio::time::timeout(Duration::from_millis(50), notify.notified())
254            .await
255            .expect("quit notification should be retained until the runner awaits it");
256    }
257}