//! App state and the event loop. Feed caching with its request-budget //! guards lives in `feeds`, the settings overlay in `settings`; views under //! `crate::ui` are stateless renderers over `&mut App`. mod alerts; mod feeds; mod mouse; pub(crate) mod report_calendar; mod runtime; mod settings; mod ticker_search; pub use feeds::{FeedBundle, FeedKind}; pub use settings::{SettingsRow, SettingsState, settings_rows}; pub use ticker_search::TickerSearch; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::Result; use chrono::{DateTime, Local, NaiveDate, Utc}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::style::Style; use ratatui::widgets::TableState; use tokio::sync::Notify; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; use crate::alphai::{self, Article}; use crate::category::{self, Categories}; use crate::config::{self, ChartDefaults, Config, UiDefaults}; use crate::domain::{Interval, Range, Sessions, TickerData}; use crate::indicators::MaType; use crate::keymap::{Action, Keymap}; use crate::motion; use crate::poller::{SharedEvery, SharedParams, SharedSource, SharedSymbols, SourceEvent}; use crate::portfolio::{self, Position}; use crate::theme::Theme; use crate::ui; pub use crate::motion::PRICE_FLASH; /// How long every symbol has to be failing before the app looks for another /// source. Three cycles at the default 15s poll, which is enough to tell a /// blocked provider from one unlucky round of timeouts, and on a long poll /// interval it simply means the next cycle. const FALLBACK_AFTER: Duration = Duration::from_secs(45); /// How long a footer notice stays up. Long enough to be read on a glance /// back at the terminal, short enough not to sit on top of the key hints /// for the rest of the session; the header keeps naming the live source /// after it fades. const NOTICE: Duration = Duration::from_secs(60); /// How the price chart draws history: candlesticks or the classic close line. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ChartStyle { Candles, Line, } /// News feed scope the f key cycles: the selected ticker, the whole market /// (story-collapsed), or the 48h trending top 10. Session-only, like the /// chart options. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum NewsScope { #[default] Ticker, Market, Trending, } impl NewsScope { pub fn next(self) -> Self { match self { Self::Ticker => Self::Market, Self::Market => Self::Trending, Self::Trending => Self::Ticker, } } /// Label for block titles and head lines. pub fn label(self, symbol: &str) -> &str { match self { Self::Ticker => symbol, Self::Market => "market", Self::Trending => "trending", } } } /// State of the full-article card overlay (v in the News/Insider views). #[derive(Default)] pub struct ArticleOverlay { pub open: bool, pub scroll: u16, /// Set by the view during a frame when it drew the card in place (the /// News chart layout), so `ui::draw` skips the fullscreen one. pub inline: bool, } /// Which line the one-line prompt is taking. #[derive(Clone, Copy, Default, PartialEq, Eq)] pub enum PromptKind { /// A ticker for the watchlist (a). #[default] Ticker, /// A holding: quantity and average price, for `Prompt::target` (p). Position, /// A one-shot price crossing for the focused ticker (A). Alert, } /// State of the prompt overlay (a, p and A anywhere). Ticker syntax is checked /// before adding it; whether it exists is still the source's answer. #[derive(Default)] pub struct Prompt { pub open: bool, pub kind: PromptKind, pub input: String, /// The fixed ticker a position or alert applies to. pub target: String, /// Set for the cases the prompt itself can rule on, such as a symbol /// already on the list or a quantity that is not a number. pub error: Option, } /// State of the help overlay (? anywhere): the full key table. #[derive(Default)] pub struct HelpOverlay { pub open: bool, pub scroll: u16, } /// State of the category picker (C where a news feed is shown). It edits a /// draft that Enter applies, so walking the list costs nothing and Esc /// leaves the feed as it was. #[derive(Default)] pub struct CategoryPicker { pub open: bool, /// Row under the cursor, an index into `category::ALL`. pub cursor: usize, pub draft: Categories, } /// Where the News view puts the article card pane relative to the list /// (x cycles, and so does the settings row). Seeded from /// `[ui] news_layout`; Save in the settings screen writes it back. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum NewsLayout { /// List on the left, card on the right. Side, /// Side, plus the ticker's chart above the list with the selected /// article marked on it: the story and the move it went with on one /// screen. Ticker scope on a wide terminal only; elsewhere it reads as /// Side, since a market-wide story has no one chart to belong to. #[default] Chart, /// List on top, card below. Stacked, } impl NewsLayout { /// Every layout in cycle order: the default first. pub const ALL: [Self; 3] = [Self::Chart, Self::Stacked, Self::Side]; pub fn next(self) -> Self { self.step(1) } /// The neighbour in cycle order, `dir` 1 forward and -1 back, wrapping. pub fn step(self, dir: isize) -> Self { let n = Self::ALL.len() as isize; let i = Self::ALL.iter().position(|l| *l == self).unwrap_or(0) as isize; Self::ALL[(i + dir).rem_euclid(n) as usize] } /// The name `[ui] news_layout` spells it with. pub fn name(self) -> &'static str { match self { Self::Side => "side", Self::Chart => "chart", Self::Stacked => "stacked", } } pub fn from_name(name: &str) -> Option { Self::ALL .into_iter() .find(|l| l.name().eq_ignore_ascii_case(name.trim())) } } /// Window of the Insider view's Form 4 chart panel, cycled by the g key: /// hidden, trailing 3 months, trailing 12 months. Seeded from /// `[ui] insider_chart`, then session-only, like the chart options. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum InsiderChartWindow { Off, #[default] M3, M12, } impl InsiderChartWindow { pub fn next(self) -> Self { match self { Self::Off => Self::M3, Self::M3 => Self::M12, Self::M12 => Self::Off, } } /// Calendar days the window covers; whole weeks, so the weekly bars /// stay uniform. None = the panel is hidden. pub fn days(self) -> Option { match self { Self::Off => None, Self::M3 => Some(91), Self::M12 => Some(364), } } /// Label for the panel title and the config value. pub fn label(self) -> &'static str { match self { Self::Off => "off", Self::M3 => "3m", Self::M12 => "12m", } } } /// One ticker's earnings reads with the moment they were fetched. Cached far /// longer than a feed (a read changes once a quarter), so the timestamp lives /// beside the data rather than in a feed bundle. pub struct EarningsSlot { pub data: alphai::TickerEarnings, pub fetched: Instant, } /// The last successful macro response and its requested UTC date window. pub struct CalendarSlot { pub events: Vec, pub fetched: Instant, pub from: NaiveDate, pub to: NaiveDate, } /// The default combos the t and T keys cycle through (`[chart] presets` /// overrides them); wraps at the ends. A startup combo not in the table /// (e.g. -r 3mo) jumps to the first preset on t and to the last on T. pub const RANGE_PRESETS: [(Range, Interval); 5] = [ (Range::D1, Interval::M5), (Range::D5, Interval::M15), (Range::Mo1, Interval::M60), (Range::Mo6, Interval::D1), (Range::Y1, Interval::D1), ]; fn next_preset( presets: &[(Range, Interval)], cur: (Range, Interval), dir: isize, ) -> (Range, Interval) { let n = presets.len() as isize; match presets.iter().position(|&p| p == cur) { Some(i) => presets[((i as isize + dir).rem_euclid(n)) as usize], None if dir > 0 => presets[0], None => presets[presets.len() - 1], } } pub struct AppInit { pub symbols: Vec, /// Validated `[[positions]]`; empty when the section is absent. pub positions: Vec, pub shared_symbols: SharedSymbols, pub source: SharedSource, pub source_name: &'static str, pub range: Range, pub interval: Interval, /// Whether the chart draws the pre and post market candles too. pub sessions: Sessions, pub params: SharedParams, pub every: SharedEvery, pub rx: UnboundedReceiver, pub refresh: Arc, pub alphai_tx: UnboundedSender, pub config: Config, /// Where Save writes the config back; None disables persisting. pub config_path: Option, pub theme: Theme, pub theme_name: &'static str, pub chart: ChartDefaults, pub ui: UiDefaults, /// Resolved from `[keybindings]` (defaults when the section is absent). pub keymap: Keymap, pub alphai_enabled: bool, pub first_run: bool, /// Why the config file did not load, when it exists but does not: the /// session runs on the defaults and says so in the footer. pub config_error: Option, /// Whether a source that stops answering may be swapped for one that /// still does (`source_fallback` in the config). pub source_fallback: bool, } pub struct App { pub symbols: Vec, /// What the user holds, in config order. Off-watchlist holdings are /// polled too (`polled_symbols`), so every row can be valued. pub positions: Vec, pub data: HashMap, pub alerts: crate::alerts::Alerts, pub errors: HashMap, /// The window each entry of `errors` was asked for, so a failure of the /// old window is not read as the new one's until it is actually tried /// (see `window_failed`). Kept beside `errors` rather than in it: the /// message is what every view reads, and tests set it plainly. error_window: HashMap, /// When a poll changed a symbol's price: (moment, tick was up). Views /// pulse the price for `PRICE_FLASH` after it via `price_flash_dir`; /// the first data a symbol ever gets sets no flash (nothing changed). pub price_flash: HashMap, pub animations: bool, /// Whether the app takes the mouse (`[ui] mouse`, `M`). The runtime /// loop enables and disables the terminal's capture to match. pub mouse: bool, /// Where the last frame put the things the mouse can act on. pub hits: ui::hits::Hits, pub synchronized_output: bool, price_refreshing: bool, animation_epoch: Instant, /// Symbols whose rows came off disk or from an abandoned source, /// with the unix second they were fetched. The rail /// badges them so a blocked source never passes old prices off as live; /// the first real poll of a symbol clears its entry. pub from_cache: HashMap, /// Last successful fetch per symbol, for labelling retained prices /// when a fallback changes the source before new quotes arrive. quote_fetched: HashMap, /// Since when every symbol on the watchlist has been failing. The /// fallback is measured from here; any successful poll clears it. pub(crate) source_trouble_since: Option, /// Sources this session has already given up on, so a fallback never /// walks back into the one that just failed. abandoned: Vec<&'static str>, /// Whether an unusable source may be swapped for a working one /// (`source_fallback` in the config). pub(crate) source_fallback: bool, /// Set once there is nothing left to fall back to: without it the app /// would rebuild every source on every frame of a long outage. Cleared /// whenever the user picks a source themselves. fallback_exhausted: bool, /// A short-lived line for the footer: what the app did on its own, such /// as switching source. Errors have their own place; this is for /// actions, and it fades. pub notice: Option<(String, Instant)>, pub selected: usize, pub view_idx: usize, pub source_name: &'static str, /// How stale the active source's prices are (`DataSource::delay_note`), /// re-read whenever the settings screen swaps the source. The quote /// rail badges it so a delayed price is never read as a live one. pub source_delay: Option<&'static str>, /// Whether the source has extended-hours candles at all, cached like /// `source_delay` (see `extended_unavailable`). pub source_extended: bool, /// A test's frozen clock, read through `now`. The app itself always /// runs on the wall clock. #[cfg(test)] pub(crate) frozen_now: Option>, pub range: Range, pub interval: Interval, /// Whether the chart draws the pre and post market candles too. pub sessions: Sessions, /// The window each symbol's candles were fetched for. A preset or /// session switch changes `range`/`interval`/`sessions` at once, but /// the rows only follow when the poller answers, which on a paged /// source takes seconds. Until then the chart draws the rows on their /// own window (see `chart_window`) rather than relabelling them. data_window: HashMap, pub last_update: Option>, pub table_state: TableState, /// The portfolio view's own cursor: its rows are the positions, which /// are neither the watchlist nor in its order. pub portfolio_selected: usize, pub portfolio_state: TableState, // Chart options: seeded from [chart], then session-only toggles pub chart_style: ChartStyle, pub show_sma: bool, pub show_rsi: bool, pub show_volume: bool, /// Whether the price chart marks the ticker's news on its candles /// (`n`, `[chart] news_markers`). The marks are drawn from the feed /// already in the cache, so they never cost a request. pub show_news_markers: bool, pub ma_type: MaType, /// Validated [chart] values: indicator periods and the t/T preset cycle. pub chart: ChartDefaults, // AlphAI feed state: every fetched feed by cache key (news under the // symbol/market/trending keys, insider under `ins:SYM`) pub feeds: HashMap, /// Uids the reader has had under the cursor, plus the baseline of each /// feed's first fetch, by feed cache key. Rows outside this set are new /// since the user last looked and render the unseen marker; resting the /// cursor on a row retires it (`mark_selected_seen`). Session-only and /// survives bundle replacement, so markers live through TTL refetches /// and manual `r`. pub feed_seen: HashMap>, pub alphai_errors: HashMap, pub alphai_enabled: bool, pub news_selected: usize, pub news_scope: NewsScope, pub news_layout: NewsLayout, /// Group ticker and market news by story; changed on Save in settings. pub collapse_news: bool, /// Requests still in flight when grouping changed. Their replies must /// retire before a fresh request can refill the cleared news cache. pub(super) discarded_news_requests: HashSet, /// Minimum relevance score in the ticker and market news feeds (1..=10, /// applied server-side). Seeded from `[ui] news_min_score`, then /// session-only via +/-, like the scope and layout. pub news_min_score: u8, /// Same for the insider feed, where the score tracks the trade size, so /// this filters by dollar value. Seeded from `[ui] insider_min_score`; /// +/- adjust whichever feed the visible view shows. pub insider_min_score: u8, /// Which categories the ticker and market news feeds show (applied /// server-side, trending has no such filter). Seeded from `[ui] /// news_categories` or `news_exclude_categories`, then session-only via /// the C picker, like the score. pub news_categories: Categories, /// Window of the Insider view's chart panel (g cycles off/3m/12m). pub insider_chart: InsiderChartWindow, /// The Insider view's card column beside the filing list; v toggles it. /// Session-only, on at start. pub insider_card: bool, /// Set by the Insider view each frame: whether the terminal is wide /// enough for that column, so v knows whether to toggle it or fall back /// to the fullscreen card. pub insider_card_fits: bool, /// Whether the quote rail is drawn (`[ui] quote_rail`). pub show_rail: bool, /// Bare mode (`z`, `--bare`, `[ui] bare`): the header and footer give /// their rows to the view. For a tmux pane, which carries its own /// status bar and needs the rows more than it needs the app's chrome. pub bare: bool, /// Earnings reads by symbol, each with the moment it was fetched. Its /// own map rather than a feed bundle: the payload has no pagination, no /// sort and no score filter, and it outlives a feed's TTL by an order of /// magnitude (a read changes once a quarter). pub earnings: HashMap, /// The macro calendar window and when it landed. One market-wide payload /// for every ticker, so it is not keyed by symbol. pub calendar: Option, pub calendar_selection: ui::calendar::Selection, pub calendar_state: TableState, pub(crate) calendar_refresh_requested: bool, pub(crate) report_calendar: report_calendar::ReportCalendar, pub(crate) earnings_target: Option, pub(crate) calendar_filter: ui::calendar::Filter, pub(crate) calendar_overlay: Option, /// SetKey acknowledgements form a barrier against old-key responses. pub(crate) alphai_key_pending: usize, /// Scroll of the Earnings view's body; reset when the ticker changes. pub earnings_scroll: u16, /// How long a fetched AlphAI bundle stays fresh. Seeded from /// `[ui] alphai_ttl_secs` (default `alphai::CACHE_TTL`), file-only. pub alphai_ttl: Duration, /// Scroll of the embedded card pane (News view); reset on selection moves. pub card_scroll: u16, pub news_table_state: TableState, pub article_overlay: ArticleOverlay, pub help: HelpOverlay, pub category_picker: CategoryPicker, pub ticker_search: TickerSearch, pub settings: SettingsState, pub prompt: Prompt, /// The watchlist as the price poller sees it; kept in step with /// `symbols` by `add_symbol` and `remove_selected_symbol`. pub shared_symbols: SharedSymbols, /// Whether `a` and `d` write the watchlist to the config. False for a /// session started on tickers from the command line while the file /// holds a list of its own (see `persist_watchlist`). pub watchlist_saved: bool, pub config: Config, pub config_path: Option, /// `a`, `d` and `p` edits that never reached the file because a write /// failed. Every later write, Save in the settings screen included, /// replays them over the file as it is then, and only a write that /// lands clears them: otherwise a holding typed while the file was /// broken went missing at the first save after it was fixed. pub pending_edits: Vec, /// The session started on a file that did not load, so `config` holds /// the defaults rather than what the file says. Save refuses to write /// them over the file once it loads again; a successful Save ends it. pub on_defaults: bool, pub theme: Theme, /// The preset `theme` was built from. The cycle key and the settings /// row move this name and rebuild the theme from it, so what is on /// screen always matches what the same name would give after a /// restart (explicit `[theme]` slots included). pub theme_name: &'static str, pub keymap: Keymap, source: SharedSource, /// The fetch window the poller re-reads each cycle. Public like the /// shared watchlist: what the keys push into it is the behaviour worth /// asserting on, not the field they set on `App`. pub params: SharedParams, /// The poll interval the poller re-reads before every sleep; the /// settings screen edits it live. every: SharedEvery, inflight: HashSet, alphai_tx: UnboundedSender, rx: UnboundedReceiver, refresh: Arc, } impl App { pub fn new(init: AppInit) -> Self { let source_delay = init.source.read().unwrap().delay_note(); let source_extended = init.source.read().unwrap().extended_candles(); let watchlist_saved = watchlist_is_saved(&init.config.watchlist, &init.symbols); let mut app = Self { watchlist_saved, pending_edits: Vec::new(), on_defaults: init.config_error.is_some(), symbols: init.symbols, shared_symbols: init.shared_symbols, prompt: Prompt::default(), data: HashMap::new(), errors: HashMap::new(), error_window: HashMap::new(), price_flash: HashMap::new(), alerts: Default::default(), animations: init.ui.animations, mouse: init.ui.mouse, hits: ui::hits::Hits::default(), synchronized_output: init.ui.synchronized_output, price_refreshing: false, animation_epoch: Instant::now(), from_cache: HashMap::new(), quote_fetched: HashMap::new(), source_trouble_since: None, abandoned: Vec::new(), source_fallback: init.source_fallback, fallback_exhausted: false, notice: None, selected: 0, view_idx: init.ui.view_idx, source_name: init.source_name, source_delay, source_extended, #[cfg(test)] frozen_now: None, range: init.range, interval: init.interval, sessions: init.sessions, data_window: HashMap::new(), last_update: None, table_state: TableState::default(), chart_style: init.chart.style, show_sma: init.chart.sma, show_rsi: init.chart.rsi, show_volume: init.chart.volume, show_news_markers: init.chart.news_markers, ma_type: init.chart.ma_type, chart: init.chart, feeds: HashMap::new(), feed_seen: HashMap::new(), alphai_errors: HashMap::new(), alphai_enabled: init.alphai_enabled, news_selected: 0, news_scope: init.ui.news_scope, news_layout: init.ui.news_layout, collapse_news: init.ui.collapse_news, discarded_news_requests: HashSet::new(), news_min_score: init.ui.news_min_score, insider_min_score: init.ui.insider_min_score, news_categories: init.ui.news_categories, insider_chart: init.ui.insider_chart, insider_card: true, insider_card_fits: false, show_rail: init.ui.quote_rail, bare: init.ui.bare, earnings: HashMap::new(), calendar: None, calendar_selection: ui::calendar::Selection::default(), calendar_state: TableState::default(), calendar_refresh_requested: false, report_calendar: report_calendar::ReportCalendar::default(), earnings_target: None, calendar_filter: ui::calendar::Filter::default(), calendar_overlay: None, alphai_key_pending: 0, earnings_scroll: 0, alphai_ttl: init.ui.alphai_ttl, card_scroll: 0, news_table_state: TableState::default(), positions: init.positions, portfolio_selected: 0, portfolio_state: TableState::default(), article_overlay: ArticleOverlay::default(), help: HelpOverlay::default(), category_picker: CategoryPicker::default(), ticker_search: TickerSearch::default(), settings: SettingsState::default(), config: init.config, config_path: init.config_path, theme: init.theme, theme_name: init.theme_name, keymap: init.keymap, source: init.source, params: init.params, every: init.every, inflight: HashSet::new(), alphai_tx: init.alphai_tx, rx: init.rx, refresh: init.refresh, }; if init.first_run { app.open_settings(); app.settings.first_run = true; } // The stderr warning is behind the TUI until it exits, and a reader // who sees no keys and no watchlist would think the file was lost. if let Some(error) = init.config_error { let name = config::file_label(app.config_path.as_deref()); app.notice = Some(( format!( "{name} did not load ({error}): running on the defaults, fix it and restart" ), Instant::now(), )); } app } pub fn selected_symbol(&self) -> &str { self.symbols.get(self.selected).map_or("", String::as_str) } /// Puts one ticker's last-good rows on screen before the first poll /// lands. Marked as cached until that poll replaces them, so the age of /// what is on screen is never a guess. pub fn seed_cached(&mut self, symbol: String, entry: crate::cache::Entry) { self.quote_fetched.insert(symbol.clone(), entry.fetched); self.from_cache.insert(symbol.clone(), entry.fetched); // The store only hands out rows saved for the starting window. self.data_window.insert(symbol.clone(), self.window()); self.data.insert(symbol, entry.data); } fn window(&self) -> (Range, Interval, Sessions) { (self.range, self.interval, self.sessions) } /// The window to draw `symbol`'s candles on, and whether a different /// one is on its way. Rows are drawn on the window they were fetched /// for: drawn on the new one, 15m bars read as hours and the axis /// extrapolates the wrong step until the poller answers. pub fn chart_window(&self, symbol: &str) -> ((Range, Interval, Sessions), bool) { let current = self.window(); let Some(&drawn) = self.data_window.get(symbol) else { return (current, false); }; let pending = drawn.0 != current.0 || drawn.1 != current.1 || (drawn.2 != current.2 && sessions_matter(symbol, current.1)); (if pending { drawn } else { current }, pending) } /// Whether the window on the keys was tried for `symbol` and refused. /// An error left over from the previous window says nothing about the /// one on its way: after a preset change that symbol is loading again. pub fn window_failed(&self, symbol: &str) -> bool { self.error_window.get(symbol) == Some(&self.window()) } /// How old the retained rows are, as a label. pub fn cached_age(&self, symbol: &str) -> Option { self.from_cache .get(symbol) .map(|at| crate::cache::age_label(*at)) } /// The still-current footer notice, if it has not faded yet. pub fn notice(&self) -> Option<&str> { self.notice .as_ref() .filter(|(_, at)| at.elapsed() < NOTICE) .map(|(text, _)| text.as_str()) } /// The still-active price pulse of a symbol: Some(tick was up) for /// `PRICE_FLASH` after a poll changed its price, then None. pub fn price_flash_dir(&self, symbol: &str) -> Option { self.price_flash .get(symbol) .filter(|(at, _)| self.animations && at.elapsed() < PRICE_FLASH) .map(|&(_, up)| up) } pub fn price_style(&self, symbol: &str, base: Style) -> Style { let Some(up) = self.price_flash_dir(symbol) else { return base; }; let elapsed = self.price_flash[symbol].0.elapsed(); motion::pulse( base, if up { self.theme.up } else { self.theme.down }, elapsed, PRICE_FLASH, ) } pub fn refreshing(&self) -> bool { self.price_refreshing || !self.inflight.is_empty() } /// A fixed-width ASCII cell, including when idle or motion is off. pub fn refresh_marker(&self) -> char { if !self.refreshing() { ' ' } else if self.animations { motion::spinner(self.animation_epoch.elapsed()) } else { '*' } } /// Identity of the visible view (its `view_idx` is the tab position). pub fn view_id(&self) -> ui::ViewId { ui::VIEWS[self.view_idx].id() } pub(crate) fn apply(&mut self, event: SourceEvent) { match event { SourceEvent::Refreshing { source, active } => { if Arc::ptr_eq(&source, &self.price_source()) { self.price_refreshing = active; } } SourceEvent::Data { params, source, symbol, mut data, } => { if !self.accepts_price_event(&source, &symbol) { return; } // Quotes do not depend on the requested candle window. // Check the incoming print before retaining old extended data. self.check_alert(&symbol, &data.quote); if params.is_some_and(|p| p != (self.range, self.interval, self.sessions)) { return; } self.errors.remove(&symbol); self.error_window.remove(&symbol); self.quote_fetched .insert(symbol.clone(), chrono::Utc::now().timestamp()); // A live answer clears the outage clock and retires the // cached rows this symbol started on. self.source_trouble_since = None; let was_cached = self.from_cache.remove(&symbol).is_some(); // A successful but partial response must not make a known // current-session print disappear. Its original timestamp // and reference close remain visible; a new session retires it. if let Some(old) = self.data.get(&symbol) && old.quote.timing.extended.is_some() && old.quote.extended_price().is_some() && data.quote.extended_price().is_none() { data.quote.extended = old.quote.extended; data.quote.timing.extended = old.quote.timing.extended; data.quote.timing.extended_feed = old.quote.timing.extended_feed; data.quote.timing.extended_reference = Some(old.quote.extended_reference()); } if let Some(old) = self.data.get(&symbol) && old.quote.price != data.quote.price // The first real price after a cached one is not a tick: // pulsing it would report yesterday's move as just now. && !was_cached && self.animations { self.price_flash.insert( symbol.clone(), (Instant::now(), data.quote.price > old.quote.price), ); } data.update_current_bar(self.interval); self.data_window .insert(symbol.clone(), params.unwrap_or(self.window())); self.data.insert(symbol, data); self.last_update = Some(Local::now()); } SourceEvent::Error { params, source, symbol, error, } => { if !self.accepts_price_event(&source, &symbol) || params.is_some_and(|p| p != (self.range, self.interval, self.sessions)) { return; } self.error_window.insert(symbol.clone(), self.window()); self.errors.insert(symbol, error); self.last_update = Some(Local::now()); // One failing ticker is a bad symbol; all of them is the // source, and that is what the fallback waits on. if self.source_trouble_since.is_none() && self.all_prices_failing() { self.source_trouble_since = Some(Instant::now()); } } SourceEvent::Alphai(ev) => self.apply_alphai(ev), } } fn accepts_price_event( &self, source: &Arc, symbol: &str, ) -> bool { // Name alone cannot distinguish rebuilt credentials for the same // provider. Pending responses retain the exact source instance. Arc::ptr_eq(source, &self.price_source()) && self .shared_symbols .read() .unwrap() .iter() .any(|s| s == symbol) } pub(crate) fn price_source(&self) -> Arc { self.source.read().unwrap().clone() } fn all_prices_failing(&self) -> bool { let symbols = self.shared_symbols.read().unwrap(); !symbols.is_empty() && symbols.iter().all(|s| self.errors.contains_key(s)) } /// State shared by manual and automatic source changes. The caller /// decides whether to retain quotes and reset the fallback history. fn set_price_source(&mut self, source: Arc) { self.source_name = source.name(); self.source_delay = source.delay_note(); self.source_extended = source.extended_candles(); *self.source.write().unwrap() = source; self.errors.clear(); self.error_window.clear(); self.source_trouble_since = None; self.price_flash.clear(); self.price_refreshing = false; self.notice = None; self.refresh.notify_one(); } /// Swaps a source that has stopped answering for one that has not. /// /// Yahoo, the keyless default, blocks by IP for tens of minutes at a /// time and no retry can shorten that, so the only real repair is a /// different provider. The swap waits for every symbol to be failing /// (one bad ticker is a bad ticker) and for `FALLBACK_AFTER` on top of /// that, keeps the rows that are on screen, and says what it did. It /// never switches back on its own: probing a throttled source is how /// the block gets extended, and the header names the source anyway. pub(crate) fn fallback_if_stuck(&mut self) { if !self.source_fallback || self.fallback_exhausted || self.settings.open { return; } // The polled symbols can change while the grace period runs. if !self.all_prices_failing() { self.source_trouble_since = None; return; } let Some(since) = self.source_trouble_since else { return; }; if since.elapsed() < FALLBACK_AFTER { return; } let Some(src) = self.fallback_source() else { // Nothing else is configured: stop looking until the user picks // a source themselves, or this rebuilds a client every frame. self.fallback_exhausted = true; return; }; let failed = self.source_name; self.abandoned.push(failed); self.from_cache.extend(self.quote_fetched.clone()); self.set_price_source(src); let name = self.source_name; self.notice = Some(( format!("{failed} stopped answering, switched to {name} (s to choose another)"), Instant::now(), )); } /// The first registered source that is not the current one, has not /// already failed this session, and has the credentials it needs. fn fallback_source(&self) -> Option> { crate::source::registry::SOURCES .iter() .filter(|info| { !info.id.eq_ignore_ascii_case(self.source_name) && !self.abandoned.contains(&info.id) }) .find_map(|info| crate::source::make_source(info.id, &self.config).ok()) } /// Returns true when the app should quit. Fixed keys resolve first /// (Ctrl-C, Esc, the positional 1-9 digits); everything else goes /// through the keymap and dispatches on `Action`, with the same view /// guards the raw keys used — a remap can never bypass them. pub(crate) fn handle_key(&mut self, key: KeyEvent) -> bool { if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { return true; } if self.prompt.open { return self.handle_prompt_key(key); } if self.ticker_search.open { return self.handle_ticker_search_key(key); } if self.settings.open { return self.handle_settings_key(key); } if self.article_overlay.open { return self.handle_overlay_key(key); } if self.calendar_overlay.is_some() { return self.handle_calendar_card_key(key); } if self.help.open { return self.handle_help_key(key); } if self.category_picker.open { return self.handle_category_key(key); } if key.code == KeyCode::Esc { return true; } if let KeyCode::Char(c @ '1'..='9') = key.code { let idx = c as usize - '1' as usize; if idx < ui::VIEWS.len() { self.switch_view(idx); } return false; } let news_view = ui::VIEWS[self.view_idx].navigates_articles(); let chart_view = ui::VIEWS[self.view_idx].has_chart_panel(); let earnings_view = ui::VIEWS[self.view_idx].shows_earnings(); let calendar_view = ui::VIEWS[self.view_idx].shows_calendar(); // ←→ walk the watchlist wherever the view is scoped to one ticker. let lr_ticker = news_view || earnings_view; let news_feed = ui::VIEWS[self.view_idx].feed_shown() == Some(FeedKind::News); let any_feed = ui::VIEWS[self.view_idx].feed_shown().is_some(); let Some(action) = self.keymap.resolve(&key) else { return false; }; match action { Action::Quit => return true, Action::AddTicker => { self.prompt = Prompt { open: true, ..Default::default() } } Action::RemoveTicker => self.remove_selected_symbol(), Action::Position => self.open_position_prompt(), Action::PriceAlert => self.open_alert_prompt(), Action::FindTicker => self.open_ticker_search(), Action::NextView => self.switch_view((self.view_idx + 1) % ui::VIEWS.len()), Action::PrevView => { self.switch_view((self.view_idx + ui::VIEWS.len() - 1) % ui::VIEWS.len()) } Action::Settings => self.open_settings(), Action::Help => { self.help = HelpOverlay { open: true, scroll: 0, } } Action::Refresh => self.manual_refresh(), Action::Up if calendar_view => self.move_calendar(-1), Action::Down if calendar_view => self.move_calendar(1), Action::PageUp if calendar_view => self.move_calendar(-10), Action::PageDown if calendar_view => self.move_calendar(10), Action::Open if calendar_view => self.open_calendar_row(), Action::Card if calendar_view => { let rows = ui::calendar::agenda(self, self.now()); self.calendar_overlay = self.calendar_selection .resolve(&rows) .map(|i| ui::calendar::CalendarOverlay { row: rows[i].clone(), scroll: 0, }); } Action::CycleScope if calendar_view => { self.calendar_filter = self.calendar_filter.next() } // News/Insider: up/down scroll articles, left/right switch ticker. Action::Up if news_view => { self.news_selected = self.news_selected.saturating_sub(1); self.card_scroll = 0; } Action::Down if news_view => { let max = self.visible_articles().map_or(0, <[Article]>::len); if self.news_selected + 1 < max { self.news_selected += 1; self.card_scroll = 0; } else { // Already on the last row: page the feed instead. self.request_more_articles(); } } Action::Left if lr_ticker => self.select_symbol(self.selected.saturating_sub(1)), Action::Right if lr_ticker && !self.symbols.is_empty() => { self.select_symbol((self.selected + 1).min(self.symbols.len() - 1)) } // The earnings body is a document: up/down scroll it, and these // arms must stay above the unguarded ones at the end of the // match, which move the watchlist selection instead. Action::Up if earnings_view => { self.earnings_scroll = self.earnings_scroll.saturating_sub(1) } Action::Down if earnings_view => { self.earnings_scroll = self.earnings_scroll.saturating_add(1) } // Card pane scrolling (the list keeps up/down). Action::PageUp if earnings_view => { self.earnings_scroll = self.earnings_scroll.saturating_sub(10) } Action::PageDown if earnings_view => { self.earnings_scroll = self.earnings_scroll.saturating_add(10) } Action::PageUp if news_view => self.card_scroll = self.card_scroll.saturating_sub(10), Action::PageDown if news_view => self.card_scroll = self.card_scroll.saturating_add(10), Action::CycleLayout if self.view_id() == ui::ViewId::News => { self.news_layout = self.news_layout.next(); self.card_scroll = 0; } Action::Open if news_view => { if let Some(a) = self .visible_articles() .and_then(|list| list.get(self.news_selected)) { open_url(&self.article_url(a)); } } // The read's own page on alphai.io, which also links the filing. // The payload carries no source URL of its own, so this ignores // the "open the original source" setting. Action::Open if earnings_view => { if let Some(url) = ui::earnings::open_url(self) { open_url(&url); } } // Insider: v shows and hides the card column beside the list, // where there is room for one. Action::Card if self.view_id() == ui::ViewId::Insider && self.insider_card_fits => { self.insider_card = !self.insider_card; self.card_scroll = 0; } Action::Card if news_view && self.visible_articles().is_some_and(|list| !list.is_empty()) => { self.article_overlay = ArticleOverlay { open: true, ..Default::default() }; } Action::CycleScope if news_feed => { self.news_scope = self.news_scope.next(); self.news_selected = 0; self.card_scroll = 0; } Action::InsiderChart if self.view_id() == ui::ViewId::Insider => { self.insider_chart = self.insider_chart.next(); } Action::Categories if news_feed => { self.category_picker = CategoryPicker { open: true, cursor: 0, draft: self.news_categories, } } Action::ScoreUp if any_feed => self.adjust_min_score(1), Action::ScoreDown if any_feed => self.adjust_min_score(-1), Action::ChartStyle if chart_view => { self.chart_style = match self.chart_style { ChartStyle::Candles => ChartStyle::Line, ChartStyle::Line => ChartStyle::Candles, } } Action::ToggleSma if chart_view => self.show_sma = !self.show_sma, Action::ToggleRsi if chart_view => self.show_rsi = !self.show_rsi, Action::ToggleVolume if chart_view => self.show_volume = !self.show_volume, Action::NewsMarkers if chart_view => self.show_news_markers = !self.show_news_markers, Action::MaType if chart_view => { self.ma_type = match self.ma_type { MaType::Sma => MaType::Ema, MaType::Ema => MaType::Sma, } } Action::NextPreset => self.cycle_range(1), Action::PrevPreset => self.cycle_range(-1), Action::ToggleBare => self.bare = !self.bare, Action::ToggleMouse => self.toggle_mouse(), Action::ToggleExtended => self.toggle_sessions(), Action::NextTheme => self.cycle_theme(1), Action::PrevTheme => self.cycle_theme(-1), Action::Up if self.view_id() == ui::ViewId::Portfolio => self.move_portfolio(-1), Action::Down if self.view_id() == ui::ViewId::Portfolio => self.move_portfolio(1), Action::Up => self.selected = self.selected.saturating_sub(1), Action::Down => { self.selected = (self.selected + 1).min(self.symbols.len().saturating_sub(1)) } _ => {} } false } /// Keys while the article card overlay is open; it swallows everything so /// list navigation does not shift under the reader. Esc always closes. fn handle_overlay_key(&mut self, key: KeyEvent) -> bool { if key.code == KeyCode::Esc { self.article_overlay = ArticleOverlay::default(); return false; } match self.keymap.resolve(&key) { Some(Action::Quit) => return true, Some(Action::Card) => self.article_overlay = ArticleOverlay::default(), Some(Action::Up) => { self.article_overlay.scroll = self.article_overlay.scroll.saturating_sub(1) } // The render pass clamps the scroll to the card's real height. Some(Action::Down) => { self.article_overlay.scroll = self.article_overlay.scroll.saturating_add(1) } Some(Action::PageUp) => { self.article_overlay.scroll = self.article_overlay.scroll.saturating_sub(10) } Some(Action::PageDown) => { self.article_overlay.scroll = self.article_overlay.scroll.saturating_add(10) } Some(Action::Open) => { if let Some(a) = self .visible_articles() .and_then(|list| list.get(self.news_selected)) { open_url(&self.article_url(a)); } } _ => {} } false } /// Keys while the help overlay is open; like the article card it swallows /// everything. Esc or ? closes, q still quits. /// Keys while the add-ticker prompt is open. It swallows everything, /// the settings pattern, so typing "d" is a letter rather than the /// remove action. fn handle_prompt_key(&mut self, key: KeyEvent) -> bool { // Tickers are short; a position line carries two numbers as well. // The caps are there so a stuck key cannot grow the line past its // box. let cap = match self.prompt.kind { PromptKind::Ticker => 16, PromptKind::Position | PromptKind::Alert => 32, }; match key.code { KeyCode::Esc => self.prompt = Prompt::default(), KeyCode::Enter => match self.commit_prompt() { Ok(()) => self.prompt = Prompt::default(), Err(msg) => self.prompt.error = Some(msg), }, KeyCode::Backspace => { self.prompt.input.pop(); self.prompt.error = None; } // The readline gesture for "start over". The position prompt // opens prefilled with what is held, so clearing the line is a // common move rather than an exotic one. KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.prompt.input.clear(); self.prompt.error = None; } // Modified keys are gestures, not text: ctrl-h used to arrive // as a plain "h" and land in the middle of a quantity. KeyCode::Char(c) if !key .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) && self.prompt.input.chars().count() < cap => { self.prompt.input.push(c); self.prompt.error = None; } _ => {} } false } fn commit_prompt(&mut self) -> Result<(), String> { let typed = self.prompt.input.clone(); match self.prompt.kind { PromptKind::Ticker => self.add_symbol(&typed), PromptKind::Alert => self.commit_alert(&typed), PromptKind::Position => { let target = self.prompt.target.clone(); let entry = portfolio::parse_entry(&typed, &target)?; self.apply_position(entry) } } } /// Opens the position prompt on the ticker in front of the reader: the /// row under the portfolio cursor in that view, the selected ticker /// anywhere else. Prefilled with what is held already, so correcting a /// number is not a retype, and an emptied line clears the holding. fn open_position_prompt(&mut self) { let target = self.focused_symbol(); let input = self .position(&target) .map(|p| format!("{} {}", p.qty, p.avg_price)) .unwrap_or_default(); self.prompt = Prompt { open: true, kind: PromptKind::Position, input, target, error: None, }; } pub(crate) fn focused_symbol(&self) -> String { if self.view_id() == ui::ViewId::Earnings { return self.earnings_symbol().to_string(); } if self.view_id() == ui::ViewId::Calendar { let rows = ui::calendar::agenda(self, self.now()); if let Some(ui::calendar::Kind::Report { symbol }) = self .calendar_selection .resolve(&rows) .map(|i| &rows[i].kind) { return symbol.clone(); } } if self.view_id() == ui::ViewId::Portfolio && let Some(p) = self.positions.get(self.portfolio_selected) { return p.symbol.clone(); } self.selected_symbol().to_string() } pub fn position(&self, symbol: &str) -> Option<&Position> { self.positions.iter().find(|p| p.symbol == symbol) } /// Moves the portfolio cursor, and the watchlist cursor with it when /// the row's ticker is on the watchlist: the rail sits above every /// view and would otherwise quote a different ticker than the row the /// cursor is on. fn move_portfolio(&mut self, dir: isize) { if self.positions.is_empty() { return; } let last = self.positions.len() as isize - 1; let next = (self.portfolio_selected as isize + dir).clamp(0, last) as usize; self.portfolio_selected = next; let symbol = self.positions[next].symbol.clone(); if let Some(at) = self.symbols.iter().position(|s| *s == symbol) { self.select_symbol(at); } } /// Writes a position through and saves it. Like the watchlist keys, and /// unlike the display options, it does not wait for Save in the /// settings screen: a quantity and a price someone typed are their /// data, not a display preference, and losing them on quit would read /// as a bug. fn apply_position(&mut self, entry: portfolio::Entry) -> Result<(), String> { let saved = match entry { portfolio::Entry::Set { symbol, qty, avg_price, } => { let next = Position { symbol, qty, avg_price, }; match self.positions.iter_mut().find(|p| p.symbol == next.symbol) { Some(slot) => *slot = next.clone(), None => self.positions.push(next.clone()), } self.save_edit(config::Edit::Hold(next)) } portfolio::Entry::Clear { symbol } => { self.positions.retain(|p| p.symbol != symbol); self.save_edit(config::Edit::Clear(symbol)) } }; self.portfolio_selected = self .portfolio_selected .min(self.positions.len().saturating_sub(1)); // A holding off the watchlist is polled too, and the nudge saves // the new row a full interval of waiting for its first price. self.sync_shared_symbols(); self.refresh.notify_one(); saved.map_err(|e| format!("kept for this session only: {e}")) } /// Writes one keypress's change into the config file as it is on disk /// now, under the file's lock, leaving every other section alone. Not /// the copy this session loaded: two panes on one config each wrote /// theirs back, and the later save dropped what the other had added. /// A file that does not load is left as it is (`config::update`), and /// the edit waits in `pending_edits` for the next write that lands. /// The change also lands in `self.config`, which Save in the settings /// screen starts from. fn save_edit(&mut self, edit: config::Edit) -> Result<(), String> { edit.apply(&mut self.config, &self.symbols); self.pending_edits.push(edit); let Some(path) = self.config_path.clone() else { self.pending_edits.clear(); return Ok(()); }; let live = &self.symbols; let pending = &self.pending_edits; let on_disk = config::update(&path, &self.config, |cfg| { for edit in pending { edit.apply(cfg, live); } }) .map_err(|e| format!("{e:#}"))?; self.pending_edits.clear(); // What another pane wrote is the file's now; Save must not take it // back out. self.config.watchlist = on_disk.watchlist; self.config.positions = on_disk.positions; Ok(()) } /// Writes the watchlist through after `a` or `d`, the way a holding is: /// a ticker someone added is their data, and losing it on quit read as /// a bug. A session started on tickers from the command line is left /// alone while the file holds another list, or `alphai-tui TSLA` and /// one keypress would replace the saved watchlist with two tickers. /// Save in the settings screen still keeps such a list on purpose. fn persist_watchlist(&mut self, edit: config::Edit) { if !self.watchlist_saved { self.notice = Some(( "Tickers from the command line are not saved (s, then Save, keeps them)".into(), Instant::now(), )); return; } if let Err(e) = self.save_edit(edit) { self.notice = Some(( format!("Watchlist kept for this session only: {e}"), Instant::now(), )); } } /// Adds a symbol to the live watchlist, selects it and saves the list. /// The poller reads the shared list at the top of its next cycle, and /// the nudge makes that cycle start now instead of up to one interval /// later. fn add_symbol(&mut self, symbol: &str) -> Result<(), String> { let symbol = crate::domain::normalize_symbol(symbol)?; if let Some(at) = self.symbols.iter().position(|s| s == &symbol) { // Not an error worth dwelling on: put the cursor where the // ticker already is, and say why nothing was added. self.select_symbol(at); return Err(format!("{symbol} is already on the watchlist")); } self.symbols.push(symbol.clone()); self.sync_shared_symbols(); self.select_symbol(self.symbols.len() - 1); self.refresh.notify_one(); self.persist_watchlist(config::Edit::Watch(symbol)); Ok(()) } /// Drops the selected symbol. The last one stays: every view reads /// `selected_symbol()` directly, so an empty watchlist needs empty /// states before it can be reached. fn remove_selected_symbol(&mut self) { if self.symbols.len() < 2 { return; } let gone = self.symbols.remove(self.selected); self.sync_shared_symbols(); self.persist_watchlist(config::Edit::Unwatch(gone.clone())); // Prices are cheap to fetch again; the AlphAI feeds are not, so // their cache survives a removal and a re-add costs no request. // A holding keeps its price: it is still polled, and blanking the // row would leave the portfolio view showing "…" until the tick. if self.position(&gone).is_none() { self.data.remove(&gone); self.data_window.remove(&gone); self.errors.remove(&gone); self.error_window.remove(&gone); self.price_flash.remove(&gone); self.from_cache.remove(&gone); self.quote_fetched.remove(&gone); } self.select_symbol(self.selected.min(self.symbols.len() - 1)); } /// Moves the watchlist cursor. Everything scoped to the selected /// ticker starts over, or the new ticker's feed would open at the old /// one's scroll position. fn select_symbol(&mut self, idx: usize) { self.earnings_target = None; self.selected = idx; self.news_selected = 0; self.card_scroll = 0; self.earnings_scroll = 0; } /// Every symbol the poller fetches: the watchlist plus anything held /// that is not on it. A holding with no price is a row that cannot be /// valued, so it is worth the request; the budget warning in `main` /// counts this list for the same reason. pub fn polled_symbols(&self) -> Vec { portfolio::polled_symbols(&self.symbols, &self.positions) } fn sync_shared_symbols(&mut self) { let symbols = self.polled_symbols(); self.alerts.retain_symbols(&symbols); *self.shared_symbols.write().unwrap() = symbols; } fn handle_help_key(&mut self, key: KeyEvent) -> bool { if key.code == KeyCode::Esc { self.help = HelpOverlay::default(); return false; } match self.keymap.resolve(&key) { Some(Action::Quit) => return true, Some(Action::Help) => self.help = HelpOverlay::default(), Some(Action::Up) => self.help.scroll = self.help.scroll.saturating_sub(1), // The render pass clamps the scroll to the table's real height. Some(Action::Down) => self.help.scroll = self.help.scroll.saturating_add(1), Some(Action::PageUp) => self.help.scroll = self.help.scroll.saturating_sub(10), Some(Action::PageDown) => self.help.scroll = self.help.scroll.saturating_add(10), _ => {} } false } /// Keys while the category picker is open; it swallows everything, like /// the help overlay. The picker's own keys are fixed (space, o, a, /// Enter); moving and quitting follow the keymap, and the key that /// opened the picker applies it, so pressing it twice reads as a toggle. fn handle_category_key(&mut self, key: KeyEvent) -> bool { let plain = !key .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT); let picker = &mut self.category_picker; match key.code { KeyCode::Esc => { self.category_picker = CategoryPicker::default(); return false; } KeyCode::Enter => { self.apply_categories(); return false; } KeyCode::Char(' ') if plain => { picker.draft.toggle(picker.cursor); return false; } KeyCode::Char('o') if plain => { picker.draft = Categories::only([picker.cursor]); return false; } KeyCode::Char('a') if plain => { picker.draft = Categories::default(); return false; } _ => {} } match self.keymap.resolve(&key) { Some(Action::Quit) => return true, Some(Action::Categories) => self.apply_categories(), Some(Action::Up) => picker.cursor = picker.cursor.saturating_sub(1), Some(Action::Down) => picker.cursor = (picker.cursor + 1).min(category::ALL.len() - 1), _ => {} } false } /// URL Enter opens for an article. News items default to their alphai.io /// article page (settings can flip this to the original source); insider /// filings always open the original, which points at the SEC filing. fn article_url(&self, a: &Article) -> String { let url = if self.view_id() == ui::ViewId::News && !self.config.news_open_original() { a.alphai_url().unwrap_or_else(|| a.original.url.clone()) } else { a.original.url.clone() }; with_utm(&url) } /// p / P: walk the preset list forward or back. fn cycle_theme(&mut self, dir: isize) { self.set_theme(crate::theme::step_preset(self.theme_name, dir)); } /// Switch to a named preset (the p key, the settings row). Rebuilt /// through the same path the config takes, so the `[theme]` slots the /// user spelled out keep overriding the palette; the frame style comes /// from `[ui] borders` and does not belong to the preset. Session-only /// until Save writes the name into the config. pub(crate) fn set_theme(&mut self, name: &'static str) { let panels = self.theme.panels; let mut warnings = Vec::new(); let (mut theme, name) = Theme::resolve(self.config.theme.as_ref(), Some(name), &mut warnings); theme.panels = panels; self.theme = theme; self.theme_name = name; } /// The time the Calendar reads, for its rows, its cursor and what /// Enter opens alike. A test that pins its agenda to a date freezes /// this too; otherwise the keys would act on today's agenda instead of /// the one the test built. pub(crate) fn now(&self) -> DateTime { #[cfg(test)] if let Some(now) = self.frozen_now { return now; } Utc::now() } fn switch_view(&mut self, idx: usize) { self.earnings_target = None; if idx != self.view_idx { self.view_idx = idx; self.news_selected = 0; self.card_scroll = 0; self.earnings_scroll = 0; if ui::VIEWS[idx].shows_calendar() { self.calendar_selection = ui::calendar::Selection::default(); self.calendar_state = TableState::default(); } } } fn move_calendar(&mut self, delta: isize) { let rows = ui::calendar::agenda(self, self.now()); let Some(current) = self.calendar_selection.resolve(&rows) else { return; }; let next = current.saturating_add_signed(delta).min(rows.len() - 1); self.choose_calendar_row(&rows, next); } /// Puts the calendar cursor on an agenda row, and the watchlist cursor /// on its company when it is a report date: the rail above the view /// would otherwise quote a different ticker than the row. pub(crate) fn choose_calendar_row(&mut self, rows: &[ui::calendar::AgendaRow], next: usize) { self.calendar_selection.choose(rows, next); if let Some(ui::calendar::Kind::Report { symbol }) = rows.get(next).map(|r| &r.kind) && let Some(idx) = self.symbols.iter().position(|s| s == symbol) { self.select_symbol(idx); } } fn open_calendar_row(&mut self) { let rows = ui::calendar::agenda(self, self.now()); if let Some(row) = self .calendar_selection .resolve(&rows) .map(|i| rows[i].clone()) { self.open_calendar_event(&row); } } fn open_calendar_event(&mut self, row: &ui::calendar::AgendaRow) { match ui::calendar::row_target(row) { ui::calendar::OpenTarget::Url(url) => open_url(&url), ui::calendar::OpenTarget::Earnings(symbol) => { if let Some(idx) = self.symbols.iter().position(|s| s == &symbol) { self.select_symbol(idx); } self.switch_view(ui::view_index(ui::ViewId::Earnings)); self.earnings_scroll = 0; self.earnings_target = row.report .clone() .map(|event| report_calendar::EarningsTarget { pending_read: self .earnings .get(&symbol) .and_then(|s| s.data.latest()) .cloned() .map(Box::new), symbol, event, phase: report_calendar::TargetPhase::Owner, refresh_pending: false, no_longer_listed: false, }); } ui::calendar::OpenTarget::Unavailable => { self.notice = Some(("No source link for this event".into(), Instant::now())); } } } fn handle_calendar_card_key(&mut self, key: KeyEvent) -> bool { let action = self.keymap.resolve(&key); if key.code == KeyCode::Esc || action == Some(Action::Card) { self.calendar_overlay = None; } else if action == Some(Action::Quit) { return true; } else if action == Some(Action::Open) { let card = self.calendar_overlay.take().unwrap(); self.open_calendar_event(&card.row); } else if let Some(card) = &mut self.calendar_overlay { match action { Some(Action::Up) => card.scroll = card.scroll.saturating_sub(1), Some(Action::Down) => card.scroll = card.scroll.saturating_add(1), Some(Action::PageUp) => card.scroll = card.scroll.saturating_sub(10), Some(Action::PageDown) => card.scroll = card.scroll.saturating_add(10), _ => {} } } false } /// t / T: jump to the next/previous range/interval preset and wake the /// price poller. Only `refresh.notify_one()` here: `manual_refresh()` /// would also drop the visible AlphAI bundle and burn a request from /// its budget for what is purely a price-history change. fn cycle_range(&mut self, dir: isize) { let (range, interval) = next_preset(&self.chart.presets, (self.range, self.interval), dir); self.range = range; self.interval = interval; self.push_params(); } /// Why `E` cannot change the chart in front of the reader, if it /// cannot. The switch still flips (it is one setting for the session, /// and the next ticker or preset may use it), but a key that does /// nothing visible has to say why. pub fn extended_unavailable(&self) -> Option { let symbol = self.selected_symbol(); if !self.source_extended { return Some(format!( "{} has no candle history, so there are no pre or post market candles to draw", self.source_name )); } if self.interval == Interval::D1 { return Some( "Daily candles cover the regular session only, pick an intraday preset (t)".into(), ); } if crate::market::is_crypto(symbol) { return Some(format!( "{symbol} trades around the clock, it has no pre or post market" )); } if !crate::market::is_us_equity(symbol) { return Some(format!( "Pre and post market candles are drawn for US listings only, not {symbol}" )); } None } /// E: draw the pre and post market candles, or stop. Same shape as the /// preset cycle, and the same reason for only nudging the price /// poller: nothing about the AlphAI feeds changes. /// /// Switching off needs no fetch to look right: dropping the extended /// candles is all a regular-hours answer would change, so the rows are /// trimmed and retagged here and the chart follows the key at once. /// The trim goes by the interval the rows were fetched at, which is not /// the current one while a preset change is still pending. fn toggle_sessions(&mut self) { let before = self.window(); self.sessions = self.sessions.toggled(); if self.sessions == Sessions::Extended && let Some(why) = self.extended_unavailable() { self.notice = Some((why, Instant::now())); } if self.sessions == Sessions::Regular { for (symbol, data) in &mut self.data { let window = self.data_window.entry(symbol.clone()).or_insert(before); if sessions_matter(symbol, window.1) { data.candles.retain(|c| { crate::market::window_at(c.ts) .is_some_and(|w| w.session == crate::market::Session::Open) }); } window.2 = Sessions::Regular; } } self.push_params(); } fn push_params(&mut self) { *self.params.write().unwrap() = (self.range, self.interval, self.sessions); self.refresh.notify_one(); } } /// Whether the watchlist a session starts on is the one the file keeps: it /// is, unless tickers on the command line replaced a saved list. With no /// saved list there is nothing to overwrite, and the first `a` saves the /// list on screen. pub(crate) fn watchlist_is_saved(saved: &[String], symbols: &[String]) -> bool { saved.is_empty() || saved .iter() .map(|s| s.trim().to_uppercase()) .eq(symbols.iter().cloned()) } /// Whether a ticker's candles differ between regular and extended hours. /// Only a US listing's intraday bars do: crypto trades around the clock, /// and a daily bar is the regular session either way (the same test /// `source::normalize_sessions` makes). fn sessions_matter(symbol: &str, interval: Interval) -> bool { interval != Interval::D1 && crate::market::is_us_equity(symbol) } /// Tag an outgoing article link with this client as the traffic source, so /// alphai.io and original publishers can attribute the referral. Left as-is /// when the URL already carries a utm_source (never clobber the feed's own /// attribution); the fragment, if any, stays at the end where it belongs. fn with_utm(url: &str) -> String { if url.contains("utm_source=") { return url.to_string(); } let (base, frag) = match url.split_once('#') { Some((base, frag)) => (base, Some(frag)), None => (url, None), }; let sep = if base.contains('?') { '&' } else { '?' }; match frag { Some(frag) => format!("{base}{sep}utm_source=alphai-tui#{frag}"), None => format!("{base}{sep}utm_source=alphai-tui"), } } /// Open a URL with the platform handler; failures are ignored (worst case the /// article just does not open — never crash the TUI over it). Tests that press /// an open key must not launch the real browser. pub fn open_url(url: &str) { if cfg!(test) || !(url.starts_with("https://") || url.starts_with("http://")) { return; } #[cfg(target_os = "macos")] let mut cmd = std::process::Command::new("open"); #[cfg(all(unix, not(target_os = "macos")))] let mut cmd = std::process::Command::new("xdg-open"); #[cfg(target_os = "windows")] let mut cmd = { let mut c = std::process::Command::new("cmd"); c.args(["/C", "start", ""]); c }; let _ = cmd .arg(url) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn(); } #[cfg(test)] mod tests { use super::{RANGE_PRESETS, next_preset, with_utm}; use crate::domain::{Interval, Range}; #[test] fn range_presets_wrap_both_ways() { let first = RANGE_PRESETS[0]; let last = RANGE_PRESETS[RANGE_PRESETS.len() - 1]; assert_eq!(next_preset(&RANGE_PRESETS, first, 1), RANGE_PRESETS[1]); assert_eq!(next_preset(&RANGE_PRESETS, last, 1), first); assert_eq!(next_preset(&RANGE_PRESETS, first, -1), last); } #[test] fn utm_tag_appended_to_plain_and_query_urls() { assert_eq!( with_utm("https://alphai.io/news/article/07-10/abc/slug"), "https://alphai.io/news/article/07-10/abc/slug?utm_source=alphai-tui" ); assert_eq!( with_utm("https://example.com/story?id=7"), "https://example.com/story?id=7&utm_source=alphai-tui" ); } #[test] fn utm_tag_respects_existing_source_and_fragment() { // A feed URL that already attributes its source is left untouched. let tagged = "https://example.com/story?utm_source=newsletter"; assert_eq!(with_utm(tagged), tagged); // The fragment stays terminal, the query lands before it. assert_eq!( with_utm("https://example.com/story#section"), "https://example.com/story?utm_source=alphai-tui#section" ); } #[test] fn range_presets_absorb_unknown_startup_combo() { // A CLI combo outside the table joins the cycle at the nearest edge. let odd = (Range::Mo3, Interval::M5); assert_eq!(next_preset(&RANGE_PRESETS, odd, 1), RANGE_PRESETS[0]); assert_eq!( next_preset(&RANGE_PRESETS, odd, -1), RANGE_PRESETS[RANGE_PRESETS.len() - 1] ); } }