Skip to main content

langcodec_cli/tui/
app.rs

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}