1use 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
15pub const DEFAULT_WATCH_INTERVAL_SECS: f64 = 30.0;
17
18pub const MIN_TUI_REFRESH_SECS: f64 = 10.0;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum ViewMode {
27 #[default]
29 Results,
30 Builder,
32 Payload,
34 Codegen,
36 Help,
38}
39
40impl ViewMode {
41 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum BuilderFocus {
68 Asset,
70 #[default]
72 Preset,
73 Limit,
75 Search,
77 Sort,
79 Markets,
81 Index,
83 Filters,
85}
86
87pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
99pub enum InputMode {
100 #[default]
102 None,
103 Search,
105 Sort,
107 Markets,
109 Index,
111 FilterField,
113 FilterOp,
115 FilterValue,
117}
118
119pub 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#[derive(Debug, Clone)]
131pub struct BuilderUi {
132 pub preset_options: Vec<String>,
134 pub preset_index: usize,
136 pub focus: BuilderFocus,
138 pub filter_selected: usize,
140 pub input_mode: InputMode,
142 pub input_buf: String,
144 pub field_matches: Vec<FieldDef>,
146 pub field_pick: usize,
148 pub op_index: usize,
150 pub draft_field: Option<String>,
152}
153
154impl BuilderUi {
155 #[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 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 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 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#[derive(Debug, Clone)]
201pub struct ScanConfig {
202 pub asset: Asset,
204 pub preset: Option<String>,
206 pub from: u32,
208 pub limit: u32,
210 pub search: Option<String>,
212 pub markets: Option<String>,
214 pub index: Option<String>,
216 pub sort_by: Option<String>,
218 pub ascending: bool,
220 pub filters: Vec<FieldCondition>,
222}
223
224impl ScanConfig {
225 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#[derive(Debug, Clone)]
240pub struct AppModel {
241 pub config: ScanConfig,
243 pub builder: BuilderUi,
245 pub fields: Vec<FieldDef>,
247 pub rows: Vec<ScreenerRow>,
249 pub status: String,
251 pub error: Option<String>,
253 pub view: ViewMode,
255 pub scroll: usize,
257 pub ticks: u64,
259 pub max_columns: usize,
261 pub watch: bool,
263 pub watch_interval: Duration,
265 pub last_scan_at: Instant,
267}
268
269impl AppModel {
270 #[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 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 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 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 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 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 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 #[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 #[must_use]
380 pub fn can_refresh(&self) -> bool {
381 self.cooldown_secs_remaining() == 0
382 }
383
384 #[must_use]
386 pub fn watch_due(&self) -> bool {
387 self.watch && self.last_scan_at.elapsed() >= self.watch_interval
388 }
389
390 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 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 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 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 pub const fn toggle_sort_ascending(&mut self) {
467 self.config.ascending = !self.config.ascending;
468 }
469
470 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 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}