a3s_tui/
element_program.rs1use 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
39pub 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}