Skip to main content

tvscreener/tui/
draw.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Ratatui draw routines for the screener TUI.
5
6use 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
25/// Draws the TUI chrome and active pane.
26pub 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}