T16 Finance: market charts with kinds, volume, profile, studies, drawings, compare, alerts and sync (T16a–b); quotes, book, tape and DOM (T16c); order entry, blotter, risk, options and payoff (T16d); markets, calendars, portfolio and wallet (T16e); typography::Ellipsis ends lines that gpui's truncate clipped; chart notes stack and ticks label round values
This commit is contained in:
76 files changed
+10311
-173
No files matched your search
@@ -0,0 +1,405 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user