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
26pub struct ResultDetailScreen {
28 pub parent: ResultScreen,
29 pub item: serde_json::Value,
30 pub table_rows: Vec<(String, String)>, pub scroll: usize,
32 pub scrollbar_state: ScrollbarState,
33 pub message: Option<String>,
34}
35
36impl ResultScreen {
37 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 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 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)); 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 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}