Skip to main content

romm_cli/tui/screens/
result.rs

1use ratatui::layout::{Constraint, Layout, Rect};
2use ratatui::style::{Color, Style};
3use ratatui::text::{Line, Span};
4use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarState, Table};
5use ratatui::Frame;
6
7use crate::tui::utils::open_in_browser;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub enum ResultViewMode {
11    Json,
12    Table,
13}
14
15pub struct ResultScreen {
16    pub raw: serde_json::Value,
17    pub highlighted_lines: Vec<Line<'static>>,
18    pub scroll: usize,
19    pub scrollbar_state: ScrollbarState,
20    pub view_mode: ResultViewMode,
21    pub table_selected: usize,
22    pub table_row_count: usize,
23    pub message: Option<String>,
24}
25
26/// Detail view for a single ROM/item when pressing Enter on the table.
27pub struct ResultDetailScreen {
28    pub parent: ResultScreen,
29    pub item: serde_json::Value,
30    pub table_rows: Vec<(String, String)>, // (field_name, field_value)
31    pub scroll: usize,
32    pub scrollbar_state: ScrollbarState,
33    pub message: Option<String>,
34}
35
36impl ResultScreen {
37    /// Create a result screen. If `endpoint_method` is "GET" and `endpoint_path` is "/api/roms"
38    /// and the response has list items, table view is used as default.
39    pub fn new(
40        result: serde_json::Value,
41        endpoint_method: Option<&str>,
42        endpoint_path: Option<&str>,
43    ) -> Self {
44        let result_text =
45            serde_json::to_string_pretty(&result).unwrap_or_else(|_| format!("{:?}", result));
46        let highlighted_lines = Self::highlight_json_lines(&result_text);
47        let line_count = highlighted_lines.len().max(1);
48        let scrollbar_state = ScrollbarState::new(line_count.saturating_sub(1));
49
50        let (table_row_count, _) = Self::items_from_value(&result);
51
52        let prefer_table = endpoint_method.is_some_and(|m| m.eq_ignore_ascii_case("GET"))
53            && endpoint_path.is_some_and(|p| p.trim_end_matches('/') == "/api/roms")
54            && table_row_count > 0;
55
56        Self {
57            raw: result,
58            highlighted_lines,
59            scroll: 0,
60            scrollbar_state,
61            view_mode: if prefer_table {
62                ResultViewMode::Table
63            } else {
64                ResultViewMode::Json
65            },
66            table_selected: 0,
67            table_row_count,
68            message: None,
69        }
70    }
71
72    fn highlight_json_lines(text: &str) -> Vec<Line<'static>> {
73        let mut out = Vec::new();
74        for line in text.lines() {
75            out.push(Self::highlight_json_line(line));
76        }
77        if out.is_empty() {
78            out.push(Line::from(Span::raw("")));
79        }
80        out
81    }
82
83    fn highlight_json_line(line: &str) -> Line<'static> {
84        let key_style = Style::default().fg(Color::Cyan);
85        let string_style = Style::default().fg(Color::Green);
86        let number_style = Style::default().fg(Color::Yellow);
87        let bool_null_style = Style::default().fg(Color::Magenta);
88        let default_style = Style::default();
89
90        let mut spans = Vec::new();
91        let bytes = line.as_bytes();
92        let mut i = 0;
93
94        while i < bytes.len() {
95            if bytes[i] == b'"' {
96                let mut end = i + 1;
97                while end < bytes.len() {
98                    if bytes[end] == b'\\' && end + 1 < bytes.len() {
99                        end += 2;
100                        continue;
101                    }
102                    if bytes[end] == b'"' {
103                        end += 1;
104                        break;
105                    }
106                    end += 1;
107                }
108                let s = std::str::from_utf8(&bytes[i..end]).unwrap_or("");
109                let rest_trimmed = bytes.get(end..).and_then(|s| {
110                    let mut j = 0;
111                    while j < s.len() && (s[j] == b' ' || s[j] == b'\t') {
112                        j += 1;
113                    }
114                    s.get(j..)
115                });
116                let is_key = rest_trimmed
117                    .map(|r| r.first() == Some(&b':'))
118                    .unwrap_or(false);
119                if is_key {
120                    spans.push(Span::styled(s.to_string(), key_style));
121                } else {
122                    spans.push(Span::styled(s.to_string(), string_style));
123                }
124                i = end;
125                continue;
126            }
127            if bytes[i].is_ascii_digit()
128                || (bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit())
129            {
130                let mut end = i;
131                if bytes[end] == b'-' {
132                    end += 1;
133                }
134                while end < bytes.len()
135                    && (bytes[end].is_ascii_digit()
136                        || bytes[end] == b'.'
137                        || bytes[end] == b'e'
138                        || bytes[end] == b'E'
139                        || bytes[end] == b'+'
140                        || bytes[end] == b'-')
141                {
142                    end += 1;
143                }
144                let s = std::str::from_utf8(&bytes[i..end]).unwrap_or("");
145                spans.push(Span::styled(s.to_string(), number_style));
146                i = end;
147                continue;
148            }
149            if i + 4 <= bytes.len() && std::str::from_utf8(&bytes[i..i + 4]).unwrap_or("") == "true"
150            {
151                spans.push(Span::styled("true".to_string(), bool_null_style));
152                i += 4;
153                continue;
154            }
155            if i + 5 <= bytes.len()
156                && std::str::from_utf8(&bytes[i..i + 5]).unwrap_or("") == "false"
157            {
158                spans.push(Span::styled("false".to_string(), bool_null_style));
159                i += 5;
160                continue;
161            }
162            if i + 4 <= bytes.len() && std::str::from_utf8(&bytes[i..i + 4]).unwrap_or("") == "null"
163            {
164                spans.push(Span::styled("null".to_string(), bool_null_style));
165                i += 4;
166                continue;
167            }
168            let ch = std::str::from_utf8(&bytes[i..(i + 1).min(bytes.len())]).unwrap_or("");
169            spans.push(Span::styled(ch.to_string(), default_style));
170            i += 1;
171        }
172        if spans.is_empty() {
173            Line::from(Span::raw(line.to_string()))
174        } else {
175            Line::from(spans)
176        }
177    }
178
179    fn items_from_value(v: &serde_json::Value) -> (usize, Option<&Vec<serde_json::Value>>) {
180        let obj = match v.as_object() {
181            Some(o) => o,
182            None => return (0, None),
183        };
184        let items = match obj.get("items").and_then(|i| i.as_array()) {
185            Some(arr) => arr,
186            None => return (0, None),
187        };
188        let total = obj
189            .get("total")
190            .and_then(|t| t.as_u64())
191            .unwrap_or(items.len() as u64) as usize;
192        (total.min(items.len()), Some(items))
193    }
194
195    pub fn collect_image_urls(value: &serde_json::Value) -> Vec<String> {
196        let mut urls = Vec::new();
197        fn collect(v: &serde_json::Value, out: &mut Vec<String>) {
198            match v {
199                serde_json::Value::Object(m) => {
200                    for (k, val) in m {
201                        if (k == "url_cover")
202                            || (k == "url_logo")
203                            || (k.starts_with("url_") && k.contains("cover"))
204                        {
205                            if let Some(s) = val.as_str().filter(|s| !s.is_empty()) {
206                                out.push(s.to_string());
207                            }
208                        }
209                        collect(val, out);
210                    }
211                }
212                serde_json::Value::Array(arr) => {
213                    for item in arr {
214                        collect(item, out);
215                    }
216                }
217                _ => {}
218            }
219        }
220        collect(value, &mut urls);
221        urls
222    }
223
224    /// Returns the selected row value when in table view (for opening detail screen).
225    pub fn get_selected_item_value(&self) -> Option<serde_json::Value> {
226        if self.view_mode != ResultViewMode::Table {
227            return None;
228        }
229        let (_, items_opt) = Self::items_from_value(&self.raw);
230        let items = items_opt?;
231        let row = items.get(self.table_selected.min(items.len().saturating_sub(1)))?;
232        Some(row.clone())
233    }
234
235    pub fn scroll_down(&mut self, amount: usize) {
236        let max_scroll = self.highlighted_lines.len().saturating_sub(1);
237        self.scroll = (self.scroll + amount).min(max_scroll);
238        self.scrollbar_state = self.scrollbar_state.position(self.scroll);
239    }
240
241    pub fn scroll_up(&mut self, amount: usize) {
242        self.scroll = self.scroll.saturating_sub(amount);
243        self.scrollbar_state = self.scrollbar_state.position(self.scroll);
244    }
245
246    pub fn table_next(&mut self) {
247        if self.table_row_count > 0 {
248            self.table_selected = (self.table_selected + 1) % self.table_row_count;
249        }
250    }
251
252    pub fn table_previous(&mut self) {
253        if self.table_row_count > 0 {
254            self.table_selected = if self.table_selected == 0 {
255                self.table_row_count - 1
256            } else {
257                self.table_selected - 1
258            };
259        }
260    }
261
262    pub fn table_page_up(&mut self) {
263        const PAGE: usize = 10;
264        self.table_selected = self.table_selected.saturating_sub(PAGE);
265    }
266
267    pub fn table_page_down(&mut self) {
268        const PAGE: usize = 10;
269        if self.table_row_count > 0 {
270            self.table_selected = (self.table_selected + PAGE).min(self.table_row_count - 1);
271        }
272    }
273
274    pub fn switch_view_mode(&mut self) {
275        self.view_mode = match self.view_mode {
276            ResultViewMode::Json => {
277                if self.table_row_count > 0 {
278                    ResultViewMode::Table
279                } else {
280                    ResultViewMode::Json
281                }
282            }
283            ResultViewMode::Table => ResultViewMode::Json,
284        };
285        self.table_selected = 0;
286    }
287
288    pub fn clear_message(&mut self) {
289        self.message = None;
290    }
291
292    pub fn render(&self, f: &mut Frame, area: Rect) {
293        let chunks = Layout::default()
294            .constraints([Constraint::Min(3), Constraint::Length(3)])
295            .direction(ratatui::layout::Direction::Vertical)
296            .split(area);
297
298        let content_area = chunks[0];
299        match self.view_mode {
300            ResultViewMode::Json => self.render_json(f, content_area),
301            ResultViewMode::Table => self.render_table(f, content_area),
302        }
303
304        let help = match self.view_mode {
305            ResultViewMode::Json => "t: Toggle view | ↑↓: Scroll | Esc: Back",
306            ResultViewMode::Table => "t: Toggle view | Enter: Detail | ↑↓: Row | Esc: Back",
307        };
308        let msg = self.message.as_deref().unwrap_or(help);
309        let footer = Paragraph::new(msg).block(Block::default().borders(Borders::ALL));
310        f.render_widget(footer, chunks[1]);
311    }
312
313    fn render_json(&self, f: &mut Frame, area: Rect) {
314        let visible: Vec<Line> = self
315            .highlighted_lines
316            .iter()
317            .skip(self.scroll)
318            .take(area.height as usize - 2)
319            .cloned()
320            .collect();
321
322        let paragraph = Paragraph::new(visible)
323            .block(
324                Block::default()
325                    .title("Response (JSON)")
326                    .borders(Borders::ALL),
327            )
328            .wrap(ratatui::widgets::Wrap { trim: true });
329
330        f.render_widget(paragraph, area);
331
332        let mut state = self.scrollbar_state;
333        let scrollbar = Scrollbar::default()
334            .orientation(ratatui::widgets::ScrollbarOrientation::VerticalRight)
335            .begin_symbol(Some("↑"))
336            .end_symbol(Some("↓"));
337        f.render_stateful_widget(scrollbar, area, &mut state);
338    }
339
340    fn render_table(&self, f: &mut Frame, area: Rect) {
341        let (_, items_opt) = Self::items_from_value(&self.raw);
342        let items = match items_opt {
343            Some(arr) if !arr.is_empty() => arr,
344            _ => {
345                let p = Paragraph::new("No items array or empty").block(
346                    Block::default()
347                        .title("Response (Table)")
348                        .borders(Borders::ALL),
349                );
350                f.render_widget(p, area);
351                return;
352            }
353        };
354
355        // Table block: 1 top border + 1 header + N data rows + 1 bottom border
356        let visible_row_count = (area.height as usize).saturating_sub(3).max(1);
357        let max_scroll_start = items.len().saturating_sub(visible_row_count);
358        let scroll_start = (self.table_selected + 1)
359            .saturating_sub(visible_row_count)
360            .min(max_scroll_start);
361        let scroll_end = (scroll_start + visible_row_count).min(items.len());
362        let visible_items = &items[scroll_start..scroll_end];
363
364        let header_cells = ["id", "name", "platform_id", "cover"]
365            .iter()
366            .map(|h| Cell::from(*h).style(Style::default().fg(Color::Cyan)));
367        let header = Row::new(header_cells).height(1);
368
369        let rows: Vec<Row> = visible_items
370            .iter()
371            .enumerate()
372            .map(|(local_idx, row)| {
373                let global_idx = scroll_start + local_idx;
374                let empty = serde_json::Map::new();
375                let obj = row.as_object().unwrap_or(&empty);
376                let id = obj
377                    .get("id")
378                    .and_then(|v| v.as_u64())
379                    .map(|n| n.to_string())
380                    .unwrap_or_else(|| "-".to_string());
381                let name = obj
382                    .get("name")
383                    .and_then(|v| v.as_str())
384                    .unwrap_or("")
385                    .to_string();
386                let pid_num = obj.get("platform_id").and_then(|v| v.as_u64());
387                let platform_name = obj
388                    .get("platform_display_name")
389                    .or_else(|| obj.get("platform_custom_name"))
390                    .or_else(|| obj.get("platform_name"))
391                    .and_then(|v| v.as_str())
392                    .filter(|s| !s.is_empty());
393                let pid = match (platform_name, pid_num) {
394                    (Some(name), Some(id)) => format!("{} ({})", name, id),
395                    (None, Some(id)) => format!("({})", id),
396                    _ => "-".to_string(),
397                };
398                let cover = if obj.get("url_cover").or(obj.get("url_logo")).is_some() {
399                    "[IMG]"
400                } else {
401                    "-"
402                };
403                let style = if global_idx == self.table_selected {
404                    Style::default().fg(Color::Yellow)
405                } else {
406                    Style::default()
407                };
408                Row::new(vec![
409                    Cell::from(id).style(style),
410                    Cell::from(name).style(style),
411                    Cell::from(pid).style(style),
412                    Cell::from(cover).style(style),
413                ])
414                .height(1)
415            })
416            .collect();
417
418        let widths = [
419            Constraint::Length(6),
420            Constraint::Percentage(40),
421            Constraint::Min(16),
422            Constraint::Length(6),
423        ];
424        let title = format!(
425            "Response (Table) - {} rows {}-{}",
426            items.len(),
427            scroll_start + 1,
428            scroll_end
429        );
430        let table = Table::new(rows, widths)
431            .header(header)
432            .block(Block::default().title(title).borders(Borders::ALL));
433
434        f.render_widget(table, area);
435    }
436}
437
438impl ResultDetailScreen {
439    pub fn new(parent: ResultScreen, item: serde_json::Value) -> Self {
440        let table_rows = Self::value_to_table_rows(&item);
441        let row_count = table_rows.len().max(1);
442        let scrollbar_state = ScrollbarState::new(row_count.saturating_sub(1));
443
444        Self {
445            parent,
446            item,
447            table_rows,
448            scroll: 0,
449            scrollbar_state,
450            message: None,
451        }
452    }
453
454    fn value_to_table_rows(value: &serde_json::Value) -> Vec<(String, String)> {
455        let mut rows = Vec::new();
456        if let Some(obj) = value.as_object() {
457            for (key, val) in obj {
458                let value_str = match val {
459                    serde_json::Value::Null => "null".to_string(),
460                    serde_json::Value::Bool(b) => b.to_string(),
461                    serde_json::Value::Number(n) => n.to_string(),
462                    serde_json::Value::String(s) => s.clone(),
463                    serde_json::Value::Array(_) => {
464                        format!("[{} items]", val.as_array().map(|a| a.len()).unwrap_or(0))
465                    }
466                    serde_json::Value::Object(_) => format!(
467                        "{{{} fields}}",
468                        val.as_object().map(|o| o.len()).unwrap_or(0)
469                    ),
470                };
471                rows.push((key.clone(), value_str));
472            }
473        }
474        rows.sort_by(|a, b| a.0.cmp(&b.0)); // Sort by field name
475        rows
476    }
477
478    pub fn scroll_down(&mut self, amount: usize) {
479        let max_scroll = self.table_rows.len().saturating_sub(1);
480        self.scroll = (self.scroll + amount).min(max_scroll);
481        self.scrollbar_state = self.scrollbar_state.position(self.scroll);
482    }
483
484    pub fn scroll_up(&mut self, amount: usize) {
485        self.scroll = self.scroll.saturating_sub(amount);
486        self.scrollbar_state = self.scrollbar_state.position(self.scroll);
487    }
488
489    pub fn open_image_url(&mut self) {
490        self.message = None;
491        let urls = ResultScreen::collect_image_urls(&self.item);
492        let url = match urls.first() {
493            Some(u) => u.clone(),
494            None => {
495                self.message = Some("No image URL".to_string());
496                return;
497            }
498        };
499        match open_in_browser(&url) {
500            Ok(_) => self.message = Some("Opened in browser".to_string()),
501            Err(e) => self.message = Some(format!("Failed to open: {}", e)),
502        }
503    }
504
505    pub fn clear_message(&mut self) {
506        self.message = None;
507    }
508
509    pub fn render(&self, f: &mut Frame, area: Rect) {
510        let chunks = Layout::default()
511            .constraints([Constraint::Min(3), Constraint::Length(3)])
512            .direction(ratatui::layout::Direction::Vertical)
513            .split(area);
514
515        let content_area = chunks[0];
516
517        // Table block: 1 top border + 1 header + N data rows + 1 bottom border
518        let visible_row_count = (content_area.height as usize).saturating_sub(3).max(1);
519        let max_scroll = self.table_rows.len().saturating_sub(visible_row_count);
520        let scroll_start = self.scroll.min(max_scroll);
521        let scroll_end = (scroll_start + visible_row_count).min(self.table_rows.len());
522        let visible_rows = &self.table_rows[scroll_start..scroll_end];
523
524        let header_cells = ["Field", "Value"]
525            .iter()
526            .map(|h| Cell::from(*h).style(Style::default().fg(Color::Cyan)));
527        let header = Row::new(header_cells).height(1);
528
529        let rows: Vec<Row> = visible_rows
530            .iter()
531            .map(|(key, value)| {
532                Row::new(vec![
533                    Cell::from(key.clone()).style(Style::default().fg(Color::Yellow)),
534                    Cell::from(value.clone()),
535                ])
536                .height(1)
537            })
538            .collect();
539
540        let widths = [Constraint::Percentage(30), Constraint::Percentage(70)];
541        let title = format!("ROM detail - {} fields", self.table_rows.len());
542        let table = Table::new(rows, widths)
543            .header(header)
544            .block(Block::default().title(title).borders(Borders::ALL));
545
546        f.render_widget(table, content_area);
547
548        let mut state = self.scrollbar_state;
549        let scrollbar = Scrollbar::default()
550            .orientation(ratatui::widgets::ScrollbarOrientation::VerticalRight)
551            .begin_symbol(Some("↑"))
552            .end_symbol(Some("↓"));
553        f.render_stateful_widget(scrollbar, content_area, &mut state);
554
555        let help = "o: Open image | ↑↓: Scroll | Esc: Back";
556        let msg = self.message.as_deref().unwrap_or(help);
557        let footer = Paragraph::new(msg).block(Block::default().borders(Borders::ALL));
558        f.render_widget(footer, chunks[1]);
559    }
560}