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 ratatui::{
7    Frame,
8    layout::{Constraint, Direction, Layout},
9    style::{Color, Modifier, Style},
10    text::{Line, Span},
11    widgets::{Block, Borders, List, ListItem, Paragraph},
12};
13use std::collections::HashMap;
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: HashMap<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 = HashMap::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}