1use 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
20pub struct TuiApp<'a> {
22 pub crates: &'a [CrateInfo],
24 pub states: IndexMap<String, CrateState>,
26 pub should_quit: bool,
28 pub throbber_state: ThrobberState,
30 pub tick_interval: Duration,
32 last_tick: Instant,
34}
35
36impl<'a> TuiApp<'a> {
37 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 pub fn set_state(&mut self, crate_name: &str, state: CrateState) {
60 self.states.insert(crate_name.to_string(), state);
61 }
62
63 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 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 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 false
118 },
119 }
120 }
121}
122
123fn 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
130pub fn draw(frame: &mut Frame, app: &TuiApp) {
132 let chunks = Layout::default()
133 .direction(Direction::Vertical)
134 .constraints([
135 Constraint::Length(3), Constraint::Min(0), ])
138 .split(frame.area());
139
140 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 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
225pub 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 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}