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