A simple TUI Library written in Rust
1use std::io;
2use std::sync::mpsc;
3use std::time::Duration;
4
5use crate::block::Block;
6use crate::input::{self, Key, RawMode};
7use crate::terminal;
8use crate::view::View;
9
10/// A Component is a pure state machine combined with a renderer.
11/// Uses closures (matching Gleam's record-of-functions pattern).
12pub struct Component<S, R> {
13 pub state: S,
14 pub render: Box<dyn Fn(&S) -> Block>,
15 pub update: Box<dyn Fn(&S, &Key) -> Update<S, R>>,
16}
17
18/// The result of processing a key event in a component.
19pub enum Update<S, R> {
20 /// Key handled, new state -- keep the event loop running
21 Continue(S),
22 /// Yield a value while continuing (for multi-step flows)
23 Emit(S, R),
24 /// Component is done -- return this final value
25 Finish(R),
26 /// User cancelled (Escape/CtrlC)
27 Cancel,
28 /// Key not handled by this component (for focus management)
29 Ignore,
30}
31
32/// Errors that can occur when running a component.
33#[derive(Debug)]
34pub enum RunError {
35 /// User pressed Escape or Ctrl+C
36 Cancelled,
37 /// Error reading from the terminal
38 InputError(io::Error),
39}
40
41impl<S, R> Component<S, R> {
42 pub fn new(
43 state: S,
44 render: impl Fn(&S) -> Block + 'static,
45 update: impl Fn(&S, &Key) -> Update<S, R> + 'static,
46 ) -> Self {
47 Component {
48 state,
49 render: Box::new(render),
50 update: Box::new(update),
51 }
52 }
53
54 /// Run this component's interactive event loop.
55 /// Installs a SIGWINCH handler so that terminal resizes cause an immediate
56 /// redraw at the new width.
57 pub fn run(self, mode: &RawMode) -> Result<R, RunError> {
58 let width = terminal::get_size()
59 .map(|(cols, _)| cols as usize)
60 .unwrap_or(80);
61 terminal::install_resize_handler();
62 self.run_with_width(mode, width)
63 }
64
65 /// Run with an explicit rendering width.
66 pub fn run_with_width(self, mode: &RawMode, width: usize) -> Result<R, RunError> {
67 let Component {
68 state,
69 render,
70 update,
71 } = self;
72 let initial_block = (render)(&state);
73 let mut view = View::create(&initial_block, width);
74 let result = event_loop(&render, &update, state, &mut view, mode);
75 match &result {
76 Ok(_) => view.finish(),
77 Err(RunError::Cancelled) => view.erase(),
78 Err(RunError::InputError(_)) => view.finish(),
79 }
80 result
81 }
82
83 /// Run with periodic tick updates (for animated components like spinners).
84 pub fn run_animated(
85 self,
86 mode: &RawMode,
87 tick_ms: u64,
88 on_tick: impl Fn(S) -> S,
89 ) -> Result<R, RunError> {
90 let width = terminal::get_size()
91 .map(|(cols, _)| cols as usize)
92 .unwrap_or(80);
93 let Component {
94 state,
95 render,
96 update,
97 } = self;
98 let initial_block = (render)(&state);
99 let mut view = View::create(&initial_block, width);
100 let result = animated_loop(&render, &update, state, &mut view, mode, tick_ms, &on_tick);
101 match &result {
102 Ok(_) => view.finish(),
103 Err(RunError::Cancelled) => view.erase(),
104 Err(RunError::InputError(_)) => view.finish(),
105 }
106 result
107 }
108
109 /// Run with a message channel. The event loop multiplexes between stdin
110 /// keypresses and messages from background threads.
111 ///
112 /// `on_msg` is called for each received message and produces an `Update`,
113 /// just like the `update` function for keypresses.
114 ///
115 /// Uses a short `read_key_timeout` interval (10 ms) to poll the channel
116 /// between key reads, keeping message latency low without busy-spinning.
117 pub fn run_with_channel<Msg>(
118 self,
119 mode: &RawMode,
120 rx: mpsc::Receiver<Msg>,
121 on_msg: impl Fn(&S, Msg) -> Update<S, R> + 'static,
122 ) -> Result<R, RunError> {
123 let width = terminal::get_size()
124 .map(|(cols, _)| cols as usize)
125 .unwrap_or(80);
126 terminal::install_resize_handler();
127 let Component {
128 state,
129 render,
130 update,
131 } = self;
132 let initial_block = (render)(&state);
133 let mut view = View::create(&initial_block, width);
134 let result = channel_loop(&render, &update, &on_msg, state, &mut view, mode, &rx);
135 match &result {
136 Ok(_) => view.finish(),
137 Err(RunError::Cancelled) => view.erase(),
138 Err(RunError::InputError(_)) => view.finish(),
139 }
140 result
141 }
142
143 /// Transform the result type of this component.
144 pub fn map<R2: 'static>(self, f: impl Fn(R) -> R2 + 'static) -> Component<S, R2>
145 where
146 S: 'static,
147 R: 'static,
148 {
149 let update = self.update;
150 Component {
151 state: self.state,
152 render: self.render,
153 update: Box::new(move |state, key| match (update)(state, key) {
154 Update::Continue(s) => Update::Continue(s),
155 Update::Emit(s, r) => Update::Emit(s, f(r)),
156 Update::Finish(r) => Update::Finish(f(r)),
157 Update::Cancel => Update::Cancel,
158 Update::Ignore => Update::Ignore,
159 }),
160 }
161 }
162
163 /// Set the initial state.
164 pub fn with_state(self, state: S) -> Self {
165 Component { state, ..self }
166 }
167}
168
169fn event_loop<S, R>(
170 render: &dyn Fn(&S) -> Block,
171 update: &dyn Fn(&S, &Key) -> Update<S, R>,
172 mut state: S,
173 view: &mut View,
174 mode: &RawMode,
175) -> Result<R, RunError> {
176 loop {
177 // Check for terminal resize before blocking on the next key
178 if terminal::take_resize() {
179 let new_width = terminal::get_size()
180 .map(|(c, _)| c as usize)
181 .unwrap_or(view.width());
182 let block = (render)(&state);
183 view.resize(&block, new_width);
184 }
185 let key = input::read_key(mode).map_err(RunError::InputError)?;
186 match (update)(&state, &key) {
187 Update::Continue(new_state) => {
188 state = new_state;
189 let block = (render)(&state);
190 view.update(&block);
191 }
192 Update::Emit(_, result) => return Ok(result),
193 Update::Finish(result) => return Ok(result),
194 Update::Cancel => return Err(RunError::Cancelled),
195 Update::Ignore => {}
196 }
197 }
198}
199
200fn channel_loop<S, R, Msg>(
201 render: &dyn Fn(&S) -> Block,
202 update: &dyn Fn(&S, &Key) -> Update<S, R>,
203 on_msg: &dyn Fn(&S, Msg) -> Update<S, R>,
204 mut state: S,
205 view: &mut View,
206 mode: &RawMode,
207 rx: &mpsc::Receiver<Msg>,
208) -> Result<R, RunError> {
209 // Poll interval — short enough for responsive messages, not a busy spin.
210 let poll_ms = Duration::from_millis(10);
211 loop {
212 // Handle terminal resize
213 if terminal::take_resize() {
214 let new_width = terminal::get_size()
215 .map(|(c, _)| c as usize)
216 .unwrap_or(view.width());
217 let block = (render)(&state);
218 view.resize(&block, new_width);
219 }
220
221 // Drain all pending channel messages
222 loop {
223 match rx.try_recv() {
224 Ok(msg) => match on_msg(&state, msg) {
225 Update::Continue(new_state) => {
226 state = new_state;
227 let block = (render)(&state);
228 view.update(&block);
229 }
230 Update::Emit(_, result) => return Ok(result),
231 Update::Finish(result) => return Ok(result),
232 Update::Cancel => return Err(RunError::Cancelled),
233 Update::Ignore => {}
234 },
235 Err(mpsc::TryRecvError::Empty) => break,
236 Err(mpsc::TryRecvError::Disconnected) => break,
237 }
238 }
239
240 // Wait briefly for a keypress
241 match input::read_key_timeout(mode, poll_ms).map_err(RunError::InputError)? {
242 None => {} // timeout — loop again to check channel
243 Some(key) => match (update)(&state, &key) {
244 Update::Continue(new_state) => {
245 state = new_state;
246 let block = (render)(&state);
247 view.update(&block);
248 }
249 Update::Emit(_, result) => return Ok(result),
250 Update::Finish(result) => return Ok(result),
251 Update::Cancel => return Err(RunError::Cancelled),
252 Update::Ignore => {}
253 },
254 }
255 }
256}
257
258fn animated_loop<S, R>(
259 render: &dyn Fn(&S) -> Block,
260 update: &dyn Fn(&S, &Key) -> Update<S, R>,
261 mut state: S,
262 view: &mut View,
263 mode: &RawMode,
264 tick_ms: u64,
265 on_tick: &dyn Fn(S) -> S,
266) -> Result<R, RunError> {
267 let timeout = Duration::from_millis(tick_ms);
268 loop {
269 // Handle terminal resize
270 if terminal::take_resize() {
271 let new_width = terminal::get_size()
272 .map(|(c, _)| c as usize)
273 .unwrap_or(view.width());
274 let block = (render)(&state);
275 view.resize(&block, new_width);
276 }
277 match input::read_key_timeout(mode, timeout).map_err(RunError::InputError)? {
278 None => {
279 state = on_tick(state);
280 let block = (render)(&state);
281 view.update(&block);
282 }
283 Some(key) => match (update)(&state, &key) {
284 Update::Continue(new_state) => {
285 state = new_state;
286 let block = (render)(&state);
287 view.update(&block);
288 }
289 Update::Emit(_, result) => return Ok(result),
290 Update::Finish(result) => return Ok(result),
291 Update::Cancel => return Err(RunError::Cancelled),
292 Update::Ignore => {}
293 },
294 }
295 }
296}