406 lines
12 KiB
Rust
406 lines
12 KiB
Rust
use gpui::{
|
|
App, ElementId, FontWeight, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
|
Window, div, relative,
|
|
};
|
|
|
|
use crate::{
|
|
data_display::{Badge, Sparkline, Tone},
|
|
motion::{Flash, Marquee},
|
|
primitives::{Icon, IconName},
|
|
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
|
typography::{format, tabular},
|
|
};
|
|
|
|
/// The rise and fall colors, green up unless red leads.
|
|
pub(crate) fn moves(red_up: bool, cx: &App) -> (Hsla, Hsla) {
|
|
let colors = &cx.theme().colors;
|
|
if red_up {
|
|
(colors.danger, colors.success)
|
|
} else {
|
|
(colors.success, colors.danger)
|
|
}
|
|
}
|
|
|
|
/// A price as it reads: grouped thousands and its places.
|
|
pub(crate) fn price(value: f64, places: usize) -> String {
|
|
format::number(value, places, format::Separators::EN)
|
|
}
|
|
|
|
/// A change and its share as a signed pair, such as "+1.24 (+0.66%)".
|
|
pub(crate) fn change(amount: f64, share: f64, places: usize) -> String {
|
|
let sign = if amount > 0.0 { "+" } else { "" };
|
|
format!(
|
|
"{sign}{} ({})",
|
|
price(amount, places),
|
|
format::percent(share, 2, true)
|
|
)
|
|
}
|
|
|
|
/// A price read to its places that tints green or red for a moment each time it moves, with an arrow the way it last went.
|
|
#[derive(IntoElement)]
|
|
pub struct PriceText {
|
|
id: ElementId,
|
|
value: f64,
|
|
before: Option<f64>,
|
|
places: usize,
|
|
size: TextSize,
|
|
red_up: bool,
|
|
}
|
|
|
|
impl PriceText {
|
|
pub fn new(id: impl Into<ElementId>, value: f64) -> Self {
|
|
assert!(value.is_finite(), "a price needs a finite value");
|
|
Self {
|
|
id: id.into(),
|
|
value,
|
|
before: None,
|
|
places: 2,
|
|
size: TextSize::Base,
|
|
red_up: false,
|
|
}
|
|
}
|
|
|
|
/// The price it moved from, which sets its arrow and tint.
|
|
pub fn from(mut self, before: f64) -> Self {
|
|
self.before = Some(before);
|
|
self
|
|
}
|
|
|
|
pub fn places(mut self, places: usize) -> Self {
|
|
self.places = places;
|
|
self
|
|
}
|
|
|
|
pub fn size(mut self, size: TextSize) -> Self {
|
|
self.size = size;
|
|
self
|
|
}
|
|
|
|
pub fn red_up(mut self) -> Self {
|
|
self.red_up = true;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for PriceText {
|
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let (rise, fall) = moves(self.red_up, cx);
|
|
let theme = cx.theme();
|
|
let way = self.before.map(|before| self.value.total_cmp(&before));
|
|
let ink = match way {
|
|
Some(std::cmp::Ordering::Greater) => rise,
|
|
Some(std::cmp::Ordering::Less) => fall,
|
|
_ => theme.colors.fg,
|
|
};
|
|
let arrow = way.filter(|way| way.is_ne()).map(|way| {
|
|
let icon = if way.is_gt() {
|
|
IconName::ArrowUp
|
|
} else {
|
|
IconName::ArrowDown
|
|
};
|
|
Icon::new(icon).size(IconSize::Xs).color(ink)
|
|
});
|
|
Flash::new(self.id, self.value.to_bits())
|
|
.tint(ink.opacity(0.18))
|
|
.flex()
|
|
.items_center()
|
|
.gap_1()
|
|
.px_1()
|
|
.rounded(theme.radius(Radius::Sm))
|
|
.children(arrow)
|
|
.child(
|
|
tabular(div())
|
|
.text_size(theme.text_size(self.size))
|
|
.text_color(ink)
|
|
.child(price(self.value, self.places)),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// A move since a reference, its amount and share, tinted by which way it went.
|
|
#[derive(IntoElement)]
|
|
pub struct PriceChangeBadge {
|
|
amount: f64,
|
|
share: f64,
|
|
places: usize,
|
|
red_up: bool,
|
|
}
|
|
|
|
impl PriceChangeBadge {
|
|
/// The move from `before` to `now`.
|
|
pub fn new(before: f64, now: f64) -> Self {
|
|
assert!(
|
|
before.is_finite() && now.is_finite() && before != 0.0,
|
|
"a change needs finite prices and a start other than zero"
|
|
);
|
|
Self {
|
|
amount: now - before,
|
|
share: (now - before) / before,
|
|
places: 2,
|
|
red_up: false,
|
|
}
|
|
}
|
|
|
|
pub fn places(mut self, places: usize) -> Self {
|
|
self.places = places;
|
|
self
|
|
}
|
|
|
|
pub fn red_up(mut self) -> Self {
|
|
self.red_up = true;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for PriceChangeBadge {
|
|
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
|
let tone = match (self.amount.total_cmp(&0.0), self.red_up) {
|
|
(std::cmp::Ordering::Equal, _) => Tone::Neutral,
|
|
(std::cmp::Ordering::Greater, false) | (std::cmp::Ordering::Less, true) => {
|
|
Tone::Success
|
|
}
|
|
_ => Tone::Danger,
|
|
};
|
|
Badge::new(change(self.amount, self.share, self.places)).tone(tone)
|
|
}
|
|
}
|
|
|
|
/// A symbol at a glance: its name, price and move since the last close, the day's line, and where the price sits in the day's range.
|
|
#[derive(IntoElement)]
|
|
pub struct QuoteCard {
|
|
id: ElementId,
|
|
symbol: SharedString,
|
|
name: SharedString,
|
|
now: f64,
|
|
close: f64,
|
|
day: Vec<f32>,
|
|
red_up: bool,
|
|
}
|
|
|
|
impl QuoteCard {
|
|
/// `close` is the last session's; `day` the prices since, oldest first.
|
|
pub fn new(
|
|
id: impl Into<ElementId>,
|
|
(symbol, name): (impl Into<SharedString>, impl Into<SharedString>),
|
|
(now, close): (f64, f64),
|
|
day: impl IntoIterator<Item = f32>,
|
|
) -> Self {
|
|
let day: Vec<f32> = day.into_iter().collect();
|
|
assert!(day.len() >= 2, "a day's line needs two prices");
|
|
Self {
|
|
id: id.into(),
|
|
symbol: symbol.into(),
|
|
name: name.into(),
|
|
now,
|
|
close,
|
|
day,
|
|
red_up: false,
|
|
}
|
|
}
|
|
|
|
pub fn red_up(mut self) -> Self {
|
|
self.red_up = true;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for QuoteCard {
|
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let (rise, fall) = moves(self.red_up, cx);
|
|
let theme = cx.theme();
|
|
let colors = &theme.colors;
|
|
let (low, high) = self
|
|
.day
|
|
.iter()
|
|
.fold((f32::MAX, f32::MIN), |(low, high), value| {
|
|
(low.min(*value), high.max(*value))
|
|
});
|
|
let at = if high > low {
|
|
(self.now as f32 - low) / (high - low)
|
|
} else {
|
|
0.5
|
|
};
|
|
let ink = if self.now >= self.close { rise } else { fall };
|
|
let badge = if self.red_up {
|
|
PriceChangeBadge::new(self.close, self.now).red_up()
|
|
} else {
|
|
PriceChangeBadge::new(self.close, self.now)
|
|
};
|
|
let quote = if self.red_up {
|
|
PriceText::new(self.id.clone(), self.now).red_up()
|
|
} else {
|
|
PriceText::new(self.id.clone(), self.now)
|
|
};
|
|
let small = |words: String| {
|
|
tabular(div())
|
|
.text_size(theme.text_size(TextSize::Xs))
|
|
.text_color(colors.fg_subtle)
|
|
.child(words)
|
|
};
|
|
let track = div()
|
|
.relative()
|
|
.flex_1()
|
|
.h(theme.meter_track())
|
|
.rounded_full()
|
|
.bg(colors.border)
|
|
.child(
|
|
div()
|
|
.absolute()
|
|
.top_0()
|
|
.bottom_0()
|
|
.left(relative(at.clamp(0.0, 1.0)))
|
|
.w(theme.meter_track())
|
|
.rounded_full()
|
|
.bg(colors.fg),
|
|
);
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_3()
|
|
.p_4()
|
|
.rounded(theme.radius(Radius::Lg))
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.bg(colors.surface)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.items_start()
|
|
.justify_between()
|
|
.gap_4()
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.child(
|
|
div()
|
|
.text_size(theme.text_size(TextSize::Md))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.text_color(colors.fg)
|
|
.child(self.symbol),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(theme.text_size(TextSize::Sm))
|
|
.text_color(colors.fg_muted)
|
|
.child(self.name),
|
|
),
|
|
)
|
|
.child(badge),
|
|
)
|
|
.child(quote.size(TextSize::Xl).from(self.close))
|
|
.child(
|
|
Sparkline::new(self.day)
|
|
.area()
|
|
.color(ink)
|
|
.w_full()
|
|
.h(theme.chart().height * 0.2),
|
|
)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.child(small(price(f64::from(low), 2)))
|
|
.child(track)
|
|
.child(small(price(f64::from(high), 2))),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Quotes running past in a band, each symbol with its price and move.
|
|
#[derive(IntoElement)]
|
|
pub struct TickerTape {
|
|
id: ElementId,
|
|
quotes: Vec<(SharedString, f64, f64)>,
|
|
red_up: bool,
|
|
}
|
|
|
|
impl TickerTape {
|
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
quotes: Vec::new(),
|
|
red_up: false,
|
|
}
|
|
}
|
|
|
|
/// A symbol, its price, and its move as a share since the last close.
|
|
pub fn quote(mut self, symbol: impl Into<SharedString>, now: f64, share: f64) -> Self {
|
|
self.quotes.push((symbol.into(), now, share));
|
|
self
|
|
}
|
|
|
|
pub fn red_up(mut self) -> Self {
|
|
self.red_up = true;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for TickerTape {
|
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let (rise, fall) = moves(self.red_up, cx);
|
|
let theme = cx.theme();
|
|
let (colors, size) = (theme.colors.clone(), theme.text_size(TextSize::Sm));
|
|
let quotes = self.quotes;
|
|
let tape = Marquee::new(self.id, move || {
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.children(quotes.iter().map(|(symbol, now, share)| {
|
|
let ink = if *share >= 0.0 { rise } else { fall };
|
|
let icon = if *share >= 0.0 {
|
|
IconName::TrendingUp
|
|
} else {
|
|
IconName::TrendingDown
|
|
};
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.px_4()
|
|
.text_size(size)
|
|
.child(
|
|
div()
|
|
.font_weight(FontWeight::MEDIUM)
|
|
.text_color(colors.fg)
|
|
.child(symbol.clone()),
|
|
)
|
|
.child(
|
|
tabular(div())
|
|
.text_color(colors.fg_muted)
|
|
.child(price(*now, 2)),
|
|
)
|
|
.child(Icon::new(icon).size(IconSize::Xs).color(ink))
|
|
.child(
|
|
tabular(div())
|
|
.text_color(ink)
|
|
.child(format::percent(*share, 2, true)),
|
|
)
|
|
}))
|
|
});
|
|
div()
|
|
.py_2()
|
|
.border_t_1()
|
|
.border_b_1()
|
|
.border_color(theme.colors.border)
|
|
.child(tape)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{change, price};
|
|
|
|
#[test]
|
|
fn prices_and_changes_read_signed() {
|
|
assert_eq!(price(1234.5, 2), "1,234.50");
|
|
assert_eq!(change(1.24, 0.0066, 2), "+1.24 (+0.66%)");
|
|
assert_eq!(
|
|
change(-0.5, -0.01, 2),
|
|
"\u{2212}0.50 (\u{2212}1.00%)",
|
|
"a true minus"
|
|
);
|
|
}
|
|
}
|