1use 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
20pub struct TuiApp<'a> {
22 pub crates: &'a [CrateInfo],
24 pub states: HashMap<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 = 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 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}