1use std::collections::BTreeMap;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum DashboardKind {
5 Translate,
6 Annotate,
7}
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum DashboardLogTone {
11 Info,
12 Success,
13 Warning,
14 Error,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum DashboardItemStatus {
19 Queued,
20 Running,
21 Succeeded,
22 Failed,
23 Skipped,
24}
25
26impl DashboardItemStatus {
27 pub fn label(self) -> &'static str {
28 match self {
29 Self::Queued => "queued",
30 Self::Running => "running",
31 Self::Succeeded => "done",
32 Self::Failed => "failed",
33 Self::Skipped => "skipped",
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct SummaryRow {
40 pub label: String,
41 pub value: String,
42}
43
44impl SummaryRow {
45 pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
46 Self {
47 label: label.into(),
48 value: value.into(),
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DashboardItem {
55 pub id: String,
56 pub title: String,
57 pub subtitle: String,
58 pub source_text: Option<String>,
59 pub output_text: Option<String>,
60 pub note_text: Option<String>,
61 pub error_text: Option<String>,
62 pub extra_rows: Vec<SummaryRow>,
63 pub status: DashboardItemStatus,
64}
65
66impl DashboardItem {
67 pub fn new(
68 id: impl Into<String>,
69 title: impl Into<String>,
70 subtitle: impl Into<String>,
71 status: DashboardItemStatus,
72 ) -> Self {
73 Self {
74 id: id.into(),
75 title: title.into(),
76 subtitle: subtitle.into(),
77 source_text: None,
78 output_text: None,
79 note_text: None,
80 error_text: None,
81 extra_rows: Vec::new(),
82 status,
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct DashboardInit {
89 pub kind: DashboardKind,
90 pub title: String,
91 pub metadata: Vec<SummaryRow>,
92 pub summary_rows: Vec<SummaryRow>,
93 pub items: Vec<DashboardItem>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum DashboardEvent {
98 Log {
99 tone: DashboardLogTone,
100 message: String,
101 },
102 UpdateItem {
103 id: String,
104 status: Option<DashboardItemStatus>,
105 subtitle: Option<String>,
106 source_text: Option<String>,
107 output_text: Option<String>,
108 note_text: Option<String>,
109 error_text: Option<String>,
110 extra_rows: Option<Vec<SummaryRow>>,
111 },
112 SummaryRows {
113 rows: Vec<SummaryRow>,
114 },
115 Completed,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum FocusPane {
120 Table,
121 Detail,
122 Log,
123}
124
125impl FocusPane {
126 pub fn next(self) -> Self {
127 match self {
128 Self::Table => Self::Detail,
129 Self::Detail => Self::Log,
130 Self::Log => Self::Table,
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
136pub struct DashboardCounts {
137 pub queued: usize,
138 pub running: usize,
139 pub succeeded: usize,
140 pub failed: usize,
141 pub skipped: usize,
142}
143
144#[derive(Debug, Clone)]
145pub struct DashboardState {
146 pub kind: DashboardKind,
147 pub title: String,
148 pub metadata: Vec<SummaryRow>,
149 pub summary_rows: Vec<SummaryRow>,
150 pub items: Vec<DashboardItem>,
151 pub logs: Vec<(DashboardLogTone, String)>,
152 pub selected: usize,
153 pub detail_scroll: u16,
154 pub log_scroll: u16,
155 pub focus: FocusPane,
156 pub completed: bool,
157 item_index: BTreeMap<String, usize>,
158}
159
160impl DashboardState {
161 pub fn new(init: DashboardInit) -> Self {
162 let item_index = init
163 .items
164 .iter()
165 .enumerate()
166 .map(|(idx, item)| (item.id.clone(), idx))
167 .collect();
168 Self {
169 kind: init.kind,
170 title: init.title,
171 metadata: init.metadata,
172 summary_rows: init.summary_rows,
173 items: init.items,
174 logs: Vec::new(),
175 selected: 0,
176 detail_scroll: 0,
177 log_scroll: 0,
178 focus: FocusPane::Table,
179 completed: false,
180 item_index,
181 }
182 }
183
184 pub fn apply(&mut self, event: DashboardEvent) {
185 match event {
186 DashboardEvent::Log { tone, message } => self.logs.push((tone, message)),
187 DashboardEvent::UpdateItem {
188 id,
189 status,
190 subtitle,
191 source_text,
192 output_text,
193 note_text,
194 error_text,
195 extra_rows,
196 } => {
197 if let Some(index) = self.item_index.get(&id).copied() {
198 let item = &mut self.items[index];
199 if let Some(status) = status {
200 item.status = status;
201 }
202 if let Some(subtitle) = subtitle {
203 item.subtitle = subtitle;
204 }
205 if let Some(source_text) = source_text {
206 item.source_text = Some(source_text);
207 }
208 if let Some(output_text) = output_text {
209 item.output_text = Some(output_text);
210 }
211 if let Some(note_text) = note_text {
212 item.note_text = Some(note_text);
213 }
214 if let Some(error_text) = error_text {
215 item.error_text = Some(error_text);
216 }
217 if let Some(extra_rows) = extra_rows {
218 item.extra_rows = extra_rows;
219 }
220 }
221 }
222 DashboardEvent::SummaryRows { rows } => self.summary_rows = rows,
223 DashboardEvent::Completed => self.completed = true,
224 }
225 }
226
227 pub fn counts(&self) -> DashboardCounts {
228 let mut counts = DashboardCounts::default();
229 for item in &self.items {
230 match item.status {
231 DashboardItemStatus::Queued => counts.queued += 1,
232 DashboardItemStatus::Running => counts.running += 1,
233 DashboardItemStatus::Succeeded => counts.succeeded += 1,
234 DashboardItemStatus::Failed => counts.failed += 1,
235 DashboardItemStatus::Skipped => counts.skipped += 1,
236 }
237 }
238 counts
239 }
240
241 pub fn selected_item(&self) -> Option<&DashboardItem> {
242 self.items.get(self.selected)
243 }
244
245 pub fn select_next(&mut self) {
246 if self.items.is_empty() {
247 return;
248 }
249 self.selected = (self.selected + 1).min(self.items.len().saturating_sub(1));
250 self.detail_scroll = 0;
251 }
252
253 pub fn select_previous(&mut self) {
254 if self.items.is_empty() {
255 return;
256 }
257 self.selected = self.selected.saturating_sub(1);
258 self.detail_scroll = 0;
259 }
260
261 pub fn jump_top(&mut self) {
262 match self.focus {
263 FocusPane::Table => self.selected = 0,
264 FocusPane::Detail => self.detail_scroll = 0,
265 FocusPane::Log => self.log_scroll = 0,
266 }
267 }
268
269 pub fn jump_bottom(&mut self) {
270 match self.focus {
271 FocusPane::Table => {
272 self.selected = self.items.len().saturating_sub(1);
273 }
274 FocusPane::Detail => self.detail_scroll = u16::MAX,
275 FocusPane::Log => self.log_scroll = u16::MAX,
276 }
277 }
278
279 pub fn scroll_forward(&mut self, amount: u16) {
280 match self.focus {
281 FocusPane::Table => {
282 for _ in 0..amount {
283 self.select_next();
284 }
285 }
286 FocusPane::Detail => {
287 self.detail_scroll = self.detail_scroll.saturating_add(amount);
288 }
289 FocusPane::Log => {
290 self.log_scroll = self.log_scroll.saturating_add(amount);
291 }
292 }
293 }
294
295 pub fn scroll_backward(&mut self, amount: u16) {
296 match self.focus {
297 FocusPane::Table => {
298 for _ in 0..amount {
299 self.select_previous();
300 }
301 }
302 FocusPane::Detail => {
303 self.detail_scroll = self.detail_scroll.saturating_sub(amount);
304 }
305 FocusPane::Log => {
306 self.log_scroll = self.log_scroll.saturating_sub(amount);
307 }
308 }
309 }
310
311 pub fn summary_value(&self, label: &str) -> Option<&str> {
312 self.summary_rows
313 .iter()
314 .find(|row| row.label == label)
315 .map(|row| row.value.as_str())
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn reducer_updates_item_status_and_counts() {
325 let mut state = DashboardState::new(DashboardInit {
326 kind: DashboardKind::Translate,
327 title: "Translate".to_string(),
328 metadata: Vec::new(),
329 summary_rows: vec![SummaryRow::new("Skipped", "2")],
330 items: vec![DashboardItem::new(
331 "fr:welcome",
332 "welcome",
333 "fr",
334 DashboardItemStatus::Queued,
335 )],
336 });
337
338 state.apply(DashboardEvent::UpdateItem {
339 id: "fr:welcome".to_string(),
340 status: Some(DashboardItemStatus::Running),
341 subtitle: None,
342 source_text: None,
343 output_text: None,
344 note_text: None,
345 error_text: None,
346 extra_rows: None,
347 });
348 assert_eq!(state.counts().running, 1);
349
350 state.apply(DashboardEvent::UpdateItem {
351 id: "fr:welcome".to_string(),
352 status: Some(DashboardItemStatus::Succeeded),
353 subtitle: None,
354 source_text: None,
355 output_text: Some("Bonjour".to_string()),
356 note_text: None,
357 error_text: None,
358 extra_rows: None,
359 });
360
361 let counts = state.counts();
362 assert_eq!(counts.succeeded, 1);
363 assert_eq!(
364 state
365 .selected_item()
366 .and_then(|item| item.output_text.as_deref()),
367 Some("Bonjour")
368 );
369 assert_eq!(state.summary_value("Skipped"), Some("2"));
370 }
371}