From 1fe5673de157ab39dfd8a064348dfe2a275a05cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 00:10:21 -0400 Subject: [PATCH] T16 round 2 fixes: synced charts move their shared window once per append, Fibonacci labels sit in a box clipped to the price pane and flip below near its top, and prices show every place their tick needs --- src/finance/labels.rs | 49 +++++++++++++++++++++++--------- src/finance/market.rs | 23 ++++++++++----- src/finance/mod.rs | 2 ++ src/finance/stage.rs | 19 +++++++++---- src/finance/tests.rs | 66 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 src/finance/tests.rs diff --git a/src/finance/labels.rs b/src/finance/labels.rs index a66233c..5bf6270 100644 --- a/src/finance/labels.rs +++ b/src/finance/labels.rs @@ -1,6 +1,9 @@ use std::ops::Range; -use gpui::{App, Div, FontWeight, Hsla, ParentElement, Pixels, SharedString, Styled, Window, div}; +use gpui::{ + App, Div, FontWeight, Hsla, ParentElement, Pixels, SharedString, Styled, Window, div, + prelude::*, +}; use jiff::tz::TimeZone; use super::{ @@ -12,7 +15,7 @@ use super::{ use crate::{ charts::{Linear, Rect, tint}, theme::{ActiveTheme, Radius, TextSize}, - typography::{format, tabular}, + typography::{LEADING, format, tabular}, }; /// Where a market chart's words go: its panes and their scales, what is in view, the candle and row under the pointer, the zone its times read in, its title, and a compared symbol's name. @@ -220,6 +223,8 @@ pub(crate) fn labels( }))); } } + let tall = f32::from(small.to_pixels(rem)) * LEADING; + let mut levels = Vec::new(); for drawing in &axes.drawings { let Drawing::Fib(from, to) = *drawing else { continue; @@ -228,30 +233,48 @@ pub(crate) fn labels( if x > main.x + main.w { continue; } - let x = x.max(main.x); - let inside = |y: f32| y >= main.y && y <= main.y + main.h; - for (share, value) in Drawing::levels(from, to) - .into_iter() - .filter(|(_, value)| inside(price.at(*value))) - { + let left = (x - main.x).max(0.0); + for (share, value) in Drawing::levels(from, to) { + let y = price.at(value) - main.y; + if !(0.0..=main.h).contains(&y) { + continue; + } let words = format!( "{} {}", format::percent(share, 1, false), format::number(value, digits, format::Separators::EN) ); - out.push( + let above = y >= tall; + levels.push( div() .absolute() - .left(Pixels::from(x)) - .top(Pixels::from(price.at(value))) + .left(Pixels::from(left)) + .top(Pixels::from(y)) .h_0() .flex() - .items_end() .pl_1() - .child(tabular(text(words, colors.fg_muted)).pb_0p5()), + .when(above, |label| label.items_end()) + .when(!above, |label| label.items_start()) + .child( + tabular(text(words, colors.fg_muted)) + .when(above, |words| words.pb_0p5()) + .when(!above, |words| words.pt_0p5()), + ), ); } } + if !levels.is_empty() { + out.push( + div() + .absolute() + .left(Pixels::from(main.x)) + .top(Pixels::from(main.y)) + .w(Pixels::from(main.w)) + .h(Pixels::from(main.h)) + .overflow_hidden() + .children(levels), + ); + } let pill = |y: f32, words: String, (fill, ink): (Hsla, Hsla)| { let body = tabular(div()) .px_1p5() diff --git a/src/finance/market.rs b/src/finance/market.rs index 5557a8d..669e90c 100644 --- a/src/finance/market.rs +++ b/src/finance/market.rs @@ -40,6 +40,8 @@ pub enum ChartKind { pub struct ChartSync { pub(crate) visible: Option, pub(crate) hover: Option, + /// How many candles the shared window last moved for, so one append moves it once. + pub(crate) seen: usize, } /// A market's prices over time as candles, bars or a line, on a price scale at the right. Drag or scroll sideways to move through time, hold Cmd and scroll to zoom, and double-press to return to the newest; the crosshair reads each candle. @@ -232,7 +234,10 @@ impl RenderOnce for CandlestickChart { Some(sync) => (sync.read(cx).visible, sync.read(cx).hover), None => (stage.read(cx).visible, stage.read(cx).hover), }; - let seen = stage.read(cx).seen; + let seen = match &self.sync { + Some(sync) => sync.read(cx).seen, + None => stage.read(cx).seen, + }; let visible = match held.0 { Some(visible) if total > seen @@ -246,12 +251,16 @@ impl RenderOnce for CandlestickChart { }; if seen != total { log::debug!("market chart: {seen} candles to {total}"); - stage.update(cx, |stage, _| stage.seen = total); - if held.0.is_some() { - match &self.sync { - Some(sync) => sync.update(cx, |sync, _| sync.visible = Some(visible)), - None => stage.update(cx, |stage, _| stage.visible = Some(visible)), - } + let kept = held.0.map(|_| visible); + match &self.sync { + Some(sync) => sync.update(cx, |sync, _| { + sync.seen = total; + sync.visible = kept.or(sync.visible); + }), + None => stage.update(cx, |stage, _| { + stage.seen = total; + stage.visible = kept.or(stage.visible); + }), } } let range = visible.range(total); diff --git a/src/finance/mod.rs b/src/finance/mod.rs index 05c82ed..aa4c1a0 100644 --- a/src/finance/mod.rs +++ b/src/finance/mod.rs @@ -22,6 +22,8 @@ mod stage; mod steer; mod symbols; mod tape; +#[cfg(all(test, feature = "test-support"))] +mod tests; mod tools; mod trade; mod wallet; diff --git a/src/finance/stage.rs b/src/finance/stage.rs index ace0e57..8199951 100644 --- a/src/finance/stage.rs +++ b/src/finance/stage.rs @@ -104,12 +104,15 @@ pub(crate) fn fit(low: f64, high: f64) -> ((f64, f64), Vec) { ((low, high), inside) } -/// Places after the point that ticks `step` apart need: none for whole steps, two for cents. +/// Places after the point that show every multiple of `step` exactly: none for whole steps, four for 0.0025. pub(crate) fn decimals(step: f64) -> usize { - if step >= 1.0 || step <= 0.0 { - return 0; - } - (-step.log10() - 1e-9).ceil().max(0.0) as usize + assert!( + step.is_finite() && step > 0.0, + "a step is positive, not {step}" + ); + let text = format!("{step:.10}"); + let fraction = text.split_once('.').map_or("", |(_, fraction)| fraction); + fraction.trim_end_matches('0').len() } #[cfg(test)] @@ -198,5 +201,11 @@ mod tests { ), (0, 1, 2, 3) ); + assert_eq!(decimals(0.0025), 4, "a quarter of a cent needs four places"); + assert_eq!( + decimals(110.2 - 110.0), + 1, + "float noise from a difference drops away" + ); } } diff --git a/src/finance/tests.rs b/src/finance/tests.rs new file mode 100644 index 0000000..71588b4 --- /dev/null +++ b/src/finance/tests.rs @@ -0,0 +1,66 @@ +use std::rc::Rc; + +use gpui::{ + AppContext as _, Context, Entity, IntoElement, ParentElement, Render, Styled, TestAppContext, + Window, div, px, +}; +use jiff::Timestamp; + +use crate::finance::{CandlestickChart, ChartSync, candles::Candle, stage::Visible}; +use crate::theme::Theme; + +fn candles(count: usize) -> Rc> { + Rc::new( + (0..count) + .map(|ix| { + let time = Timestamp::from_second(ix as i64 * 86_400).expect("a day"); + Candle::new(time, (10.0, 11.0, 9.0, 10.0 + ix as f64 * 0.01), 100.0) + }) + .collect(), + ) +} + +struct Pair { + candles: Rc>, + sync: Entity, +} + +impl Render for Pair { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(800.)) + .child(CandlestickChart::new("first", self.candles.clone()).sync(&self.sync)) + .child(CandlestickChart::new("second", self.candles.clone()).sync(&self.sync)) + } +} + +#[gpui::test] +fn synced_charts_move_their_shared_window_once_per_append(cx: &mut TestAppContext) { + cx.update(Theme::init); + let sync = cx.new(|_| ChartSync::default()); + let shared = sync.clone(); + let (view, cx) = cx.add_window_view(|_, _| Pair { + candles: candles(40), + sync: shared, + }); + cx.run_until_parked(); + sync.update(cx, |sync, cx| { + sync.visible = Some(Visible { + start: 20.0, + count: 20.0, + }); + cx.notify(); + }); + view.update(cx, |_, cx| cx.notify()); + cx.run_until_parked(); + view.update(cx, |pair, cx| { + pair.candles = candles(45); + cx.notify(); + }); + cx.run_until_parked(); + let start = sync.read_with(cx, |sync, _| sync.visible.expect("held").start); + assert_eq!( + start, 25.0, + "five new candles move the shared window five, not ten" + ); +}