A simple TUI Library written in Rust
1use std::io;
2
3use crate::block::Block;
4use crate::component::{RunError, Update};
5use crate::input::{self, Key, RawMode};
6use crate::style::{Line, Span, Style};
7use crate::terminal;
8use crate::view::View;
9
10// ---------------------------------------------------------------------------
11// Trait
12// ---------------------------------------------------------------------------
13
14/// Outcome of processing a keypress inside a `FocusItem`.
15pub enum FocusEvent {
16 /// Item handled the key and updated internal state.
17 Continue,
18 /// Item finished (e.g. user pressed Enter on a text field).
19 Done,
20 /// User pressed Escape/Ctrl+C — entire group should cancel.
21 Cancel,
22 /// Item did not handle this key (focus group may intercept for Tab).
23 Ignore,
24}
25
26/// A component that can participate in a `FocusGroup`.
27///
28/// Because focus items are held as mutable references, the user retains
29/// ownership of each item and can inspect results after `FocusGroup::run`.
30pub trait FocusItem {
31 fn render(&self) -> Block;
32 fn handle_key(&mut self, key: &Key) -> FocusEvent;
33}
34
35// ---------------------------------------------------------------------------
36// FocusSlot — wraps a Component<S, R> as a FocusItem
37// ---------------------------------------------------------------------------
38
39/// Wraps a `Component`-style state machine as a `FocusItem`, storing
40/// the final result so it can be retrieved after `FocusGroup::run`.
41pub struct FocusSlot<S: Clone, R> {
42 state: S,
43 render_fn: Box<dyn Fn(&S) -> Block>,
44 update_fn: Box<dyn Fn(&S, &Key) -> Update<S, R>>,
45 pub result: Option<R>,
46 pub done: bool,
47}
48
49impl<S: Clone, R> FocusSlot<S, R> {
50 pub fn new(
51 state: S,
52 render: impl Fn(&S) -> Block + 'static,
53 update: impl Fn(&S, &Key) -> Update<S, R> + 'static,
54 ) -> Self {
55 FocusSlot {
56 state,
57 render_fn: Box::new(render),
58 update_fn: Box::new(update),
59 result: None,
60 done: false,
61 }
62 }
63
64 pub fn state(&self) -> &S {
65 &self.state
66 }
67}
68
69impl<S: Clone, R> FocusItem for FocusSlot<S, R> {
70 fn render(&self) -> Block {
71 (self.render_fn)(&self.state)
72 }
73
74 fn handle_key(&mut self, key: &Key) -> FocusEvent {
75 match (self.update_fn)(&self.state, key) {
76 Update::Continue(s) => {
77 self.state = s;
78 FocusEvent::Continue
79 }
80 Update::Emit(s, r) => {
81 self.state = s;
82 self.result = Some(r);
83 FocusEvent::Continue
84 }
85 Update::Finish(r) => {
86 self.result = Some(r);
87 self.done = true;
88 FocusEvent::Done
89 }
90 Update::Cancel => FocusEvent::Cancel,
91 Update::Ignore => FocusEvent::Ignore,
92 }
93 }
94}
95
96// ---------------------------------------------------------------------------
97// FocusGroup
98// ---------------------------------------------------------------------------
99
100/// Style options for the focus indicator shown next to each item.
101pub struct FocusGroupStyle {
102 /// Prefix shown next to the focused item (e.g. `"▶ "`).
103 pub focused_prefix: String,
104 /// Prefix shown next to unfocused items (spaces to match width).
105 pub unfocused_prefix: String,
106 pub focused_prefix_style: Style,
107 pub unfocused_prefix_style: Style,
108}
109
110impl Default for FocusGroupStyle {
111 fn default() -> Self {
112 use crate::color::Color;
113 FocusGroupStyle {
114 focused_prefix: "▶ ".into(),
115 unfocused_prefix: " ".into(),
116 focused_prefix_style: Style::new().bold().fg(Color::Cyan),
117 unfocused_prefix_style: Style::new(),
118 }
119 }
120}
121
122/// Manages a list of `FocusItem` mutable references, routing keypresses to
123/// the focused item and handling `Tab` / `BackTab` to move focus.
124///
125/// Returns when:
126/// - `Ctrl+Enter` is pressed (all items are considered submitted as-is).
127/// - `Escape` or `Ctrl+C` is received from any item.
128/// - All items have signalled `Done`.
129pub struct FocusGroup<'a> {
130 items: Vec<&'a mut dyn FocusItem>,
131 focused: usize,
132 style: FocusGroupStyle,
133}
134
135impl<'a> FocusGroup<'a> {
136 pub fn new(items: Vec<&'a mut dyn FocusItem>) -> Self {
137 FocusGroup {
138 items,
139 focused: 0,
140 style: FocusGroupStyle::default(),
141 }
142 }
143
144 pub fn style(self, style: FocusGroupStyle) -> Self {
145 Self { style, ..self }
146 }
147
148 /// Run the interactive event loop.
149 ///
150 /// - `Tab` / `BackTab` moves focus between items.
151 /// - `Ctrl+Enter` (sent as `Ctrl('j')` or `Ctrl('m')`) submits the group.
152 /// - Escape / Ctrl+C from any item cancels the entire group.
153 pub fn run(mut self, mode: &RawMode) -> Result<(), RunError> {
154 let width = terminal::get_size().map(|(c, _)| c as usize).unwrap_or(80);
155 let initial_block = self.render_all();
156 let mut view = View::create(&initial_block, width);
157
158 let result = self.event_loop(mode, &mut view);
159
160 match &result {
161 Ok(_) => view.finish(),
162 Err(RunError::Cancelled) => view.erase(),
163 Err(RunError::InputError(_)) => view.finish(),
164 }
165 result
166 }
167
168 fn render_all(&self) -> Block {
169 let n = self.items.len();
170 let blocks: Vec<Block> = self
171 .items
172 .iter()
173 .enumerate()
174 .map(|(i, item)| {
175 let is_focused = i == self.focused;
176 let prefix_str = if is_focused {
177 &self.style.focused_prefix
178 } else {
179 &self.style.unfocused_prefix
180 };
181 let prefix_style = if is_focused {
182 &self.style.focused_prefix_style
183 } else {
184 &self.style.unfocused_prefix_style
185 };
186 let prefix_line: Line =
187 vec![Span::styled(prefix_str.clone(), prefix_style.clone())];
188 let prefix_block = Block::text(vec![prefix_line]);
189 let content_block = item.render();
190 Block::hstack(vec![prefix_block, content_block], 0)
191 })
192 .collect();
193
194 if n == 0 {
195 Block::text(vec![])
196 } else {
197 Block::vstack(blocks, 0)
198 }
199 }
200
201 fn event_loop(&mut self, mode: &RawMode, view: &mut View) -> Result<(), RunError> {
202 loop {
203 let key = input::read_key(mode).map_err(RunError::InputError)?;
204
205 // Ctrl+Enter submits the whole group.
206 if matches!(key, Key::Ctrl('j') | Key::Ctrl('m')) {
207 return Ok(());
208 }
209
210 // Tab / BackTab move focus.
211 if matches!(key, Key::Tab) {
212 self.focus_next();
213 view.update(&self.render_all());
214 continue;
215 }
216 if matches!(key, Key::BackTab) {
217 self.focus_prev();
218 view.update(&self.render_all());
219 continue;
220 }
221
222 // Route to focused item.
223 let event = self.items[self.focused].handle_key(&key);
224 match event {
225 FocusEvent::Continue => {
226 view.update(&self.render_all());
227 }
228 FocusEvent::Done => {
229 // Auto-advance focus to next unfocused item.
230 if !self.focus_next_undone() {
231 // All items done — finish.
232 return Ok(());
233 }
234 view.update(&self.render_all());
235 }
236 FocusEvent::Cancel => return Err(RunError::Cancelled),
237 FocusEvent::Ignore => {}
238 }
239 }
240 }
241
242 fn focus_next(&mut self) {
243 if !self.items.is_empty() {
244 self.focused = (self.focused + 1) % self.items.len();
245 }
246 }
247
248 fn focus_prev(&mut self) {
249 if !self.items.is_empty() {
250 let n = self.items.len();
251 self.focused = (self.focused + n - 1) % n;
252 }
253 }
254
255 /// Advance focus to the next item that hasn't signalled Done.
256 /// Returns `true` if such an item was found.
257 fn focus_next_undone(&mut self) -> bool {
258 let n = self.items.len();
259 if let Some(offset) = (1..=n).next() {
260 let idx = (self.focused + offset) % n;
261 // We can't inspect `done` without downcasting unless FocusItem exposes it.
262 // For simplicity: always advance to next item.
263 self.focused = idx;
264 return true;
265 }
266 false
267 }
268}
269
270// ---------------------------------------------------------------------------
271// RunError impls
272// ---------------------------------------------------------------------------
273
274impl From<io::Error> for RunError {
275 fn from(e: io::Error) -> Self {
276 RunError::InputError(e)
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 struct CounterItem {
285 count: usize,
286 done_at: usize,
287 pub final_count: Option<usize>,
288 }
289
290 impl CounterItem {
291 fn new(done_at: usize) -> Self {
292 CounterItem {
293 count: 0,
294 done_at,
295 final_count: None,
296 }
297 }
298 }
299
300 impl FocusItem for CounterItem {
301 fn render(&self) -> Block {
302 Block::text(vec![vec![Span::plain(format!("count={}", self.count))]])
303 }
304
305 fn handle_key(&mut self, key: &Key) -> FocusEvent {
306 match key {
307 Key::Char(' ') => {
308 self.count += 1;
309 if self.count >= self.done_at {
310 self.final_count = Some(self.count);
311 FocusEvent::Done
312 } else {
313 FocusEvent::Continue
314 }
315 }
316 Key::Escape | Key::CtrlC => FocusEvent::Cancel,
317 _ => FocusEvent::Ignore,
318 }
319 }
320 }
321
322 #[test]
323 fn focus_slot_stores_result() {
324 use crate::component::Update;
325
326 let mut slot: FocusSlot<String, String> = FocusSlot::new(
327 "hello".to_string(),
328 |s| Block::text(vec![vec![Span::plain(s.clone())]]),
329 |s, key| match key {
330 Key::Enter => Update::Finish(s.clone()),
331 Key::CtrlC => Update::Cancel,
332 _ => Update::Ignore,
333 },
334 );
335
336 assert!(slot.result.is_none());
337 assert!(!slot.done);
338
339 // Enter → Done
340 let event = slot.handle_key(&Key::Enter);
341 assert!(matches!(event, FocusEvent::Done));
342 assert_eq!(slot.result.as_deref(), Some("hello"));
343 assert!(slot.done);
344 }
345
346 #[test]
347 fn focus_slot_cancel() {
348 use crate::component::Update;
349
350 let mut slot: FocusSlot<String, String> = FocusSlot::new(
351 "".to_string(),
352 |s| Block::text(vec![vec![Span::plain(s.clone())]]),
353 |_, key| match key {
354 Key::CtrlC => Update::Cancel,
355 _ => Update::Ignore,
356 },
357 );
358
359 assert!(matches!(slot.handle_key(&Key::CtrlC), FocusEvent::Cancel));
360 }
361
362 #[test]
363 fn focus_slot_continue() {
364 use crate::component::Update;
365
366 let mut slot: FocusSlot<usize, ()> = FocusSlot::new(
367 0usize,
368 |n| Block::text(vec![vec![Span::plain(n.to_string())]]),
369 |&n, key| match key {
370 Key::Char(' ') => Update::Continue(n + 1),
371 _ => Update::Ignore,
372 },
373 );
374
375 slot.handle_key(&Key::Char(' '));
376 assert_eq!(*slot.state(), 1);
377 }
378
379 #[test]
380 fn counter_item_done_event() {
381 let mut item = CounterItem::new(2);
382
383 let e1 = item.handle_key(&Key::Char(' '));
384 assert!(matches!(e1, FocusEvent::Continue));
385 assert!(item.final_count.is_none());
386
387 let e2 = item.handle_key(&Key::Char(' '));
388 assert!(matches!(e2, FocusEvent::Done));
389 assert_eq!(item.final_count, Some(2));
390 }
391}