1use ratatui::layout::{Constraint, Direction, Layout, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
10use ratatui::Frame;
11use serde_json::Value;
12
13use super::model::{AppModel, BuilderFocus, InputMode, ViewMode, FILTER_OPS};
14use super::scan::{payload_pretty, render_codegen};
15use super::style::tone_color;
16use crate::beautify::{format_cell_for_field, select_table_fields, RowTechMap};
17use crate::util::get_columns_to_request;
18
19const LABEL: Style = Style::new().fg(Color::DarkGray);
20const ACCENT: Style = Style::new().fg(Color::Cyan);
21const MUTED: Style = Style::new().fg(Color::Gray);
22const HIGHLIGHT: Style = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
23const ACTIVE: Style = Style::new().fg(Color::Green);
24
25pub fn draw(frame: &mut Frame<'_>, model: &AppModel) {
27 let chunks = Layout::default()
28 .direction(Direction::Vertical)
29 .constraints([
30 Constraint::Length(3),
31 Constraint::Min(8),
32 Constraint::Length(2),
33 ])
34 .split(frame.area());
35
36 draw_title(frame, chunks[0], model);
37 match model.view {
38 ViewMode::Results => draw_results(frame, chunks[1], model),
39 ViewMode::Builder => draw_builder(frame, chunks[1], model),
40 ViewMode::Payload => draw_payload(frame, chunks[1], model),
41 ViewMode::Codegen => draw_codegen(frame, chunks[1], model),
42 ViewMode::Help => draw_help(frame, chunks[1]),
43 }
44 draw_footer(frame, chunks[2], model);
45}
46
47fn draw_title(frame: &mut Frame<'_>, area: Rect, model: &AppModel) {
48 let preset = model.config.preset.as_deref().unwrap_or("(defaults)");
49 let tabs = [
50 ViewMode::Results.tab_label(),
51 ViewMode::Builder.tab_label(),
52 ViewMode::Payload.tab_label(),
53 ViewMode::Codegen.tab_label(),
54 ];
55 let tab_spans: Vec<Span<'_>> = tabs
56 .iter()
57 .flat_map(|label| {
58 let style = if *label == model.view.tab_label() {
59 ACCENT.add_modifier(Modifier::BOLD)
60 } else {
61 MUTED
62 };
63 [Span::styled(*label, style), Span::raw(" ")]
64 })
65 .collect();
66 let title = Paragraph::new(Line::from(vec![
67 Span::styled("tvscreener-tui", ACCENT.add_modifier(Modifier::BOLD)),
68 Span::raw(" "),
69 Span::styled(preset, MUTED),
70 Span::raw(" "),
71 ]))
72 .block(
73 Block::default()
74 .borders(Borders::ALL)
75 .title(Line::from(tab_spans)),
76 );
77 frame.render_widget(title, area);
78}
79
80fn draw_footer(frame: &mut Frame<'_>, area: Rect, model: &AppModel) {
81 let status = model.error.as_deref().unwrap_or(model.status.as_str());
82 let style = if model.error.is_some() {
83 Style::new().fg(Color::Red)
84 } else {
85 MUTED
86 };
87 let keys = match model.view {
88 ViewMode::Results => {
89 "Tab views · r refresh · a watch · c copy rows · h help · ↑↓ scroll"
90 }
91 ViewMode::Builder => {
92 "Tab views · ←→ asset/preset · +/- limit · s search · t/u sort · m/i scope · f filter · Enter scan · j/k focus"
93 }
94 ViewMode::Payload | ViewMode::Codegen => "Tab views · c copy · ↑↓ scroll · h help",
95 ViewMode::Help => "Tab views · h back · q quit",
96 };
97 let line = Paragraph::new(Line::from(vec![
98 Span::styled(status, style),
99 Span::raw(" "),
100 Span::styled(keys, LABEL),
101 ]));
102 frame.render_widget(line, area);
103}
104
105fn draw_help(frame: &mut Frame<'_>, area: Rect) {
106 let lines = vec![
107 Line::from(Span::styled("Views", ACCENT.add_modifier(Modifier::BOLD))),
108 Line::from(" Tab / 1–4 Results · Builder · Payload · Codegen"),
109 Line::from(" h toggle this help"),
110 Line::from(""),
111 Line::from(Span::styled("Scan", ACCENT.add_modifier(Modifier::BOLD))),
112 Line::from(" r refresh (min 10s between scans)"),
113 Line::from(" a toggle watch (default 30s)"),
114 Line::from(" Enter (Builder) apply config and scan"),
115 Line::from(""),
116 Line::from(Span::styled("Builder", ACCENT.add_modifier(Modifier::BOLD))),
117 Line::from(" ← / → cycle asset (Asset row) or preset (Preset row)"),
118 Line::from(" + / - row limit"),
119 Line::from(" s edit name search"),
120 Line::from(" t / u edit sort field / toggle ascending (Sort row)"),
121 Line::from(" m / i edit markets / index (Stock, row focused)"),
122 Line::from(" f add field filter (pick field, op, value)"),
123 Line::from(" d remove selected filter"),
124 Line::from(" j / k move focus between rows"),
125 Line::from(""),
126 Line::from(Span::styled("Copy", ACCENT.add_modifier(Modifier::BOLD))),
127 Line::from(" c Results: rows JSON · Payload: request JSON · Codegen: snippets"),
128 Line::from(" Uses OSC 52 (terminal clipboard)."),
129 Line::from(""),
130 Line::from(Span::styled("Launch", ACCENT.add_modifier(Modifier::BOLD))),
131 Line::from(" cargo run --features apps --bin tvscreener-tui -- crypto --limit 10"),
132 Line::from(
133 " Edit asset, preset, limit, search, sort, markets, index, filters in Builder.",
134 ),
135 ];
136 let body = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Help "));
137 frame.render_widget(body, area);
138}
139
140fn draw_builder(frame: &mut Frame<'_>, area: Rect, model: &AppModel) {
141 let mut lines = builder_header_lines(model);
142 lines.extend(builder_filter_lines(model));
143 lines.push(Line::from(""));
144 lines.extend(input_hint_lines(model));
145
146 let body = Paragraph::new(lines)
147 .wrap(Wrap { trim: false })
148 .scroll((u16::try_from(model.scroll).unwrap_or(0), 0))
149 .block(Block::default().borders(Borders::ALL).title(" Builder "));
150 frame.render_widget(body, area);
151}
152
153fn builder_header_lines(model: &AppModel) -> Vec<Line<'static>> {
154 let preset_name = model
155 .builder
156 .preset_options
157 .get(model.builder.preset_index)
158 .map_or("(defaults)", String::as_str);
159 let search = optional_config_text(model.config.search.as_deref());
160 let sort = optional_config_text(model.config.sort_by.as_deref());
161 let sort_dir = if model.config.ascending {
162 "asc"
163 } else {
164 "desc"
165 };
166 let markets = stock_scope_text(model, model.config.markets.as_deref());
167 let index = stock_scope_text(model, model.config.index.as_deref());
168 vec![
169 Line::from(Span::styled(
170 "Query builder",
171 ACCENT.add_modifier(Modifier::BOLD),
172 )),
173 Line::from(""),
174 builder_row(
175 model,
176 BuilderFocus::Asset,
177 "Asset",
178 &format!("{} (← → cycle)", model.config.asset.as_str()),
179 ),
180 builder_row(
181 model,
182 BuilderFocus::Preset,
183 "Preset",
184 &format!("{preset_name} (← → cycle)"),
185 ),
186 builder_row(
187 model,
188 BuilderFocus::Limit,
189 "Limit",
190 &format!("{} rows (+ / -)", model.config.limit),
191 ),
192 builder_row(
193 model,
194 BuilderFocus::Search,
195 "Search",
196 &format!("{search} (s edit)"),
197 ),
198 builder_row(
199 model,
200 BuilderFocus::Sort,
201 "Sort",
202 &format!("{sort} {sort_dir} (t edit · u toggle)"),
203 ),
204 builder_row(
205 model,
206 BuilderFocus::Markets,
207 "Markets",
208 &format!("{markets} (m edit)"),
209 ),
210 builder_row(
211 model,
212 BuilderFocus::Index,
213 "Index",
214 &format!("{index} (i edit)"),
215 ),
216 Line::from(""),
217 builder_row(
218 model,
219 BuilderFocus::Filters,
220 "Filters",
221 "(f add · d remove · j/k select)",
222 ),
223 ]
224}
225
226fn builder_filter_lines(model: &AppModel) -> Vec<Line<'static>> {
227 if model.config.filters.is_empty() {
228 return vec![Line::from(Span::styled(" (no filters)", MUTED))];
229 }
230 model
231 .config
232 .filters
233 .iter()
234 .enumerate()
235 .map(|(i, f)| {
236 let marker = if i == model.builder.filter_selected {
237 ">"
238 } else {
239 " "
240 };
241 let style = if i == model.builder.filter_selected {
242 HIGHLIGHT
243 } else {
244 MUTED
245 };
246 Line::from(Span::styled(
247 format!(" {marker} {} {} {}", f.left, f.operation, f.value),
248 style,
249 ))
250 })
251 .collect()
252}
253
254fn stock_scope_text(model: &AppModel, value: Option<&str>) -> String {
255 if model.config.asset != crate::field::Asset::Stock {
256 return "(n/a)".into();
257 }
258 value
259 .filter(|s| !s.is_empty())
260 .map_or_else(|| "(none)".into(), String::from)
261}
262
263fn optional_config_text(value: Option<&str>) -> String {
264 value
265 .filter(|s| !s.is_empty())
266 .map_or_else(|| "(none)".into(), String::from)
267}
268
269fn builder_row(model: &AppModel, row: BuilderFocus, label: &str, value: &str) -> Line<'static> {
270 let style = if model.builder.focus == row {
271 HIGHLIGHT
272 } else {
273 Style::new()
274 };
275 Line::from(vec![
276 Span::styled(format!("{label:8}"), style),
277 Span::raw(" "),
278 Span::styled(
279 value.to_string(),
280 if model.builder.focus == row {
281 ACTIVE
282 } else {
283 MUTED
284 },
285 ),
286 ])
287}
288
289fn prompt_input_lines(label: &str, buf: &str) -> Vec<Line<'static>> {
290 vec![
291 Line::from(""),
292 Line::from(Span::styled(format!("{label}: {buf}_"), ACTIVE)),
293 Line::from(Span::styled("Enter save · Esc cancel", LABEL)),
294 ]
295}
296
297fn input_hint_lines(model: &AppModel) -> Vec<Line<'static>> {
298 match model.builder.input_mode {
299 InputMode::None => Vec::new(),
300 InputMode::Search => prompt_input_lines("Search", &model.builder.input_buf),
301 InputMode::Sort => prompt_input_lines("Sort field", &model.builder.input_buf),
302 InputMode::Markets => prompt_input_lines("Markets CSV", &model.builder.input_buf),
303 InputMode::Index => prompt_input_lines("Index CSV", &model.builder.input_buf),
304 InputMode::FilterField => {
305 let mut out = vec![
306 Line::from(""),
307 Line::from(Span::styled(
308 format!("Field search: {}_", model.builder.input_buf),
309 ACTIVE,
310 )),
311 ];
312 for (i, field) in model.builder.field_matches.iter().take(8).enumerate() {
313 let pick = if i == model.builder.field_pick {
314 ">"
315 } else {
316 " "
317 };
318 let style = if i == model.builder.field_pick {
319 HIGHLIGHT
320 } else {
321 MUTED
322 };
323 out.push(Line::from(Span::styled(
324 format!(" {pick} {} ({})", field.field_name, field.label),
325 style,
326 )));
327 }
328 out.push(Line::from(Span::styled(
329 "j/k pick · Enter next · Esc cancel",
330 LABEL,
331 )));
332 out
333 }
334 InputMode::FilterOp => {
335 let op = FILTER_OPS
336 .get(model.builder.op_index)
337 .copied()
338 .unwrap_or(FILTER_OPS[0]);
339 vec![
340 Line::from(""),
341 Line::from(Span::styled(
342 format!("Operator: {op} (o cycle · Enter value)"),
343 ACTIVE,
344 )),
345 ]
346 }
347 InputMode::FilterValue => vec![
348 Line::from(""),
349 Line::from(Span::styled(
350 format!(
351 "Value for {}: {}_",
352 model.builder.draft_field.as_deref().unwrap_or("?"),
353 model.builder.input_buf
354 ),
355 ACTIVE,
356 )),
357 Line::from(Span::styled("Enter add · Esc cancel", LABEL)),
358 ],
359 }
360}
361
362fn draw_payload(frame: &mut Frame<'_>, area: Rect, model: &AppModel) {
363 let text = match payload_pretty(&model.config, false) {
364 Ok(json) => json,
365 Err(err) => format!("payload error: {err}"),
366 };
367 let body = Paragraph::new(text)
368 .wrap(Wrap { trim: false })
369 .scroll((u16::try_from(model.scroll).unwrap_or(0), 0))
370 .style(MUTED)
371 .block(
372 Block::default()
373 .borders(Borders::ALL)
374 .title(" Payload (build_payload JSON) "),
375 );
376 frame.render_widget(body, area);
377}
378
379fn draw_codegen(frame: &mut Frame<'_>, area: Rect, model: &AppModel) {
380 let text = render_codegen(&model.config);
381 let body = Paragraph::new(text)
382 .wrap(Wrap { trim: false })
383 .scroll((u16::try_from(model.scroll).unwrap_or(0), 0))
384 .style(MUTED)
385 .block(
386 Block::default()
387 .borders(Borders::ALL)
388 .title(" Codegen (Rust + CLI) "),
389 );
390 frame.render_widget(body, area);
391}
392
393fn draw_results(frame: &mut Frame<'_>, area: Rect, model: &AppModel) {
394 let selected = select_table_fields(&model.rows, &model.fields, model.max_columns);
395 let columns = get_columns_to_request(&model.fields);
396
397 let mut header_cells = vec![Cell::from("Symbol").style(ACCENT.add_modifier(Modifier::BOLD))];
398 header_cells.extend(
399 selected
400 .iter()
401 .map(|f| Cell::from(f.label.as_str()).style(ACCENT.add_modifier(Modifier::BOLD))),
402 );
403 let header = Row::new(header_cells).height(1);
404
405 let visible = model.rows.get(model.scroll..).unwrap_or(&[]);
406 let body_rows: Vec<Row<'_>> = visible
407 .iter()
408 .map(|row| {
409 let tech = RowTechMap::from_row(row, &columns);
410 let mut cells = vec![Cell::from(row.symbol.as_str())];
411 for field in &selected {
412 let value = row.data.get(&field.label).unwrap_or(&Value::Null);
413 let cell = format_cell_for_field(value, field, Some(&tech));
414 let style = Style::new().fg(tone_color(cell.tone));
415 cells.push(Cell::from(cell.text).style(style));
416 }
417 Row::new(cells)
418 })
419 .collect();
420
421 let mut widths = vec![Constraint::Length(22)];
422 widths.extend(selected.iter().map(|_| Constraint::Min(10)));
423
424 let table = Table::new(body_rows, widths)
425 .header(header)
426 .block(Block::default().borders(Borders::ALL).title(" Results "))
427 .column_spacing(1);
428
429 frame.render_widget(table, area);
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use crate::field::{Asset, FieldDef};
436 use crate::tui::model::{ScanConfig, DEFAULT_WATCH_INTERVAL_SECS};
437 use ratatui::backend::TestBackend;
438 use ratatui::Terminal;
439 use serde_json::json;
440
441 fn sample_model() -> AppModel {
442 let config = ScanConfig {
443 asset: Asset::Crypto,
444 preset: Some("crypto_price".into()),
445 from: 0,
446 limit: 5,
447 search: None,
448 markets: None,
449 index: None,
450 sort_by: None,
451 ascending: false,
452 filters: Vec::new(),
453 };
454 let fields = vec![FieldDef {
455 label: "Change %".into(),
456 field_name: "change".into(),
457 format: Some("percent".into()),
458 interval: true,
459 historical: false,
460 }];
461 let mut data = serde_json::Map::new();
462 data.insert("Change %".into(), json!(1.25));
463 let row = crate::ScreenerRow {
464 symbol: "BINANCE:BTCUSDT".into(),
465 data,
466 };
467 AppModel::new(
468 config,
469 fields,
470 vec![row],
471 false,
472 DEFAULT_WATCH_INTERVAL_SECS,
473 )
474 }
475
476 fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
477 let buf = terminal.backend().buffer();
478 let area = buf.area();
479 let mut out = String::new();
480 for y in 0..area.height {
481 for x in 0..area.width {
482 out.push_str(buf[(x, y)].symbol());
483 }
484 out.push('\n');
485 }
486 out
487 }
488
489 #[test]
490 fn draw_results_shows_title_and_symbol() {
491 let backend = TestBackend::new(80, 20);
492 let mut terminal = Terminal::new(backend).expect("terminal");
493 let model = sample_model();
494 terminal.draw(|f| draw(f, &model)).expect("draw");
495 let text = buffer_text(&terminal);
496 assert!(text.contains("tvscreener-tui"), "{text}");
497 assert!(text.contains("BINANCE:BTCUSDT"), "{text}");
498 assert!(text.contains("1.25%"), "{text}");
499 }
500
501 #[test]
502 fn draw_help_lists_keys() {
503 let backend = TestBackend::new(80, 20);
504 let mut terminal = Terminal::new(backend).expect("terminal");
505 let mut model = sample_model();
506 model.view = ViewMode::Help;
507 terminal.draw(|f| draw(f, &model)).expect("draw");
508 let text = buffer_text(&terminal);
509 assert!(text.contains("Builder"), "{text}");
510 assert!(text.contains("Payload"), "{text}");
511 }
512
513 #[test]
514 fn draw_builder_shows_asset_and_sort_rows() {
515 let backend = TestBackend::new(80, 28);
516 let mut terminal = Terminal::new(backend).expect("terminal");
517 let mut model = sample_model();
518 model.view = ViewMode::Builder;
519 terminal.draw(|f| draw(f, &model)).expect("draw");
520 let text = buffer_text(&terminal);
521 assert!(text.contains("Asset"), "{text}");
522 assert!(text.contains("Sort"), "{text}");
523 assert!(text.contains("crypto"), "{text}");
524 }
525
526 #[test]
527 fn draw_builder_shows_preset() {
528 let backend = TestBackend::new(80, 24);
529 let mut terminal = Terminal::new(backend).expect("terminal");
530 let mut model = sample_model();
531 model.view = ViewMode::Builder;
532 terminal.draw(|f| draw(f, &model)).expect("draw");
533 let text = buffer_text(&terminal);
534 assert!(text.contains("crypto_price"), "{text}");
535 }
536
537 #[test]
538 fn draw_payload_renders_filter_key() {
539 let backend = TestBackend::new(80, 24);
540 let mut terminal = Terminal::new(backend).expect("terminal");
541 let mut model = sample_model();
542 model.view = ViewMode::Payload;
543 terminal.draw(|f| draw(f, &model)).expect("draw");
544 let text = buffer_text(&terminal);
545 assert!(text.contains("filter"), "{text}");
546 }
547}