Skip to main content

es_fluent_cli/tui/
app.rs

1//! TUI application state and rendering.
2
3use crate::core::{CrateInfo, CrateState};
4use crate::tui::Message;
5use crossterm::event::{self, Event, KeyCode, KeyModifiers};
6use indexmap::IndexMap;
7use ratatui::{
8    Frame,
9    layout::{Constraint, Direction, Layout},
10    style::{Color, Modifier, Style},
11    text::{Line, Span},
12    widgets::{Block, Borders, List, ListItem, Paragraph},
13};
14use std::io;
15use std::time::{Duration, Instant};
16use throbber_widgets_tui::{BRAILLE_SIX, ThrobberState};
17
18const DEFAULT_TICK_INTERVAL: Duration = Duration::from_millis(100);
19
20/// The TUI application state.
21pub struct TuiApp<'a> {
22    /// The crates being watched.
23    pub crates: &'a [CrateInfo],
24    /// The current state of each crate.
25    pub states: IndexMap<String, CrateState>,
26    /// Whether the app should quit.
27    pub should_quit: bool,
28    /// Throbber state for the "generating" animation.
29    pub throbber_state: ThrobberState,
30    /// How often to advance the animation.
31    pub tick_interval: Duration,
32    /// Last time the animation was advanced.
33    last_tick: Instant,
34}
35
36impl<'a> TuiApp<'a> {
37    /// Creates a new TUI app.
38    pub fn new(crates: &'a [CrateInfo]) -> Self {
39        let mut states = IndexMap::new();
40        for krate in crates {
41            if krate.has_lib_rs {
42                states.insert(krate.name.clone(), CrateState::Generating);
43            } else {
44                states.insert(krate.name.clone(), CrateState::MissingLibRs);
45            }
46        }
47
48        Self {
49            crates,
50            states,
51            should_quit: false,
52            throbber_state: ThrobberState::default(),
53            tick_interval: DEFAULT_TICK_INTERVAL,
54            last_tick: Instant::now(),
55        }
56    }
57
58    /// Updates the state of a crate.
59    pub fn set_state(&mut self, crate_name: &str, state: CrateState) {
60        self.states.insert(crate_name.to_string(), state);
61    }
62
63    /// Advance the throbber animation if enough time has passed.
64    pub fn tick(&mut self) {
65        if self.last_tick.elapsed() >= self.tick_interval {
66            self.throbber_state.calc_next();
67            self.last_tick = Instant::now();
68        }
69    }
70
71    /// Process a message and update application state (Elm-style update).
72    ///
73    /// Returns `true` if the message was handled and requires a redraw.
74    pub fn update(&mut self, msg: Message) -> bool {
75        match msg {
76            Message::Tick => {
77                self.tick();
78                true
79            },
80            Message::Quit => {
81                self.should_quit = true;
82                false
83            },
84            Message::FileChanged { crate_name } => {
85                // Only matters if we're not already generating
86                if !matches!(self.states.get(&crate_name), Some(CrateState::Generating)) {
87                    self.set_state(&crate_name, CrateState::Generating);
88                    true
89                } else {
90                    false
91                }
92            },
93            Message::GenerationStarted { crate_name } => {
94                self.set_state(&crate_name, CrateState::Generating);
95                true
96            },
97            Message::GenerationComplete { result } => {
98                if let Some(ref error) = result.error {
99                    self.set_state(
100                        &result.name,
101                        CrateState::Error {
102                            message: error.clone(),
103                        },
104                    );
105                } else {
106                    self.set_state(
107                        &result.name,
108                        CrateState::Watching {
109                            resource_count: result.resource_count,
110                        },
111                    );
112                }
113                true
114            },
115            Message::WatchError { error: _ } => {
116                // Errors are already visible in the crate state
117                false
118            },
119        }
120    }
121}
122
123/// Get the current throbber symbol based on state.
124fn get_throbber_symbol(state: &ThrobberState) -> &'static str {
125    let symbols = BRAILLE_SIX.symbols;
126    let idx = state.index().rem_euclid(symbols.len() as i8) as usize;
127    symbols[idx]
128}
129
130/// Draws the TUI.
131pub fn draw(frame: &mut Frame, app: &TuiApp) {
132    let chunks = Layout::default()
133        .direction(Direction::Vertical)
134        .constraints([
135            Constraint::Length(3), // Header
136            Constraint::Min(0),    // Crate list
137        ])
138        .split(frame.area());
139
140    // Header
141    let header = Paragraph::new("es-fluent watch (q to quit)")
142        .style(
143            Style::default()
144                .fg(Color::Cyan)
145                .add_modifier(Modifier::BOLD),
146        )
147        .block(Block::default().borders(Borders::BOTTOM));
148    frame.render_widget(header, chunks[0]);
149
150    // Crate list
151    let throbber_symbol = get_throbber_symbol(&app.throbber_state);
152
153    let items: Vec<ListItem> = app
154        .crates
155        .iter()
156        .map(|krate| {
157            let state = app.states.get(&krate.name);
158            let (symbol, status_text, status_color) = match state {
159                Some(CrateState::MissingLibRs) => ("!", "missing lib.rs", Color::Red),
160                Some(CrateState::Generating) => (throbber_symbol, "generating", Color::Yellow),
161                Some(CrateState::Watching { resource_count }) => {
162                    let text = format!("watching ({} resources)", resource_count);
163                    return ListItem::new(Line::from(vec![
164                        Span::styled(
165                            "✓ ",
166                            Style::default()
167                                .fg(Color::Green)
168                                .add_modifier(Modifier::BOLD),
169                        ),
170                        Span::styled(
171                            krate.name.clone(),
172                            Style::default()
173                                .fg(Color::White)
174                                .add_modifier(Modifier::BOLD),
175                        ),
176                        Span::raw(" "),
177                        Span::styled(text, Style::default().fg(Color::Green)),
178                    ]));
179                },
180                Some(CrateState::Error { message }) => {
181                    return ListItem::new(Line::from(vec![
182                        Span::styled(
183                            "✗ ",
184                            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
185                        ),
186                        Span::styled(
187                            krate.name.clone(),
188                            Style::default()
189                                .fg(Color::White)
190                                .add_modifier(Modifier::BOLD),
191                        ),
192                        Span::raw(" "),
193                        Span::styled(
194                            format!("error: {}", message),
195                            Style::default().fg(Color::Red),
196                        ),
197                    ]));
198                },
199                None => ("-", "pending", Color::DarkGray),
200            };
201
202            ListItem::new(Line::from(vec![
203                Span::styled(
204                    format!("{} ", symbol),
205                    Style::default()
206                        .fg(status_color)
207                        .add_modifier(Modifier::BOLD),
208                ),
209                Span::styled(
210                    krate.name.clone(),
211                    Style::default()
212                        .fg(Color::White)
213                        .add_modifier(Modifier::BOLD),
214                ),
215                Span::raw(" "),
216                Span::styled(status_text, Style::default().fg(status_color)),
217            ]))
218        })
219        .collect();
220
221    let crate_list = List::new(items).block(Block::default().borders(Borders::ALL).title("Crates"));
222    frame.render_widget(crate_list, chunks[1]);
223}
224
225/// Polls for keyboard events with a timeout.
226/// Returns true if the user wants to quit.
227pub fn poll_quit_event(timeout: Duration) -> io::Result<bool> {
228    if event::poll(timeout)?
229        && let Event::Key(key) = event::read()?
230        && (key.code == KeyCode::Char('q')
231            || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c')))
232    {
233        return Ok(true);
234    }
235
236    Ok(false)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::core::GenerateResult;
243    use ratatui::{Terminal, backend::TestBackend};
244    use std::path::PathBuf;
245
246    fn test_crate(name: &str, has_lib_rs: bool) -> CrateInfo {
247        CrateInfo {
248            name: name.to_string(),
249            manifest_dir: PathBuf::from("/tmp/test"),
250            src_dir: PathBuf::from("/tmp/test/src"),
251            i18n_config_path: PathBuf::from("/tmp/test/i18n.toml"),
252            ftl_output_dir: PathBuf::from("/tmp/test/i18n/en"),
253            has_lib_rs,
254            fluent_features: Vec::new(),
255        }
256    }
257
258    #[test]
259    fn app_new_initializes_states_from_crates() {
260        let crates = vec![test_crate("a", true), test_crate("b", false)];
261        let app = TuiApp::new(&crates);
262
263        assert!(matches!(app.states.get("a"), Some(CrateState::Generating)));
264        assert!(matches!(
265            app.states.get("b"),
266            Some(CrateState::MissingLibRs)
267        ));
268        assert!(!app.should_quit);
269    }
270
271    #[test]
272    fn app_update_covers_message_transitions() {
273        let crates = vec![test_crate("a", true)];
274        let mut app = TuiApp::new(&crates);
275
276        assert!(app.update(Message::Tick));
277
278        // Already generating => no redraw request.
279        assert!(!app.update(Message::FileChanged {
280            crate_name: "a".to_string(),
281        }));
282
283        app.set_state("a", CrateState::Watching { resource_count: 1 });
284        assert!(app.update(Message::FileChanged {
285            crate_name: "a".to_string(),
286        }));
287        assert!(matches!(app.states.get("a"), Some(CrateState::Generating)));
288
289        assert!(app.update(Message::GenerationStarted {
290            crate_name: "a".to_string(),
291        }));
292
293        assert!(app.update(Message::GenerationComplete {
294            result: GenerateResult::success(
295                "a".to_string(),
296                Duration::from_millis(1),
297                3,
298                None,
299                true,
300            ),
301        }));
302        assert!(matches!(
303            app.states.get("a"),
304            Some(CrateState::Watching { resource_count: 3 })
305        ));
306
307        assert!(app.update(Message::GenerationComplete {
308            result: GenerateResult::failure(
309                "a".to_string(),
310                Duration::from_millis(1),
311                "boom".to_string(),
312            ),
313        }));
314        assert!(matches!(
315            app.states.get("a"),
316            Some(CrateState::Error { message }) if message == "boom"
317        ));
318
319        assert!(!app.update(Message::WatchError {
320            error: "watch failed".to_string(),
321        }));
322        assert!(!app.update(Message::Quit));
323        assert!(app.should_quit);
324    }
325
326    #[test]
327    fn draw_renders_without_panicking() {
328        let crates = vec![test_crate("a", true)];
329        let app = TuiApp::new(&crates);
330        let backend = TestBackend::new(80, 20);
331        let mut terminal = Terminal::new(backend).expect("create terminal");
332
333        terminal.draw(|f| draw(f, &app)).expect("draw");
334    }
335
336    #[test]
337    fn poll_quit_event_times_out_to_false() {
338        match poll_quit_event(Duration::from_millis(0)) {
339            Ok(quit) => assert!(!quit),
340            Err(err) => assert!(
341                err.to_string()
342                    .contains("Failed to initialize input reader"),
343                "unexpected poll error: {err}"
344            ),
345        }
346    }
347
348    #[test]
349    fn tick_advances_throbber_when_interval_elapsed() {
350        let crates = vec![test_crate("a", true)];
351        let mut app = TuiApp::new(&crates);
352        app.tick_interval = Duration::ZERO;
353
354        let before = app.throbber_state.index();
355        app.tick();
356        let after = app.throbber_state.index();
357
358        assert_ne!(before, after, "tick should advance throbber frame");
359    }
360
361    #[test]
362    fn draw_covers_watching_error_and_pending_states() {
363        let crates = vec![
364            test_crate("watching", true),
365            test_crate("error", true),
366            test_crate("pending", true),
367        ];
368        let mut app = TuiApp::new(&crates);
369        app.set_state("watching", CrateState::Watching { resource_count: 2 });
370        app.set_state(
371            "error",
372            CrateState::Error {
373                message: "boom".to_string(),
374            },
375        );
376        app.states.shift_remove("pending");
377
378        let backend = TestBackend::new(80, 20);
379        let mut terminal = Terminal::new(backend).expect("create terminal");
380        terminal.draw(|f| draw(f, &app)).expect("draw");
381    }
382}