Skip to main content

tvscreener/tui/
model.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! TUI application model and scan configuration.
5
6use std::time::{Duration, Instant};
7
8use crate::beautify::DEFAULT_TABLE_MAX_COLUMNS;
9use crate::field::{default_fields, get_preset, search_fields, Asset, FieldDef};
10use crate::filter::{FieldCondition, FilterOperator};
11use crate::ScreenerRow;
12
13use super::scan::{preset_index, preset_options};
14
15/// Default auto-refresh interval when watch mode is on (seconds).
16pub const DEFAULT_WATCH_INTERVAL_SECS: f64 = 30.0;
17
18/// Minimum gap between TUI scans (manual `r` or watch).
19///
20/// Stricter than library [`crate::util::MIN_STREAM_INTERVAL_SECS`] (1s), which is only an
21/// API floor for `stream()`, not a UI default.
22pub const MIN_TUI_REFRESH_SECS: f64 = 10.0;
23
24/// Which pane the TUI is showing.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum ViewMode {
27    /// Beautified results table.
28    #[default]
29    Results,
30    /// Query builder (preset, limit, search, filters).
31    Builder,
32    /// Pretty-printed `build_payload()` JSON.
33    Payload,
34    /// Rust + CLI codegen snippets.
35    Codegen,
36    /// Key binding help.
37    Help,
38}
39
40impl ViewMode {
41    /// Cycles to the next primary pane (skips Help).
42    #[must_use]
43    pub const fn next(self) -> Self {
44        match self {
45            Self::Results => Self::Builder,
46            Self::Builder => Self::Payload,
47            Self::Payload => Self::Codegen,
48            Self::Codegen | Self::Help => Self::Results,
49        }
50    }
51
52    /// Short label for the title bar.
53    #[must_use]
54    pub const fn tab_label(self) -> &'static str {
55        match self {
56            Self::Results => "1 Results",
57            Self::Builder => "2 Builder",
58            Self::Payload => "3 Payload",
59            Self::Codegen => "4 Codegen",
60            Self::Help => "Help",
61        }
62    }
63}
64
65/// Builder row focus for `j` / `k` navigation.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum BuilderFocus {
68    /// Asset class picker.
69    Asset,
70    /// Field preset picker.
71    #[default]
72    Preset,
73    /// Row limit.
74    Limit,
75    /// Name search text.
76    Search,
77    /// Sort field and direction.
78    Sort,
79    /// Stock markets CSV.
80    Markets,
81    /// Stock index CSV.
82    Index,
83    /// Filter list.
84    Filters,
85}
86
87/// Assets shown in the builder picker (fixed order).
88pub const BUILDER_ASSETS: [Asset; 6] = [
89    Asset::Stock,
90    Asset::Crypto,
91    Asset::Forex,
92    Asset::Bond,
93    Asset::Futures,
94    Asset::Coin,
95];
96
97/// Text input sub-mode inside the builder pane.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
99pub enum InputMode {
100    /// No active input.
101    #[default]
102    None,
103    /// Editing search text.
104    Search,
105    /// Editing sort field text.
106    Sort,
107    /// Editing stock markets CSV.
108    Markets,
109    /// Editing stock index CSV.
110    Index,
111    /// Picking a field from catalog search hits.
112    FilterField,
113    /// Cycling filter operator before value entry.
114    FilterOp,
115    /// Typing filter right-hand value.
116    FilterValue,
117}
118
119/// Operators offered in the simple filter wizard.
120pub const FILTER_OPS: [FilterOperator; 6] = [
121    FilterOperator::Above,
122    FilterOperator::Below,
123    FilterOperator::AboveOrEqual,
124    FilterOperator::BelowOrEqual,
125    FilterOperator::Equal,
126    FilterOperator::Match,
127];
128
129/// Builder pane UI state (not sent to the API until refresh).
130#[derive(Debug, Clone)]
131pub struct BuilderUi {
132    /// Sorted preset names for the current asset (includes `(defaults)`).
133    pub preset_options: Vec<String>,
134    /// Index into [`Self::preset_options`].
135    pub preset_index: usize,
136    /// Highlighted builder row.
137    pub focus: BuilderFocus,
138    /// Selected filter row (`d` removes this one).
139    pub filter_selected: usize,
140    /// Active text input, if any.
141    pub input_mode: InputMode,
142    /// Buffer for search / field query / filter value.
143    pub input_buf: String,
144    /// Field catalog hits while adding a filter.
145    pub field_matches: Vec<FieldDef>,
146    /// Selected index in [`Self::field_matches`].
147    pub field_pick: usize,
148    /// Index into [`FILTER_OPS`] while adding a filter.
149    pub op_index: usize,
150    /// Draft field name between field pick and value entry.
151    pub draft_field: Option<String>,
152}
153
154impl BuilderUi {
155    /// Builds picker state from scan config.
156    #[must_use]
157    pub fn from_config(config: &ScanConfig) -> Self {
158        let preset_options = preset_options(config.asset);
159        let preset_index = preset_index(config.asset, config.preset.as_deref());
160        Self {
161            preset_options,
162            preset_index,
163            focus: BuilderFocus::default(),
164            filter_selected: 0,
165            input_mode: InputMode::default(),
166            input_buf: String::new(),
167            field_matches: Vec::new(),
168            field_pick: 0,
169            op_index: 0,
170            draft_field: None,
171        }
172    }
173
174    /// Refreshes preset list and index after the config asset or preset changes.
175    pub fn sync_preset_index(&mut self, config: &ScanConfig) {
176        self.preset_options = preset_options(config.asset);
177        self.preset_index = preset_index(config.asset, config.preset.as_deref());
178    }
179
180    /// Updates field matches from the current query buffer.
181    pub fn refresh_field_matches(&mut self, asset: Asset) {
182        self.field_matches = search_fields(asset, &self.input_buf);
183        if self.field_pick >= self.field_matches.len() && !self.field_matches.is_empty() {
184            self.field_pick = self.field_matches.len() - 1;
185        }
186    }
187
188    /// Clears filter-add wizard state.
189    pub fn cancel_input(&mut self) {
190        self.input_mode = InputMode::None;
191        self.input_buf.clear();
192        self.field_matches.clear();
193        self.field_pick = 0;
194        self.op_index = 0;
195        self.draft_field = None;
196    }
197}
198
199/// CLI / env scan parameters for the TUI.
200#[derive(Debug, Clone)]
201pub struct ScanConfig {
202    /// Screener asset class.
203    pub asset: Asset,
204    /// Optional field preset name.
205    pub preset: Option<String>,
206    /// Result window start.
207    pub from: u32,
208    /// Max rows.
209    pub limit: u32,
210    /// Optional name search.
211    pub search: Option<String>,
212    /// Stock markets CSV (const or wire).
213    pub markets: Option<String>,
214    /// Stock index CSV (const or wire).
215    pub index: Option<String>,
216    /// Sort field (const, technical, or label).
217    pub sort_by: Option<String>,
218    /// Sort ascending when true (default descending).
219    pub ascending: bool,
220    /// Field conditions applied via `where_condition`.
221    pub filters: Vec<FieldCondition>,
222}
223
224impl ScanConfig {
225    /// Resolves selected fields (preset or asset defaults).
226    ///
227    /// # Errors
228    ///
229    /// Returns catalog errors from [`get_preset`].
230    pub fn resolve_fields(&self) -> crate::Result<Vec<FieldDef>> {
231        if let Some(name) = self.preset.as_deref() {
232            return get_preset(name);
233        }
234        Ok(default_fields(self.asset))
235    }
236}
237
238/// Pane model: config, last rows, status line, active view.
239#[derive(Debug, Clone)]
240pub struct AppModel {
241    /// Scan parameters.
242    pub config: ScanConfig,
243    /// Builder pane state.
244    pub builder: BuilderUi,
245    /// Fields used for the last scan / table columns.
246    pub fields: Vec<FieldDef>,
247    /// Last scan rows.
248    pub rows: Vec<ScreenerRow>,
249    /// Status / branding line (non-error).
250    pub status: String,
251    /// Last error message, if any.
252    pub error: Option<String>,
253    /// Active view.
254    pub view: ViewMode,
255    /// Vertical scroll offset (results rows or text panes).
256    pub scroll: usize,
257    /// Refresh counter.
258    pub ticks: u64,
259    /// Max data columns in the results table.
260    pub max_columns: usize,
261    /// When true, auto-refresh on [`Self::watch_interval`].
262    pub watch: bool,
263    /// Auto-refresh period (already floored to [`MIN_TUI_REFRESH_SECS`]).
264    pub watch_interval: Duration,
265    /// Instant of the last completed scan attempt (success or failure).
266    pub last_scan_at: Instant,
267}
268
269impl AppModel {
270    /// Builds a model after an initial scan (or empty rows on failure).
271    #[must_use]
272    pub fn new(
273        config: ScanConfig,
274        fields: Vec<FieldDef>,
275        rows: Vec<ScreenerRow>,
276        watch: bool,
277        watch_interval_secs: f64,
278    ) -> Self {
279        let watch_interval = Duration::from_secs_f64(watch_interval_secs.max(MIN_TUI_REFRESH_SECS));
280        let builder = BuilderUi::from_config(&config);
281        let status = format!(
282            "{}  rows={}  {}",
283            config.asset.as_str(),
284            rows.len(),
285            refresh_mode_label(watch, watch_interval),
286        );
287        Self {
288            builder,
289            config,
290            fields,
291            rows,
292            status,
293            error: None,
294            view: ViewMode::Results,
295            scroll: 0,
296            ticks: 0,
297            max_columns: DEFAULT_TABLE_MAX_COLUMNS,
298            watch,
299            watch_interval,
300            last_scan_at: Instant::now(),
301        }
302    }
303
304    /// Records a successful scan.
305    pub fn set_rows(&mut self, fields: Vec<FieldDef>, rows: Vec<ScreenerRow>) {
306        self.fields = fields;
307        self.rows = rows;
308        self.error = None;
309        self.scroll = 0;
310        self.ticks = self.ticks.saturating_add(1);
311        self.last_scan_at = Instant::now();
312        self.status = format!(
313            "{}  rows={}  tick={}  {}",
314            self.config.asset.as_str(),
315            self.rows.len(),
316            self.ticks,
317            refresh_mode_label(self.watch, self.watch_interval),
318        );
319    }
320
321    /// Records a scan failure (keeps previous rows).
322    pub fn set_error(&mut self, message: impl Into<String>) {
323        self.error = Some(message.into());
324        self.ticks = self.ticks.saturating_add(1);
325        self.last_scan_at = Instant::now();
326    }
327
328    /// Toggles Results ↔ Help.
329    pub const fn toggle_help(&mut self) {
330        self.view = match self.view {
331            ViewMode::Help => ViewMode::Results,
332            _ => ViewMode::Help,
333        };
334        self.scroll = 0;
335    }
336
337    /// Cycles Results → Builder → Payload → Codegen.
338    pub fn cycle_view(&mut self) {
339        if self.view == ViewMode::Help {
340            self.view = ViewMode::Results;
341        } else {
342            self.view = self.view.next();
343        }
344        self.scroll = 0;
345    }
346
347    /// Jumps to a numbered pane (`1`–`4`).
348    pub const fn set_view_number(&mut self, n: u8) {
349        self.view = match n {
350            1 => ViewMode::Results,
351            2 => ViewMode::Builder,
352            3 => ViewMode::Payload,
353            4 => ViewMode::Codegen,
354            _ => return,
355        };
356        self.scroll = 0;
357    }
358
359    /// Toggles opt-in auto-refresh (off by default).
360    pub fn toggle_watch(&mut self) {
361        self.watch = !self.watch;
362        self.status = format!(
363            "{}  rows={}  {}",
364            self.config.asset.as_str(),
365            self.rows.len(),
366            refresh_mode_label(self.watch, self.watch_interval),
367        );
368    }
369
370    /// Seconds remaining before another scan is allowed (`0` when ready).
371    #[must_use]
372    pub fn cooldown_secs_remaining(&self) -> u64 {
373        let min_gap = Duration::from_secs_f64(MIN_TUI_REFRESH_SECS);
374        let elapsed = self.last_scan_at.elapsed();
375        min_gap.saturating_sub(elapsed).as_secs()
376    }
377
378    /// Returns true when a manual or watch refresh may run (honors TUI floor).
379    #[must_use]
380    pub fn can_refresh(&self) -> bool {
381        self.cooldown_secs_remaining() == 0
382    }
383
384    /// Returns true when watch mode is on and the watch interval has elapsed.
385    #[must_use]
386    pub fn watch_due(&self) -> bool {
387        self.watch && self.last_scan_at.elapsed() >= self.watch_interval
388    }
389
390    /// Scrolls the active pane by `delta` lines.
391    pub fn scroll_by(&mut self, delta: isize) {
392        if delta >= 0 {
393            self.scroll = self
394                .scroll
395                .saturating_add(usize::try_from(delta).unwrap_or(0));
396        } else {
397            let step = usize::try_from(delta.wrapping_neg()).unwrap_or(0);
398            self.scroll = self.scroll.saturating_sub(step);
399        }
400    }
401
402    /// Moves builder focus up/down.
403    pub fn builder_focus_by(&mut self, delta: isize) {
404        let rows = [
405            BuilderFocus::Asset,
406            BuilderFocus::Preset,
407            BuilderFocus::Limit,
408            BuilderFocus::Search,
409            BuilderFocus::Sort,
410            BuilderFocus::Markets,
411            BuilderFocus::Index,
412            BuilderFocus::Filters,
413        ];
414        let idx = rows
415            .iter()
416            .position(|&f| f == self.builder.focus)
417            .unwrap_or(0);
418        let next = if delta >= 0 {
419            (idx + 1).min(rows.len() - 1)
420        } else {
421            idx.saturating_sub(1)
422        };
423        self.builder.focus = rows[next];
424    }
425
426    /// Cycles asset when the Asset row is focused.
427    pub fn step_asset(&mut self, delta: isize) {
428        let idx = BUILDER_ASSETS
429            .iter()
430            .position(|&a| a == self.config.asset)
431            .unwrap_or(0);
432        let len = BUILDER_ASSETS.len();
433        let next = if delta >= 0 {
434            (idx + 1) % len
435        } else {
436            (idx + len - 1) % len
437        };
438        if BUILDER_ASSETS[next] != self.config.asset {
439            self.config.asset = BUILDER_ASSETS[next];
440            self.on_asset_changed();
441        }
442    }
443
444    /// Resets config after an asset switch in the builder.
445    pub fn on_asset_changed(&mut self) {
446        self.config.preset = None;
447        self.config.filters.clear();
448        if self.config.asset != Asset::Stock {
449            self.config.markets = None;
450            self.config.index = None;
451        }
452        self.builder.sync_preset_index(&self.config);
453        self.builder.filter_selected = 0;
454        self.builder.cancel_input();
455        self.rows.clear();
456        self.fields = default_fields(self.config.asset);
457        self.status = format!(
458            "{}  asset changed - Enter to scan",
459            self.config.asset.as_str()
460        );
461        self.error = None;
462        self.scroll = 0;
463    }
464
465    /// Toggles sort direction when the Sort row is focused.
466    pub const fn toggle_sort_ascending(&mut self) {
467        self.config.ascending = !self.config.ascending;
468    }
469
470    /// Steps the preset picker and updates config.
471    pub fn step_preset(&mut self, delta: isize) {
472        let len = self.builder.preset_options.len();
473        if len == 0 {
474            return;
475        }
476        let idx = self.builder.preset_index;
477        let next = if delta >= 0 {
478            (idx + 1).min(len - 1)
479        } else {
480            idx.saturating_sub(1)
481        };
482        self.builder.preset_index = next;
483        super::scan::apply_preset_index(&mut self.config, next);
484        self.builder.sync_preset_index(&self.config);
485    }
486
487    /// Adjusts row limit (minimum 1, maximum 500).
488    pub fn step_limit(&mut self, delta: i32) {
489        let next = (i64::from(self.config.limit) + i64::from(delta)).clamp(1, 500);
490        self.config.limit = u32::try_from(next).unwrap_or(1);
491    }
492}
493
494fn refresh_mode_label(watch: bool, interval: Duration) -> String {
495    if watch {
496        format!("watch {}s", interval.as_secs().max(1))
497    } else {
498        "manual".into()
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    fn stock_model() -> AppModel {
507        AppModel::new(
508            ScanConfig {
509                asset: Asset::Stock,
510                preset: None,
511                from: 0,
512                limit: 5,
513                search: None,
514                markets: Some("AMERICA".into()),
515                index: Some("SP500".into()),
516                sort_by: None,
517                ascending: false,
518                filters: Vec::new(),
519            },
520            Vec::new(),
521            Vec::new(),
522            false,
523            DEFAULT_WATCH_INTERVAL_SECS,
524        )
525    }
526
527    fn empty_model(watch: bool, interval: f64) -> AppModel {
528        AppModel::new(
529            ScanConfig {
530                asset: Asset::Crypto,
531                preset: None,
532                from: 0,
533                limit: 5,
534                search: None,
535                markets: None,
536                index: None,
537                sort_by: None,
538                ascending: false,
539                filters: Vec::new(),
540            },
541            Vec::new(),
542            Vec::new(),
543            watch,
544            interval,
545        )
546    }
547
548    #[test]
549    fn watch_off_by_default_and_interval_floored() {
550        let model = empty_model(false, 1.0);
551        assert!(!model.watch);
552        assert!(!model.watch_due());
553        assert_eq!(
554            model.watch_interval,
555            Duration::from_secs_f64(MIN_TUI_REFRESH_SECS)
556        );
557        assert!(!model.can_refresh());
558    }
559
560    #[test]
561    fn toggle_watch_updates_label() {
562        let mut model = empty_model(false, DEFAULT_WATCH_INTERVAL_SECS);
563        model.toggle_watch();
564        assert!(model.watch);
565        assert!(model.status.contains("watch"));
566        model.toggle_watch();
567        assert!(!model.watch);
568        assert!(model.status.contains("manual"));
569    }
570
571    #[test]
572    fn cycle_view_order() {
573        let mut model = empty_model(false, DEFAULT_WATCH_INTERVAL_SECS);
574        assert_eq!(model.view, ViewMode::Results);
575        model.cycle_view();
576        assert_eq!(model.view, ViewMode::Builder);
577        model.cycle_view();
578        assert_eq!(model.view, ViewMode::Payload);
579        model.cycle_view();
580        assert_eq!(model.view, ViewMode::Codegen);
581    }
582
583    #[test]
584    fn step_limit_clamps() {
585        let mut model = empty_model(false, DEFAULT_WATCH_INTERVAL_SECS);
586        model.step_limit(1000);
587        assert_eq!(model.config.limit, 500);
588        model.step_limit(-1000);
589        assert_eq!(model.config.limit, 1);
590    }
591
592    #[test]
593    fn step_asset_clears_markets_when_leaving_stock() {
594        let mut model = stock_model();
595        model.step_asset(1);
596        assert_eq!(model.config.asset, Asset::Crypto);
597        assert!(model.config.markets.is_none());
598        assert!(model.config.index.is_none());
599        assert_eq!(model.config.filters.len(), 0);
600        assert!(model.config.preset.is_none());
601        assert!(model.status.contains("asset changed"));
602    }
603
604    #[test]
605    fn builder_focus_includes_asset_and_sort() {
606        let mut model = empty_model(false, DEFAULT_WATCH_INTERVAL_SECS);
607        model.builder.focus = BuilderFocus::Asset;
608        model.builder_focus_by(1);
609        assert_eq!(model.builder.focus, BuilderFocus::Preset);
610        model.builder.focus = BuilderFocus::Search;
611        model.builder_focus_by(1);
612        assert_eq!(model.builder.focus, BuilderFocus::Sort);
613        model.builder_focus_by(1);
614        assert_eq!(model.builder.focus, BuilderFocus::Markets);
615    }
616
617    #[test]
618    fn toggle_sort_ascending_flips_flag() {
619        let mut model = empty_model(false, DEFAULT_WATCH_INTERVAL_SECS);
620        assert!(!model.config.ascending);
621        model.toggle_sort_ascending();
622        assert!(model.config.ascending);
623    }
624}