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 pub message_clear_at: Option<std::time::Instant>,
35}
36
37impl ResultScreen {
38 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 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 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)); 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 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}