use gpui::{ App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, relative, }; use super::quotes::{PriceChangeBadge, QuoteCard, price}; use crate::{ charts::tint, theme::{ActiveTheme, TextSize}, typography::tabular, }; /// Where a market's day stands: before hours, open, after hours, or closed. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Session { Before, Open, After, Closed, } /// Whether a market trades now, with a dot, and when that next changes. #[derive(IntoElement)] pub struct MarketStatus { market: SharedString, session: Session, next: Option, } impl MarketStatus { pub fn new(market: impl Into, session: Session) -> Self { Self { market: market.into(), session, next: None, } } /// When the session changes, such as "Closes in 2 h 14 min". pub fn next(mut self, next: impl Into) -> Self { self.next = Some(next.into()); self } } impl RenderOnce for MarketStatus { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = &theme.colors; let (words, dot) = match self.session { Session::Before => ("Before hours", colors.warning), Session::Open => ("Open", colors.success), Session::After => ("After hours", colors.warning), Session::Closed => ("Closed", colors.fg_subtle), }; div() .flex() .items_center() .gap_2() .px_2p5() .py_1() .rounded_full() .border_1() .border_color(colors.border) .text_size(theme.text_size(TextSize::Sm)) .child(div().size(theme.status_dot()).rounded_full().bg(dot)) .child( div() .text_color(colors.fg) .child(format!("{} ยท {words}", self.market)), ) .children( self.next .map(|next| div().text_color(colors.fg_muted).child(next)), ) } } /// The world's trading day on one scale: each market's hours as a band across a day in UTC, those open now lit, and a line at now. #[derive(IntoElement)] pub struct TradingSessionClock { markets: Vec<(SharedString, f32, f32)>, now: f32, } impl TradingSessionClock { /// `now` in hours since midnight UTC. pub fn new(now: f32) -> Self { assert!((0.0..24.0).contains(&now), "now is an hour of the day"); Self { markets: Vec::new(), now, } } /// A market and its hours in UTC; a session past midnight runs from `open` to `close` the next day. pub fn market(mut self, name: impl Into, open: f32, close: f32) -> Self { assert!( (0.0..24.0).contains(&open) && (0.0..=24.0).contains(&close), "hours run from 0 to 24" ); self.markets.push((name.into(), open, close)); self } } /// A session's bands across the day, split at midnight when it runs past it, as shares of the day. pub(crate) fn bands(open: f32, close: f32) -> Vec<(f32, f32)> { if open <= close { vec![(open / 24.0, close / 24.0)] } else { vec![(open / 24.0, 1.0), (0.0, close / 24.0)] } } impl RenderOnce for TradingSessionClock { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors.clone(); let now = self.now / 24.0; let rows = self .markets .iter() .enumerate() .map(|(ix, (name, open, close))| { let spans = bands(*open, *close); let lit = spans.iter().any(|(from, to)| (*from..*to).contains(&now)); let ink = tint(&colors, ix); let track = div() .relative() .flex_1() .h(theme.meter_track() * 2.0) .rounded_full() .bg(colors.hover) .children(spans.into_iter().map(|(from, to)| { div() .absolute() .top_0() .bottom_0() .left(relative(from)) .w(relative(to - from)) .rounded_full() .bg(if lit { ink } else { ink.opacity(0.35) }) })); div() .flex() .items_center() .gap_3() .child( div() .w(theme.label_width() * 0.6) .text_size(theme.text_size(TextSize::Sm)) .text_color(if lit { colors.fg } else { colors.fg_muted }) .child(name.clone()), ) .child(track) }); let hours = [0, 6, 12, 18, 24].map(|hour| { let at = hour as f32 / 24.0; div() .absolute() .top_0() .left(relative(at)) .w_0() .flex() .justify_center() .child(tabular(div()).child(format!("{hour:02}:00"))) }); let line = div() .absolute() .top_0() .bottom_0() .left(relative(now)) .w(theme.chart().hairline) .bg(colors.fg); div() .flex() .flex_col() .gap_2() .child( div() .relative() .flex() .flex_col() .gap_2() .children(rows) .child( div() .absolute() .top_0() .bottom_0() .left(theme.label_width() * 0.6) .right_0() .ml_3() .child(line), ), ) .child( div() .flex() .gap_3() .child(div().w(theme.label_width() * 0.6)) .child( div() .relative() .flex_1() .h(theme.text_size(TextSize::Xs) * 1.5) .text_size(theme.text_size(TextSize::Xs)) .text_color(colors.fg_subtle) .children(hours), ), ) } } /// A market at a glance: its session, its indexes as cards in a row, and the day's movers up and down. #[derive(IntoElement)] pub struct MarketOverview { id: ElementId, status: Option, indexes: Vec<(SharedString, SharedString, f64, f64, Vec)>, movers: Vec<(SharedString, f64, f64)>, red_up: bool, } impl MarketOverview { pub fn new(id: impl Into) -> Self { Self { id: id.into(), status: None, indexes: Vec::new(), movers: Vec::new(), red_up: false, } } pub fn status(mut self, status: MarketStatus) -> Self { self.status = Some(status); self } /// An index: its symbol, name, last value, last close, and today's values. pub fn index( mut self, (symbol, name): (impl Into, impl Into), (last, close): (f64, f64), day: impl IntoIterator, ) -> Self { self.indexes.push(( symbol.into(), name.into(), last, close, day.into_iter().collect(), )); self } /// A symbol that moved today: its last price and the last close. pub fn mover(mut self, symbol: impl Into, last: f64, close: f64) -> Self { self.movers.push((symbol.into(), last, close)); self } pub fn red_up(mut self) -> Self { self.red_up = true; self } } impl RenderOnce for MarketOverview { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors.clone(); let red_up = self.red_up; let cards = self.indexes .into_iter() .enumerate() .map(|(ix, (symbol, name, last, close, day))| { let card = QuoteCard::new( (self.id.clone(), format!("index-{ix}")), (symbol, name), (last, close), day, ); div() .flex_1() .min_w_0() .child(if red_up { card.red_up() } else { card }) }); let mut movers = self.movers; movers.sort_by(|a, b| (b.1 / b.2).total_cmp(&(a.1 / a.2))); let column = |title: &'static str, list: Vec<(SharedString, f64, f64)>| { div() .flex_1() .flex() .flex_col() .gap_1() .child( div() .text_size(theme.text_size(TextSize::Xs)) .text_color(colors.fg_muted) .child(title), ) .children(list.into_iter().map(|(symbol, last, close)| { let badge = if red_up { PriceChangeBadge::new(close, last).red_up() } else { PriceChangeBadge::new(close, last) }; div() .flex() .items_center() .justify_between() .py_1() .text_size(theme.text_size(TextSize::Sm)) .child( div() .font_weight(FontWeight::MEDIUM) .text_color(colors.fg) .child(symbol), ) .child( div() .flex() .items_center() .gap_3() .child( tabular(div()) .text_color(colors.fg_muted) .child(price(last, 2)), ) .child(badge), ) })) }; let half = movers.len().div_ceil(2); let losers: Vec<_> = movers.split_off(half).into_iter().rev().collect(); div() .flex() .flex_col() .gap_4() .children(self.status) .child(div().flex().gap_4().children(cards)) .child( div() .flex() .gap_8() .child(column("Up the most", movers)) .child(column("Down the most", losers)), ) } } #[cfg(test)] mod tests { use super::bands; #[test] fn sessions_split_at_midnight() { assert_eq!(bands(8.0, 16.5), [(8.0 / 24.0, 16.5 / 24.0)]); assert_eq!( bands(22.0, 6.0), [(22.0 / 24.0, 1.0), (0.0, 0.25)], "a session past midnight takes two bands" ); } }