src/app.rs
Rama main · réplica de lectura del repositorio original.
//! 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<String>,
}
/// 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> {
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<u32> {
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<alphai::CalendarEvent>,
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<String>,
/// Validated `[[positions]]`; empty when the section is absent.
pub positions: Vec<Position>,
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<SourceEvent>,
pub refresh: Arc<Notify>,
pub alphai_tx: UnboundedSender<alphai::Cmd>,
pub config: Config,
/// Where Save writes the config back; None disables persisting.
pub config_path: Option<PathBuf>,
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<String>,
/// 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<String>,
/// What the user holds, in config order. Off-watchlist holdings are
/// polled too (`polled_symbols`), so every row can be valued.
pub positions: Vec<Position>,
pub data: HashMap<String, TickerData>,
pub alerts: crate::alerts::Alerts,
pub errors: HashMap<String, String>,
/// 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<String, (Range, Interval, Sessions)>,
/// 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<String, (Instant, bool)>,
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<String, i64>,
/// Last successful fetch per symbol, for labelling retained prices
/// when a fallback changes the source before new quotes arrive.
quote_fetched: HashMap<String, i64>,
/// 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<Instant>,
/// 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<DateTime<Utc>>,
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<String, (Range, Interval, Sessions)>,
pub last_update: Option<DateTime<Local>>,
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<String, FeedBundle>,
/// 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<String, HashSet<String>>,
pub alphai_errors: HashMap<String, String>,
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<String>,
/// 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<String, EarningsSlot>,
/// 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<CalendarSlot>,
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<report_calendar::EarningsTarget>,
pub(crate) calendar_filter: ui::calendar::Filter,
pub(crate) calendar_overlay: Option<ui::calendar::CalendarOverlay>,
/// 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<PathBuf>,
/// `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<config::Edit>,
/// 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<String>,
alphai_tx: UnboundedSender<alphai::Cmd>,
rx: UnboundedReceiver<SourceEvent>,
refresh: Arc<Notify>,
}
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<String> {
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<bool> {
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<dyn crate::source::DataSource>,
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<dyn crate::source::DataSource> {
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<dyn crate::source::DataSource>) {
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<Arc<dyn crate::source::DataSource>> {
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<String> {
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<Utc> {
#[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<String> {
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]
);
}
}