From bfd8ebfbc8fa3c5c63fbf43d0a2893f6c345c248 Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:40:11 +0200 Subject: [PATCH] perf(chart): skip hidden chart work and offscreen append damage With many charts open, every chart paid for every tick: a chart in a background tab or a hidden dock still prepared frames, woke its axis observers and re-folded its whole retained volume timeframe after each candle patch, and each tick appended off screen still marked the cached bake dirty. Hidden or non-presentable charts now skip frame preparation and axis notifications, and the dock host's visibility reaches the stacks attached to it (detached windows keep their own). Revealing an Add or Custom stack restores only the tiles its virtual list shows. The volume timeframe scan is bounded to the patched suffix, an offscreen append marks damage only when new or evicted rows reach the cached cross or volume bake, and the pending append queue is bounded to its newest rows. A visible chart draws the same pixels as before. The candle tail update per tick drops from about 22 us to about 0.13 us per chart, flat across 1, 16 and 64 charts. --- .../moon-ui-gpui/src/chart_tabs/add_stack.rs | 111 +++++++++- .../moon-ui-gpui/src/chart_tabs/main_stack.rs | 53 ++++- crates/moon-ui-gpui/src/chart_tabs/mod.rs | 62 ++++-- crates/moon-ui-gpui/src/chartdx/backend.rs | 22 ++ crates/moon-ui-gpui/src/chartdx/combo.rs | 96 ++++++++- .../moon-ui-gpui/src/chartdx/combo/tests.rs | 203 ++++++++++++++++++ .../src/chartdx/data_state/market.rs | 51 ++++- .../src/chartdx/data_state/market/tests.rs | 45 ++++ .../src/chartdx/data_state/state.rs | 44 ++-- crates/moon-ui-gpui/src/chartdx/engine.rs | 41 +++- .../moon-ui-gpui/src/chartdx/engine/tests.rs | 168 +++++++++++++++ crates/moon-ui-gpui/src/chartdx/mod.rs | 7 +- .../moon-ui-gpui/src/chartdx/render_state.rs | 16 +- crates/moon-ui-gpui/src/panels/chart/mod.rs | 84 ++++++-- crates/moon-ui-gpui/src/panels/chart/tests.rs | 33 +++ 15 files changed, 953 insertions(+), 83 deletions(-) diff --git a/crates/moon-ui-gpui/src/chart_tabs/add_stack.rs b/crates/moon-ui-gpui/src/chart_tabs/add_stack.rs index 9ff6c6fb..9d9d5740 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/add_stack.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/add_stack.rs @@ -3,6 +3,7 @@ //! [`super::stack`]. Used by both the tab strip and detached windows ([`super::windows`]). use std::cell::Cell; +use std::collections::HashSet; use std::ops::Range; use std::rc::Rc; use std::time::{Duration, Instant}; @@ -137,6 +138,19 @@ pub(crate) struct AddChartStack { compact_timer_armed: bool, /// Scroll handle for the vertical `MoonVirtualList` used by the stack's Scroll mode. scroll: MoonVirtualListScrollHandle, + /// Whether the host presenting this stack is on screen. + /// + /// The strip writes it through [`Self::set_scene_visible`]. A direct render — the detached + /// window, which never receives the dock hook, or this stack painted as the active tab — + /// stores `true` because paint proves the host is present. Child visibility is this flag + /// AND [`Self::viewport_visible`], so a range update cannot show a chart under a hidden host. + host_visible: bool, + /// Entity ids of children last known to lie in the painted viewport. + /// + /// Identities, not indexes: pin reorder, a retained vacated slot, and close/add all renumber + /// the stack. Geometry writes this set before host visibility is applied, and a hide keeps + /// it so a later reveal can restore that subset when a cached ancestor skips the range callback. + viewport_visible: HashSet, } impl AddChartStack { @@ -204,6 +218,8 @@ impl AddChartStack { last_count_change: Instant::now(), compact_timer_armed: false, scroll: MoonVirtualListScrollHandle::new(), + host_visible: true, + viewport_visible: HashSet::new(), } } @@ -1101,26 +1117,86 @@ impl AddChartStack { } } + /// Record whether the host is showing this stack, and propagate a real change. + /// + /// An unchanged value returns immediately. A hide forces every child off and keeps + /// [`Self::viewport_visible`]. A reveal turns on only the nonvacated children in that set. + /// Children the viewport has not seen yet stay off until render or the next prepaint. + /// + /// Args: + /// visible: Whether the host currently presents this stack. + /// cx: Stack context used to update child panels. + /// + /// Returns: + /// Nothing; an unchanged host flag leaves child visibility and viewport membership + /// untouched. pub(super) fn set_scene_visible(&mut self, visible: bool, cx: &mut Context) { - for entry in &self.charts { - entry - .panel - .update(cx, |panel, _| panel.set_scene_visible(visible)); + if self.host_visible == visible { + return; } + self.host_visible = visible; + self.apply_viewport_visibility(cx); } + /// Replace viewport membership from one reported display range, then push host-gated visibility. + /// + /// Membership is derived before `host_visible` is consulted and is stored even when the host + /// is hidden. An empty range clears the set. A hidden host therefore cannot be switched back + /// on by this callback. + /// + /// Args: + /// range: Display indexes the virtual list currently paints. Empty means the list has no rows. + /// render_order: Real chart indexes in the order the list paints them. + /// cx: Stack context used to update child panels. + /// + /// Returns: + /// Nothing. A child is on only when the host is showing, the slot is not vacated, and its + /// entity id is in the range. fn sync_stack_visible_ordered( &mut self, range: Range, render_order: &[usize], cx: &mut Context, ) { - for (ix, entry) in self.charts.iter().enumerate() { - let visible = !entry.vacated - && render_order - .iter() - .enumerate() - .any(|(display_ix, real_ix)| *real_ix == ix && range.contains(&display_ix)); + let mut viewport = HashSet::with_capacity(range.len()); + for (display_ix, &real_ix) in render_order.iter().enumerate() { + if !range.contains(&display_ix) { + continue; + } + if let Some(entry) = self.charts.get(real_ix).filter(|entry| !entry.vacated) { + viewport.insert(entry.panel.entity_id()); + } + } + self.viewport_visible = viewport; + self.apply_viewport_visibility(cx); + } + + /// Remember every nonvacated child as viewport membership, without pushing visibility. + /// + /// FIT, COMPRESS, and horizontal scroll mount each of those children and do not report a + /// range. Vertical scroll must not call this: its painted subset arrives from + /// [`Self::sync_stack_visible_ordered`]. + fn remember_mounted_children(&mut self) { + self.viewport_visible.clear(); + for entry in &self.charts { + if !entry.vacated { + self.viewport_visible.insert(entry.panel.entity_id()); + } + } + } + + /// Push `host_visible` AND remembered viewport membership to every child. + /// + /// Args: + /// cx: Stack context used to update child panels. + /// + /// Returns: + /// Nothing. The remembered set is not changed. + fn apply_viewport_visibility(&self, cx: &mut Context) { + for entry in &self.charts { + let visible = self.host_visible + && !entry.vacated + && self.viewport_visible.contains(&entry.panel.entity_id()); entry .panel .update(cx, |panel, _| panel.set_scene_visible(visible)); @@ -1142,12 +1218,19 @@ impl Render for AddChartStack { /// Renders every Add/Custom chart as a labelled, guttered stack tile. /// /// These stacks have no fullscreen mode, so their shared card helper always retains the - /// separator and never needs the Main stack's position note. + /// separator and never needs the Main stack's position note. Paint stores `host_visible` + /// because a rendered stack is on screen, including a detached window that never receives + /// the dock hook. It does not call `set_scene_visible`: vertical scroll keeps the last + /// reported range, and every mounted route records that mounted set for a later reveal. fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Paint means this host is on screen, detached window included. Store the flag only: + // `set_scene_visible` would mark every remembered child visible from inside render. + self.host_visible = true; crate::diag::bump(&crate::diag::ADD_STACK_RENDER); let _render_us = crate::diag::scope(&crate::diag::ADD_STACK_RENDER_US); let palette = moon_ui::MoonPalette::active(cx); if self.charts.is_empty() { + self.viewport_visible.clear(); // The cover is not optional here: a detached window has `Root=NoFill` and no own // pass, so without it the white window backing shows through. Only the mark on it // follows the switch, and both come from one builder so that cannot be got wrong. @@ -1193,6 +1276,12 @@ impl Render for AddChartStack { .layout_orientation .unwrap_or(StackOrientation::Vertical) .is_horizontal(); + // `render_chart_stack` builds a `MoonVirtualList` only for vertical scroll outside + // COMPRESS. Horizontal scroll and FIT/COMPRESS mount every nonvacated child and never + // report a range, so those routes are the geometry the next reveal is allowed to restore. + if !(scroll && !compress && !horizontal) { + self.remember_mounted_children(); + } let entity = cx.entity(); let p = palette; let title_size = crate::design::t_body(cx); diff --git a/crates/moon-ui-gpui/src/chart_tabs/main_stack.rs b/crates/moon-ui-gpui/src/chart_tabs/main_stack.rs index e0e007ec..aa615bba 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/main_stack.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/main_stack.rs @@ -113,6 +113,12 @@ pub(crate) struct MainChartStack { layout_min_slot: Option, /// Size the stack was last painted at, written by the render probe. See `AddChartStack`. measured: Rc>>, + /// Whether the host presenting this stack is on screen. + /// + /// The dock writes it through [`Self::set_scene_visible`]. Paint stores `true` because a + /// rendered stack is present. Prune and virtual-list callbacks AND this flag, so a hidden + /// host cannot be switched back on by a local visibility update. + host_visible: bool, scroll: MoonVirtualListScrollHandle, } @@ -246,6 +252,7 @@ impl MainChartStack { layout_columns_exact: None, layout_min_slot: None, measured: Rc::new(Cell::new(Size::default())), + host_visible: true, scroll: MoonVirtualListScrollHandle::new(), }; if let Some((core, market)) = focus_open { @@ -1202,7 +1209,24 @@ impl MainChartStack { .update(cx, |panel, pcx| panel.debug_fill_history_to_capacity(pcx)) } + /// Record whether the host is showing this stack, and propagate a real change. + /// + /// An unchanged value returns immediately. Child prune and layout already update local + /// visibility, and repeating the hide or reveal on every backend observation would reset + /// virtual-list children. + /// + /// Args: + /// visible: Whether the host currently presents this stack. + /// cx: Stack context used to update child panels. + /// + /// Returns: + /// Nothing; an unchanged host flag leaves child visibility untouched. A reveal reuses + /// [`Self::sync_visibility`]. A hide forces every child off and clears stack-scroll mode. pub(super) fn set_scene_visible(&mut self, visible: bool, cx: &mut Context) { + if self.host_visible == visible { + return; + } + self.host_visible = visible; if visible { self.sync_visibility(cx); } else { @@ -1215,12 +1239,20 @@ impl MainChartStack { } } + /// Push fullscreen or stack visibility down to every child, gated by the host. + /// + /// Args: + /// cx: Stack context used to update child panels. + /// + /// Returns: + /// Nothing. A hidden host forces every child off. fn sync_visibility(&mut self, cx: &mut Context) { for (ix, entry) in self.charts.iter().enumerate() { // In fullscreen, only the active chart is visible. In stack mode, visible tiles set // themselves visible in `ChartPanel::render`; offscreen virtual-list entries remain - // hidden and do not run preparation work. - let visible = !self.show_stack && Some(ix) == self.active; + // hidden and do not run preparation work. `host_visible` keeps a hidden dock or an + // inactive Main tab from being turned back on by prune, open, or close. + let visible = self.host_visible && !self.show_stack && Some(ix) == self.active; let stack_scroll = self.show_stack; entry.panel.update(cx, |panel, _| { panel.set_main_stack_scroll(stack_scroll); @@ -1229,12 +1261,21 @@ impl MainChartStack { } } + /// Apply one virtual-list window to stack-mode children, gated by the host. + /// + /// Args: + /// range: Display indexes the virtual list currently paints. + /// cx: Stack context used to update child panels. + /// + /// Returns: + /// Nothing. Fullscreen ignores the window. A hidden host forces every child in the + /// window off. fn sync_stack_visible_range(&mut self, range: Range, cx: &mut Context) { if !self.show_stack { return; } for (ix, entry) in self.charts.iter().enumerate() { - let visible = range.contains(&ix); + let visible = self.host_visible && range.contains(&ix); entry.panel.update(cx, |panel, _| { panel.set_main_stack_scroll(true); panel.set_scene_visible(visible); @@ -1619,7 +1660,13 @@ mod tests; impl Render for MainChartStack { /// Renders the per-chart tab row above either the active full-bleed chart or the virtualized /// whole-stack layout. + /// + /// Paint stores `host_visible` because a rendered stack is on screen. Child wake stays on the + /// visible-range path; `set_scene_visible` is not called from here. fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Paint means this host is on screen. Store the flag only: `set_scene_visible` would mark + // every offscreen child visible. + self.host_visible = true; crate::diag::bump(&crate::diag::MAIN_STACK_RENDER); let _render_us = crate::diag::scope(&crate::diag::MAIN_STACK_RENDER_US); let palette = moon_ui::MoonPalette::active(cx); diff --git a/crates/moon-ui-gpui/src/chart_tabs/mod.rs b/crates/moon-ui-gpui/src/chart_tabs/mod.rs index d95b548b..c1d3a12a 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/mod.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/mod.rs @@ -303,6 +303,12 @@ pub struct ChartTabs { detached: Vec<(u32, ChartBucket, Entity)>, /// Active tab. active: Tab, + /// Whether this Charts panel is the front dock tab. + /// + /// Separate from the inner-tab flag above. `Panel::set_active` is the dock host's visibility + /// hook; keyboard focus is not occlusion. Starts shown, so the frame before the dock announces + /// a change still prepares the active chart. Detached windows are not gated by this flag. + host_visible: bool, /// Number of markets already seen while each `(number, bucket)` tab was active. /// The badge is `pane_count - seen`; active tabs catch up and hide it, while inactive tabs /// retain their seen count so new detects increase the badge. @@ -737,6 +743,7 @@ impl ChartTabs { custom_gate_gen: HashMap::new(), detached: Vec::new(), active: Tab::Main, + host_visible: true, seen: HashMap::new(), add_seq: HashMap::new(), last_sig: initial_sig, @@ -1147,25 +1154,30 @@ impl ChartTabs { chart_pane_label(&self.backend, &self.group, n, bucket, cx) } - /// Mark inactive tabs invisible because they are absent from the current GPUI scene and must - /// not run CPU preparation from chart-data observation. Active or detached panels mark - /// themselves visible in their own render path. + /// Mark attached stacks that are absent from the current GPUI scene so they do not run CPU + /// preparation from chart-data observation. + /// + /// A hidden Charts dock passes `false` to every attached Main, Add, and Custom stack. While + /// the dock is showing, each attached stack follows the active inner tab, including the active + /// Add stack. Detached windows are not in these lists and keep their own visibility. + /// + /// Args: + /// cx: Tab context used to update attached stacks. + /// + /// Returns: + /// Nothing; `detached` is not visited. fn sync_inactive_chart_visibility(&self, cx: &mut Context) { let active = self.active.clone(); - if matches!(active, Tab::Main) { - self.main - .update(cx, |panel, pcx| panel.set_scene_visible(true, pcx)); - } else { - self.main - .update(cx, |panel, pcx| panel.set_scene_visible(false, pcx)); - } + let host_visible = self.host_visible; + self.main.update(cx, |panel, pcx| { + panel.set_scene_visible(host_visible && matches!(active, Tab::Main), pcx); + }); for (n, c, panel) in &self.add { - if Tab::Add(*n, c.clone()) != active { - panel.update(cx, |panel, pcx| panel.set_scene_visible(false, pcx)); - } + let visible = host_visible && Tab::Add(*n, c.clone()) == active; + panel.update(cx, |panel, pcx| panel.set_scene_visible(visible, pcx)); } for (n, c, panel) in &self.custom { - let visible = Tab::Custom(*n, c.clone()) == active; + let visible = host_visible && Tab::Custom(*n, c.clone()) == active; panel.update(cx, |panel, pcx| panel.set_scene_visible(visible, pcx)); } } @@ -1347,6 +1359,28 @@ impl Panel for ChartTabs { fn background_policy(&self, _cx: &App) -> MoonBackgroundPolicy { MoonBackgroundPolicy::NoFill } + + /// Propagate dock-tab visibility into the attached chart stacks. + /// + /// `active` is whether this Charts panel is the front dock tab. An unchanged value returns + /// without notifying, persisting, switching the inner tab, or touching detached windows. + /// Keyboard focus is not consulted: a window can stay focused while this dock tab sits behind + /// another. + /// + /// Args: + /// active: Whether the dock host is showing this panel. + /// _window: Unused. The dock passes the window that rendered the tab group. + /// cx: Panel context used to update attached stacks on a real change. + /// + /// Returns: + /// Nothing. + fn set_active(&mut self, active: bool, _window: &mut Window, cx: &mut Context) { + if self.host_visible == active { + return; + } + self.host_visible = active; + self.sync_inactive_chart_visibility(cx); + } } /// Build an AddToChart label for both the tab strip and detached-window title. diff --git a/crates/moon-ui-gpui/src/chartdx/backend.rs b/crates/moon-ui-gpui/src/chartdx/backend.rs index e70cd438..84fa280f 100644 --- a/crates/moon-ui-gpui/src/chartdx/backend.rs +++ b/crates/moon-ui-gpui/src/chartdx/backend.rs @@ -194,6 +194,28 @@ impl PlatformLayers { } } + /// Whether appending these rows can change a cached combo bitmap. + /// + /// DX11 asks the combo layer, which knows both bake spans. Every other + /// backend reports damage, so it keeps the previous unconditional repaint. + /// + /// Args: + /// data: Rows about to be appended. + /// + /// Returns: + /// `false` only when DX11 can prove neither cached span is touched. + pub fn combo_append_touches_cached_span(&self, data: &[ChartCross]) -> bool { + #[cfg(windows)] + { + self.combo.append_touches_cached_span(data) + } + #[cfg(not(windows))] + { + let _ = data; + true + } + } + /// Fully replaces the layer's candle set from the whole composed list. pub fn set_candles(&mut self, data: &[CandleGpu]) { #[cfg(windows)] diff --git a/crates/moon-ui-gpui/src/chartdx/combo.rs b/crates/moon-ui-gpui/src/chartdx/combo.rs index ca746450..822f3309 100644 --- a/crates/moon-ui-gpui/src/chartdx/combo.rs +++ b/crates/moon-ui-gpui/src/chartdx/combo.rs @@ -255,13 +255,70 @@ impl ComboLayer { } /// Append live ticks and retain lateness evidence even when this batch replaces the ring. + /// + /// Once the queue passes twice the ring capacity, its oldest rows are dropped until + /// the newest capacity rows remain. The logical ring already publishes only that + /// tail, and the lateness evidence is left in place so a later search stays wide. pub fn append(&mut self, data: &[ChartCross]) { - if !data.is_empty() { - self.tick_time_order.extend(data.iter().map(|c| c.time_rel)); - self.pending_append.extend_from_slice(data); + if data.is_empty() { + return; + } + self.tick_time_order.extend(data.iter().map(|c| c.time_rel)); + self.pending_append.extend_from_slice(data); + let cap = self.cross_capacity as usize; + if self.pending_append.len() > cap.saturating_mul(2) { + let excess = self.pending_append.len() - cap; + self.pending_append.drain(..excess); } } + /// Whether appending these rows can change a pixel in either cached bitmap. + /// + /// An empty batch cannot. A missing or invalid cross or volume cache is damage, + /// because there is no span that proves the rows are offscreen. Otherwise both + /// bake spans, margins included, are tested against the new times and against + /// the oldest rows this append would evict from the pending logical ring. + /// The ring is read before any mutation. + /// + /// Args: + /// data: Rows about to be appended. Not yet in `pending_append`. + /// + /// Returns: + /// `true` when either cached bitmap may change. + pub fn append_touches_cached_span(&self, data: &[ChartCross]) -> bool { + if data.is_empty() { + return false; + } + let Some(cross) = self.tex.as_ref().filter(|tex| tex.key.valid) else { + return true; + }; + let Some(volume) = self.vol_tex.as_ref().filter(|tex| tex.key.valid) else { + return true; + }; + let cross_span = tick_bake_span( + cross.key.bake_t0, + cross.key.tex_w as f32, + cross.key.time_to_px, + cross.key.marker_half, + ); + let volume_span = tick_bake_span( + volume.key.bake_t0, + volume.key.tex_w as f32, + volume.key.time_to_px, + 0.0, + ); + let capacity = self.cross_capacity as usize; + let old = moon_chart::tick_volume::pending_ring( + &self.resident_crosses, + self.resident_head, + self.resident_count, + capacity, + self.pending_reset.as_deref(), + &self.pending_append, + ); + append_span_damage(old, data, cross_span, volume_span, capacity) + } + /// Updates tick colours and invalidates history baked with the previous style. pub fn set_tick_style(&mut self, style: TickStyleGpu) { if self.tick_style != style { @@ -1241,6 +1298,39 @@ fn draw_price_ring( } } +/// Whether new rows, or the old rows they evict, intersect either cached span. +/// +/// `old` is the logical ring before the append, in chronological order. The +/// eviction count is how far `old.len() + new_rows.len()` passes `capacity`, +/// and it never exceeds `old.len()`. A non-finite time touches every span. +/// +/// Args: +/// old: Pending logical ring before this append. +/// new_rows: Rows about to be appended. +/// cross_span: Cached cross-bitmap time span, margins included. +/// volume_span: Cached volume-bitmap time span, margins included. +/// capacity: Ring capacity. Already a positive normalized value. +/// +/// Returns: +/// `true` when any tested time lies in either span. +fn append_span_damage<'a>( + old: impl ExactSizeIterator, + new_rows: &[ChartCross], + cross_span: (f64, f64), + volume_span: (f64, f64), + capacity: usize, +) -> bool { + let evicted = old + .len() + .saturating_add(new_rows.len()) + .saturating_sub(capacity) + .min(old.len()); + let touches = + |time: f32| tick_touches_bake(time, cross_span) || tick_touches_bake(time, volume_span); + new_rows.iter().any(|row| touches(row.time_rel)) + || old.take(evicted).any(|row| touches(row.time_rel)) +} + fn sanitize_capacity(capacity: usize) -> u32 { capacity.clamp(MIN_COMBO_CAPACITY as usize, u32::MAX as usize) as u32 } diff --git a/crates/moon-ui-gpui/src/chartdx/combo/tests.rs b/crates/moon-ui-gpui/src/chartdx/combo/tests.rs index 1bc26cb3..4740d1a6 100644 --- a/crates/moon-ui-gpui/src/chartdx/combo/tests.rs +++ b/crates/moon-ui-gpui/src/chartdx/combo/tests.rs @@ -285,3 +285,206 @@ fn live_follow_inside_the_margin_never_rebakes_and_slides_the_blit() { "a price-scale change must bake" ); } + +/// Whether `time` lies inside a cached bitmap span. Non-finite times are outside this fixture. +/// +/// Args: +/// time: Row time, relative to the chart epoch. +/// span: Inclusive bake interval. +/// +/// Returns: +/// `true` when a finite time is inside the span. +fn covers(time: f32, span: (f64, f64)) -> bool { + time.is_finite() && f64::from(time) >= span.0 && f64::from(time) <= span.1 +} + +/// Chronological rows the next upload would publish, copied out of the pending ring. +/// +/// Args: +/// layer: Combo layer whose reset and append queues are already filled. +/// +/// Returns: +/// The retained logical ring, oldest first. +fn logical_ring(layer: &ComboLayer) -> Vec { + let capacity = layer.cross_capacity as usize; + moon_chart::tick_volume::pending_ring( + &layer.resident_crosses, + layer.resident_head, + layer.resident_count, + capacity, + layer.pending_reset.as_deref(), + &layer.pending_append, + ) + .copied() + .collect() +} + +/// Run the damage predicate on `new_rows` without appending them. +/// +/// Args: +/// layer: Pending ring the predicate reads. +/// new_rows: Batch that has not been queued yet. +/// cross_span: Cached cross-bitmap span. +/// volume_span: Cached volume-bitmap span. +/// +/// Returns: +/// Whatever `append_span_damage` reports for that ring. +fn damage_of( + layer: &ComboLayer, + new_rows: &[ChartCross], + cross_span: (f64, f64), + volume_span: (f64, f64), +) -> bool { + let capacity = layer.cross_capacity as usize; + let old = moon_chart::tick_volume::pending_ring( + &layer.resident_crosses, + layer.resident_head, + layer.resident_count, + capacity, + layer.pending_reset.as_deref(), + &layer.pending_append, + ); + super::append_span_damage(old, new_rows, cross_span, volume_span, capacity) +} + +/// `combo.rs:append_span_damage` deleting the evicted-prefix disjunct still accepts offscreen +/// new rows and leaves a visible cross or volume bar baked after the ring has dropped it. +#[test] +fn offscreen_rows_that_evict_a_visible_row_damage_the_cached_span() { + let cross_span = (1_000.0, 1_100.0); + let volume_span = (90.0, 160.0); + let capacity = 4usize; + let new_rows = [cross(500.0, 0, 1.0), cross(600.0, 0, 1.0)]; + + let mut quiet = ComboLayer::new(); + quiet.set_capacity(capacity, 1); + quiet.reset(vec![ + cross(10.0, 0, 1.0), + cross(20.0, 0, 1.0), + cross(300.0, 0, 1.0), + ]); + quiet.append(&[cross(400.0, 0, 1.0)]); + assert!( + !damage_of(&quiet, &new_rows, cross_span, volume_span), + "offscreen eviction of offscreen rows must not repaint" + ); + + let mut layer = ComboLayer::new(); + layer.set_capacity(capacity, 1); + // Pending reset replaces resident rows. The queued append is already part of the old ring. + // Times 100 and 150 sit in the volume span and miss the cross span. + layer.reset(vec![ + cross(100.0, 0, 1.0), + cross(150.0, 0, 1.0), + cross(300.0, 0, 1.0), + ]); + layer.append(&[cross(400.0, 0, 1.0)]); + + let old_rows = logical_ring(&layer); + let mut combined = old_rows.clone(); + combined.extend_from_slice(&new_rows); + let retained = &combined[combined.len().saturating_sub(capacity)..]; + let evicted_n = old_rows + .len() + .saturating_add(new_rows.len()) + .saturating_sub(capacity) + .min(old_rows.len()); + let evicted = &old_rows[..evicted_n]; + + assert!( + evicted + .iter() + .any(|row| { covers(row.time_rel, volume_span) && !covers(row.time_rel, cross_span) }), + "fixture must evict a volume-only row" + ); + assert!( + new_rows + .iter() + .all(|row| { !covers(row.time_rel, volume_span) && !covers(row.time_rel, cross_span) }), + "new rows must miss both cached spans" + ); + assert!( + evicted + .iter() + .all(|row| retained.iter().all(|kept| kept.price != row.price)), + "evicted prices must leave the retained ring" + ); + + assert!( + damage_of(&layer, &new_rows, cross_span, volume_span), + "evicted visible row must mark cached volume damage" + ); +} + +/// `combo.rs:append_span_damage` dropping `new_rows.iter().any(...)` ignores a tick that lands +/// in a cached span when the ring does not evict. +/// +/// The user-visible consequence is a stale cross or volume bitmap until a later eviction. +/// A miss outside both spans, with no eviction, stays quiet. +#[test] +fn new_row_in_span_damages_without_eviction() { + let cross_span = (1_000.0, 1_100.0); + let volume_span = (90.0, 160.0); + let mut layer = ComboLayer::new(); + layer.set_capacity(8, 1); + layer.reset(vec![ + cross(10.0, 0, 1.0), + cross(20.0, 0, 1.0), + cross(30.0, 0, 1.0), + ]); + let old = logical_ring(&layer); + assert!(old.len() + 1 <= 8, "fixture must not evict"); + assert!( + damage_of(&layer, &[cross(120.0, 0, 4.0)], cross_span, volume_span), + "a new row inside the volume span must damage without eviction" + ); + assert!( + damage_of(&layer, &[cross(1_050.0, 0, 5.0)], cross_span, volume_span), + "a new row inside the cross span must damage without eviction" + ); + assert!( + !damage_of(&layer, &[cross(5_000.0, 0, 6.0)], cross_span, volume_span), + "a new row outside both spans must stay quiet when nothing is evicted" + ); +} + +/// `combo.rs:ComboLayer::append` replacing `drain(..excess)` with `truncate(cap)` keeps the +/// oldest queued rows and drops the live tail. +/// +/// The user-visible consequence is the chart drawing stale ticks after the queue overflows. +/// Lateness is evidence over every appended time, so the trim must not clear it. +#[test] +fn pending_append_keeps_newest_capacity() { + let cap = 4usize; + let mut layer = ComboLayer::new(); + layer.set_capacity(cap, 1); + let mut all = Vec::new(); + all.push(cross(50.0, 0, 1.0)); + all.push(cross(10.0, 0, 1.0)); + for i in 0..(cap * 2) { + all.push(cross(100.0 + i as f32, 0, 1.0)); + } + assert!(all.len() > cap * 2, "fixture must cross the overflow line"); + layer.append(&all); + let tail = &all[all.len() - cap..]; + assert_eq!( + layer.pending_append.len(), + cap, + "overflow keeps one capacity" + ); + for (got, want) in layer.pending_append.iter().zip(tail.iter()) { + assert_eq!(got.time_rel, want.time_rel, "oldest rows must not survive"); + assert_eq!(got.price, want.price); + } + let mut order = moon_chart::tick_volume::TickTimeOrder::default(); + order.extend(all.iter().map(|row| row.time_rel)); + assert_eq!( + layer.tick_time_order.max_lateness(), + order.max_lateness(), + "overflow must keep lateness from the dropped prefix" + ); + assert!( + order.max_lateness() >= 40.0, + "fixture lateness is 50 minus 10" + ); +} diff --git a/crates/moon-ui-gpui/src/chartdx/data_state/market.rs b/crates/moon-ui-gpui/src/chartdx/data_state/market.rs index d3e088c5..ef112db1 100644 --- a/crates/moon-ui-gpui/src/chartdx/data_state/market.rs +++ b/crates/moon-ui-gpui/src/chartdx/data_state/market.rs @@ -509,6 +509,7 @@ impl ChartDataState { pr.last_candle_rev = u64::MAX; pr.candle_rows.clear(); pr.volume_samples.clear(); + pr.volume_samples_max_tf = 0.0; pr.candle_rows_epoch = f64::NAN; pr.candle_resync = false; } @@ -647,6 +648,7 @@ impl ChartDataState { pr.last_candle_rev = u64::MAX; pr.candle_rows.clear(); pr.volume_samples.clear(); + pr.volume_samples_max_tf = 0.0; pr.candle_rows_epoch = f64::NAN; pr.candle_resync = false; pr.gpu_prepare_dirty = true; @@ -942,6 +944,7 @@ impl ChartDataState { &crate::diag::CHART_COMBO_UPLOAD_LEN, pr.cross_upload.len() as u64, ); + let damage = pr.layers.combo_append_touches_cached_span(&pr.cross_upload); pr.layers.append_combo(&pr.cross_upload); // The first crosses this pane ever received may arrive through the live drain // rather than a full read: a chart opened on a market whose ring the core has @@ -956,7 +959,7 @@ impl ChartDataState { } } pr.gpu_prepare_dirty = true; - pixels_changed = true; + pixels_changed |= damage; } // Append liquidation-trade crosses with side=2 to the same combo ring. Ring order // does not affect placement because the shader uses time_rel. On combo_reset the @@ -969,9 +972,10 @@ impl ChartDataState { pane.view.epoch_ms, &mut pr.liq_upload, ); + let damage = pr.layers.combo_append_touches_cached_span(&pr.liq_upload); pr.layers.append_combo(&pr.liq_upload); pr.gpu_prepare_dirty = true; - pixels_changed = true; + pixels_changed |= damage; } // A live trade batch usually changes only the last candle, so the source ships // just the changed tail and `candle_rows` is patched in place; a rebuild ships the @@ -1009,12 +1013,13 @@ impl ChartDataState { pr.last_candle_rev = u64::MAX; pr.candle_resync = true; } else { - // Ascending as the composed history is; the widest width bounds each - // lookup. A patch may bring a wider row, so this follows every apply. - pr.volume_samples_max_tf = pr - .volume_samples - .iter() - .fold(0.0, |max: f64, s| max.max(s.tf_ms)); + // The bound only widens the sorted lookup. A patch keeps a removed + // maximum until the next full replacement, which cannot drop a pixel. + pr.volume_samples_max_tf = volume_sample_timeframe_bound( + &pr.volume_samples, + &applied, + pr.volume_samples_max_tf, + ); crate::diag::record_us(&crate::diag::CHART_CANDLE_UPLOAD_US, upload_timer); pr.last_candle_rev = history.candles_revision; pr.candle_resync = false; @@ -1131,6 +1136,7 @@ impl ChartDataState { pr.last_candle_rev = u64::MAX; pr.candle_rows.clear(); pr.volume_samples.clear(); + pr.volume_samples_max_tf = 0.0; pr.candle_rows_epoch = f64::NAN; pr.candle_resync = false; pr.history_cursor.reset(); @@ -2217,6 +2223,35 @@ fn same_period_set(held: &[(VolumeSpan, VolumeAt)], want: &[(VolumeSpan, VolumeA held.len() == want.len() && want.iter().all(|key| held.contains(key)) } +/// Conservative upper bound on sample timeframes after one candle apply. +/// +/// A full replacement scans every sample from zero. A patch scans only the new +/// suffix and starts from the previous bound, so removing the widest row cannot +/// shrink it. A rejected apply leaves the previous bound unchanged. +/// +/// Args: +/// samples: Retained samples after the apply. +/// applied: How the read landed. +/// previous: Bound before this apply. +/// +/// Returns: +/// A value at least as large as every surviving sample's `tf_ms`. +fn volume_sample_timeframe_bound( + samples: &[moon_chart::VolumeSample], + applied: &CandleApply, + previous: f64, +) -> f64 { + match applied { + CandleApply::Rejected => previous, + CandleApply::Full => samples + .iter() + .fold(0.0, |max, sample| max.max(sample.tf_ms)), + CandleApply::Patch(from) => samples[*from..] + .iter() + .fold(previous, |max, sample| max.max(sample.tf_ms)), + } +} + /// How a candle read landed in a pane's retained rows. pub(crate) enum CandleApply { /// The whole list was replaced. diff --git a/crates/moon-ui-gpui/src/chartdx/data_state/market/tests.rs b/crates/moon-ui-gpui/src/chartdx/data_state/market/tests.rs index b6829f4d..45b4cb15 100644 --- a/crates/moon-ui-gpui/src/chartdx/data_state/market/tests.rs +++ b/crates/moon-ui-gpui/src/chartdx/data_state/market/tests.rs @@ -365,3 +365,48 @@ fn live_follow_inside_the_book_margin_keeps_instances_and_bitmap() { ); } } + +/// `market.rs:volume_sample_timeframe_bound` seeding the patch fold at `0.0` drops a wide row +/// the patch does not touch. +/// +/// The user-visible consequence is the volume band clipping a coarse candle that is still +/// retained. A wider tail is the other direction and must raise the bound. +#[test] +fn patch_bound_keeps_wide_row_and_wider_tail() { + use super::{CandleApply, volume_sample_timeframe_bound}; + + let mut samples = Vec::with_capacity(8); + for i in 0..8 { + samples.push(moon_chart::VolumeSample { + t_open_ms: i as f64, + tf_ms: if i == 1 { 3_600_000.0 } else { 60_000.0 }, + quote_volume: 1.0, + }); + } + let previous = 3_600_000.0; + let suffix_max = samples[4..] + .iter() + .map(|sample| sample.tf_ms) + .fold(0.0, f64::max); + assert!( + suffix_max < previous, + "fixture suffix is narrower than row 1" + ); + assert_eq!( + volume_sample_timeframe_bound(&samples, &CandleApply::Patch(4), previous), + previous.max(suffix_max), + "a patch must keep the older wide row" + ); + + samples[7].tf_ms = 7_200_000.0; + let raised_suffix = samples[4..] + .iter() + .map(|sample| sample.tf_ms) + .fold(0.0, f64::max); + assert!(raised_suffix > previous, "fixture tail is a new maximum"); + assert_eq!( + volume_sample_timeframe_bound(&samples, &CandleApply::Patch(4), previous), + previous.max(raised_suffix), + "a wider tail must raise the bound" + ); +} diff --git a/crates/moon-ui-gpui/src/chartdx/data_state/state.rs b/crates/moon-ui-gpui/src/chartdx/data_state/state.rs index 2e7e7bd4..8387adc0 100644 --- a/crates/moon-ui-gpui/src/chartdx/data_state/state.rs +++ b/crates/moon-ui-gpui/src/chartdx/data_state/state.rs @@ -426,23 +426,39 @@ impl ChartDataState { changed } + /// Prepare one canvas frame, or skip it before any chart work. + /// + /// Hidden, not-presentable, and empty-bounds frames return [`GpuFrameDecision::Skip`] + /// before slot geometry, present-rate sampling, market sync, countdown captions, or + /// [`RenderState::frame`]. `view_dirty` and `RenderState::needs_present` stay pending. + /// `last_frame_tick_at` is cleared so the gap is not a cadence sample. The next eligible + /// frame applies the current slot, then one market pull performs the only source sync. + /// A present-rate change only marks the view dirty and rides that same pull. With no + /// source the dirty flag stays set. + /// + /// Args: + /// info: Frame input from the GPU canvas. `info.now` is the monotonic clock for + /// present-rate and countdown checks. + /// + /// Returns: + /// `Skip` when this chart must not prepare, otherwise the render state's decision. pub(crate) fn frame(&mut self, info: GpuFrameInfo) -> GpuFrameDecision { - // Apply slot geometry synchronously from info.bounds, which the fork provides for this - // frame before presentation. Doing this before pull/sync lets the own pass draw in the - // current slot without the one- or two-frame probe-to-notify-to-render-to-present delay; - // otherwise a vacated or shifted slot flashes the window clear during stack reflow. - self.apply_slot_geometry(&info); - if !info.presentable || info.bounds.is_empty() { - return self.render.borrow_mut().frame(info); + if !self.scene_visible || !info.presentable || info.bounds.is_empty() { + self.last_frame_tick_at = None; + // Same counter `RenderState::frame` bumps for a direct caller. This path no + // longer reaches that guard. + if !info.presentable || info.bounds.is_empty() { + crate::diag::bump(&crate::diag::CHART_FRAME_SKIP_NOT_PRESENTABLE); + } + return GpuFrameDecision::Skip; } - let now = Instant::now(); + // Geometry before pull/sync: the fork already has this frame's slot, so the own + // pass draws it without a one- or two-frame reflow flash. A reveal applies it in + // the same call, before anything is drawn. + self.apply_slot_geometry(&info); + let now = info.now; if self.observe_present_rate(now) { - if let Some(source) = self.market_source.clone() { - crate::diag::bump(&crate::diag::CHART_PREPARE); - self.sync_from_market_source(&source, None); - } else { - self.view_dirty = true; - } + self.mark_view_dirty(); } if self.pull_market_source_if_visible() { crate::diag::bump(&crate::diag::CHART_PREPARE); diff --git a/crates/moon-ui-gpui/src/chartdx/engine.rs b/crates/moon-ui-gpui/src/chartdx/engine.rs index 40fd5315..99e81e9e 100644 --- a/crates/moon-ui-gpui/src/chartdx/engine.rs +++ b/crates/moon-ui-gpui/src/chartdx/engine.rs @@ -268,12 +268,22 @@ impl ChartEngine { self.container.borrow().layout(area) } + /// Publish a bootstrap present rate when the requested value changes. + /// + /// The panel calls this on every render. The rate stored here is that request, + /// not the cadence learned from frame callbacks. Writing the learned rate back + /// on each render would schedule a full source sync every time. + /// + /// Args: + /// hz: Requested frames per second. Values below 1 become 1. pub fn set_present_rate_hz(&mut self, hz: f32) { - self.present_rate_hz = hz.max(1.0); - self.data.borrow_mut().present_rate_hz = self.present_rate_hz; - self.state - .borrow_mut() - .set_target_present_rate_hz(self.present_rate_hz); + let hz = hz.max(1.0); + if hz == self.present_rate_hz { + return; + } + self.present_rate_hz = hz; + self.data.borrow_mut().present_rate_hz = hz; + self.state.borrow_mut().set_target_present_rate_hz(hz); } /// Uploads the live Moon palette for chart chrome that follows the UI theme. @@ -427,8 +437,27 @@ impl ChartEngine { self.data.borrow().notify_signature(session) } + /// Record whether this chart's scene is on screen. + /// + /// An unchanged flag does nothing. A real change drops cadence samples so a + /// hidden gap cannot train the present rate, including when no frame callback + /// runs while the chart is hidden. Revealing marks the view dirty so the next + /// frame pulls the latest source. Render dirtiness is left as it was. + /// + /// Args: + /// visible: Whether the panel is showing this engine. pub fn set_scene_visible(&mut self, visible: bool) { - self.data.borrow_mut().scene_visible = visible; + let mut data = self.data.borrow_mut(); + if data.scene_visible == visible { + return; + } + data.scene_visible = visible; + data.last_frame_tick_at = None; + data.present_rate_candidate_hits = 0; + data.present_rate_candidate_hz = 0.0; + if visible { + data.mark_view_dirty(); + } } /// Adopt a new device pixel scale, invalidating the userdata layer when it actually moved. diff --git a/crates/moon-ui-gpui/src/chartdx/engine/tests.rs b/crates/moon-ui-gpui/src/chartdx/engine/tests.rs index cc22e3c9..6a595f24 100644 --- a/crates/moon-ui-gpui/src/chartdx/engine/tests.rs +++ b/crates/moon-ui-gpui/src/chartdx/engine/tests.rs @@ -47,6 +47,8 @@ fn slot_geometry_keeps_device_density_while_the_pointer_crosses_with_the_windows let now = 1_700_000_000_000.0; let mut engine = ChartEngine::new(now, ChartTheme::default()); engine.open(42, "TESTUSDT"); + // The scene starts hidden. A frame before this returns Skip and installs no slot. + engine.set_scene_visible(true); engine.data.borrow_mut().frame(GpuFrameInfo { now: Instant::now(), bounds: Bounds { @@ -82,3 +84,169 @@ fn slot_geometry_keeps_device_density_while_the_pointer_crosses_with_the_windows assert_eq!((x, y), (150.0, 150.0), "(60 − 10) × 3 and (70 − 20) × 3"); assert!(within); } + +/// Whole-pixel live edge, the same rounding `ChartView::quantize_edge_ms` uses. +/// +/// Args: +/// epoch_ms: Chart time origin. +/// px_per_ms: Scale read back from the pane after the frame. +/// edge_ms: Wall-clock instant the sync may have anchored. +/// +/// Returns: +/// `edge_ms` snapped onto the pane's pixel grid. +fn quantized_edge(epoch_ms: f64, px_per_ms: f32, edge_ms: f64) -> f64 { + let ppm = f64::from(px_per_ms.max(moon_chart::view::MIN_PX_PER_MS)); + let rel = edge_ms - epoch_ms; + (rel * ppm).round() / ppm + epoch_ms +} + +/// One-level book. Bids are descending and asks ascending, which `OrderBookModel::update` requires. +/// +/// Args: +/// bid: Best bid. +/// ask: Best ask. +/// +/// Returns: +/// A snapshot whose midpoint is `(bid + ask) / 2`. +fn book(bid: f32, ask: f32) -> moon_core::feed::OrderBook { + moon_core::feed::OrderBook { + bids: vec![moon_core::feed::Level { + price: bid, + qty: 1.0, + }], + asks: vec![moon_core::feed::Level { + price: ask, + qty: 1.0, + }], + } +} + +/// One presentable slot. Same bounds every call so a later frame is not a resize. +/// +/// Args: +/// engine: Chart whose data state receives the frame. +fn present_frame(engine: &mut ChartEngine) { + use gpui::{Bounds, GpuFrameInfo, point, px, size}; + use std::time::Instant; + + engine.data.borrow_mut().frame(GpuFrameInfo { + now: Instant::now(), + bounds: Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(800.0), px(400.0)), + }, + scale_factor: 1.0, + content_zoom: 1.0, + presentable: true, + }); +} + +/// Read the pane camera the next pull will publish. +/// +/// Args: +/// engine: Chart whose main pane is open. +/// +/// Returns: +/// `(right_time_ms, center_price, px_per_ms)`. +fn camera(engine: &ChartEngine) -> (f64, f32, f32) { + let container = engine.container.borrow(); + let view = &container.panes().first().expect("main pane").view; + (view.right_time_ms, view.center_price, view.px_per_ms) +} + +/// Park the live pane on a known camera without marking the view dirty. +/// +/// Args: +/// engine: Chart whose main pane is open. +/// right_time_ms: Time anchor to store. +/// center_price: Y center to store. +/// price_range: Y range to store. Kept wide enough that a later book mid clears `CENTER_BUFFER`. +fn park_camera(engine: &mut ChartEngine, right_time_ms: f64, center_price: f32, price_range: f32) { + let mut container = engine.container.borrow_mut(); + let view = &mut container.panes_mut().first_mut().expect("main pane").view; + view.follow = true; + view.right_time_ms = right_time_ms; + view.center_price = center_price; + view.price_range = price_range; +} + +/// `engine.rs:ChartEngine::set_scene_visible` dropping `if visible { data.mark_view_dirty(); }` +/// leaves the next shown frame on the camera parked while the chart was hidden, so a covered +/// chart comes back on the old book center after the book has moved. +#[test] +fn reveal_after_a_hidden_book_update_refreshes_center_and_live_edge() { + use std::collections::HashMap; + + use moon_core::market::{MarketDataSource, MarketStore}; + + const CORE: u64 = 42; + const MARKET: &str = "TESTUSDT"; + let epoch = 1_700_000_000_000.0; + let store = MarketStore::shared(epoch); + { + let mut guard = store.write().expect("market store"); + guard.reset(CORE, MARKET); + guard.apply_book(CORE, MARKET, &book(100.0, 101.0)); + } + let source = MarketDataSource::new(store.clone()); + let mut providers = HashMap::new(); + providers.insert(CORE, CORE); + source.set_provider_map(&providers); + + let mut engine = ChartEngine::new(epoch, ChartTheme::default()); + engine.open(CORE, MARKET); + engine.set_scene_visible(true); + engine.set_market_source(Some(source.clone())); + present_frame(&mut engine); + assert!( + !engine.data.borrow().view_dirty, + "the first visible frame must finish its pull" + ); + + // Far from the live edge, and above the first book's mid, so a spurious pull cannot + // leave both numbers sitting on these sentinels. + let sentinel_edge = epoch + 50_000.0; + let sentinel_center = 180.0; + park_camera(&mut engine, sentinel_edge, sentinel_center, 20.0); + engine.set_scene_visible(true); + present_frame(&mut engine); + let (right, center, _) = camera(&engine); + assert_eq!(right, sentinel_edge, "repeated visibility must not pull"); + assert_eq!( + center, sentinel_center, + "repeated visibility must not refit" + ); + + engine.set_scene_visible(false); + { + let mut guard = store.write().expect("market store"); + guard.apply_book(CORE, MARKET, &book(249.0, 251.0)); + } + let (bid, ask) = source + .with_orderbook_view(CORE, MARKET, |view| { + view.and_then(|(book, _)| book.best_bid_ask()) + }) + .expect("the hidden book update is in the store"); + let mid = (bid + ask) * 0.5; + assert!( + mid > sentinel_center, + "fixture mid {mid} must sit above the parked center" + ); + + engine.set_scene_visible(true); + let now_before = moon_core::util::time::now_unix_ms(); + present_frame(&mut engine); + let now_after = moon_core::util::time::now_unix_ms(); + let (right, center, ppm) = camera(&engine); + let q_before = quantized_edge(epoch, ppm, now_before); + let q_after = quantized_edge(epoch, ppm, now_after); + let pixel_ms = 1.0 / f64::from(ppm.max(moon_chart::view::MIN_PX_PER_MS)); + let edge_ok = right == q_before + || right == q_after + || ((right - q_before).abs() <= pixel_ms && (right - now_after).abs() <= 2_000.0); + assert!( + center > sentinel_center && center < mid && edge_ok, + "reveal frame kept hidden center {center} (book mid {mid}) and edge {right} \ + (quantized {q_before}..{q_after})" + ); +} diff --git a/crates/moon-ui-gpui/src/chartdx/mod.rs b/crates/moon-ui-gpui/src/chartdx/mod.rs index ca0d4d65..971bf392 100644 --- a/crates/moon-ui-gpui/src/chartdx/mod.rs +++ b/crates/moon-ui-gpui/src/chartdx/mod.rs @@ -645,7 +645,12 @@ struct PaneRender { /// the uploaded candle layer is still resident. Scaling the band from it would blank the /// band on exactly the gesture that should rescale it. volume_samples: Vec, - /// Widest `tf_ms` among `volume_samples`, bounding the sorted band lookups' windows. + /// Conservative upper bound on `tf_ms` among `volume_samples`, not an exact maximum. + /// + /// A full replacement scans every sample. A patch keeps this bound when it is wider + /// than the new suffix, so a removed maximum may stay high until the next full + /// replacement. Sorted band lookups only use it to widen the candidate window; the + /// exact candle intersection still decides the result. volume_samples_max_tf: f64, /// Visible-range volume max and average behind the band, kept as SEMANTIC values. /// diff --git a/crates/moon-ui-gpui/src/chartdx/render_state.rs b/crates/moon-ui-gpui/src/chartdx/render_state.rs index 25484e56..1d8f8f28 100644 --- a/crates/moon-ui-gpui/src/chartdx/render_state.rs +++ b/crates/moon-ui-gpui/src/chartdx/render_state.rs @@ -727,6 +727,17 @@ impl RenderState { } /// Requests presents for changed chart state and bounded decorations; a steady border is idle. + /// + /// Monotonic decisions — arrival pacing, the present cap, shot-caption expiry, and the + /// camera-shift window — read `info.now`. Camera positions stay on the wall clock. + /// An arrival-only present does not advance the camera. + /// + /// Args: + /// info: Frame input from the GPU canvas. `info.now` drives scheduling; wall-clock + /// `now_unix_ms` still places the camera. + /// + /// Returns: + /// `RequestPresent` when the canvas changed, otherwise `Skip`. pub(super) fn frame(&mut self, info: GpuFrameInfo) -> GpuFrameDecision { crate::diag::bump(&crate::diag::CHART_FRAME); if !info.presentable || info.bounds.is_empty() { @@ -734,13 +745,14 @@ impl RenderState { return GpuFrameDecision::Skip; } + // Wall clock for camera positions. `info.now` is the monotonic clock for pacing. let now_ms = now_unix_ms(); - let now = Instant::now(); + let now = info.now; let mut wants_present = std::mem::take(&mut self.needs_present); if self.firetest_force_present { wants_present = true; } - // Shot caption: ENDED here, from wall clock, with + // Shot caption: ENDED here, from the frame clock, with // no timer, no notify, and nobody to trust with the clear. This is the WATCHDOG, not the // normal path: the shot restores the caption itself as soon as it has its picture, and this // only fires when that chain never completed. Leaving it armed would keep the EXCHANGE on diff --git a/crates/moon-ui-gpui/src/panels/chart/mod.rs b/crates/moon-ui-gpui/src/panels/chart/mod.rs index 959f8fbc..51dd726e 100644 --- a/crates/moon-ui-gpui/src/panels/chart/mod.rs +++ b/crates/moon-ui-gpui/src/panels/chart/mod.rs @@ -381,6 +381,31 @@ pub struct ChartPanel { focus: FocusHandle, } +/// Whether a live-data axis notice may wake this chart's window. +/// +/// Hidden charts never wake, however long the last wake was. A visible chart +/// wakes only when the signature changed and the previous wake is missing or +/// at least `floor` old. +/// +/// Args: +/// visible: Whether this panel's scene is on screen. +/// changed: Whether the data signature differs from the last axis wake. +/// last: Time of the previous axis wake, if one has happened. +/// now: Observation time. +/// floor: Minimum gap between axis wakes. +/// +/// Returns: +/// `true` only when the caller should stamp the wake and notify. +fn chart_axis_notify_due( + visible: bool, + changed: bool, + last: Option, + now: Instant, + floor: Duration, +) -> bool { + visible && changed && last.is_none_or(|t| now.saturating_duration_since(t) >= floor) +} + impl ChartPanel { fn sync_orders_from_backend_notify(&mut self, cx: &mut Context) -> bool { crate::diag::bump(&crate::diag::CHART_ORDER_SYNC); @@ -615,8 +640,13 @@ impl ChartPanel { let now = Instant::now(); let (sig, settings_sig, panic_rev, fav_rev) = { let b = backend.read(cx); + let sig = if this.scene_visible { + this.chart.notify_signature(&b.session) + } else { + this.data_sig + }; ( - this.chart.notify_signature(&b.session), + sig, chart_settings_sig( &b, this.chart_graphics, @@ -660,21 +690,24 @@ impl ChartPanel { crate::diag::bump(&crate::diag::CHART_OBS_NOTIFY); cx.notify(); } - this.data_sig = sig; - // Throttle notification because `gpu_canvas` presents data itself. GPUI notification is - // needed only for the top-down axis overlay and also wakes Orders, so cap it at 4 Hz for - // fast panels and 1 Hz for numbered AddToChart and Custom panels. - // `gpu_canvas.frame()` handles frequent GPU data and state updates without marking GPUI - // dirty. + if this.scene_visible { + this.data_sig = sig; + } + // Axis overlay only. The own pass presents market data itself. A hidden chart + // has no canvas, so a live-data notice must not wake the window. Fast panels + // floor at 250 ms and slow panels at 1 s. Settings and orders above stay immediate. let floor = if this.fast { Duration::from_millis(250) } else { Duration::from_millis(1_000) }; - let notify_due = this - .last_adaptive_notify_at - .is_none_or(|last| now.duration_since(last) >= floor); - if sig != this.last_axis_notify_data_sig && notify_due { + if chart_axis_notify_due( + this.scene_visible, + sig != this.last_axis_notify_data_sig, + this.last_adaptive_notify_at, + now, + floor, + ) { this.last_axis_notify_data_sig = sig; this.last_adaptive_notify_at = Some(now); crate::diag::bump(&crate::diag::CHART_OBS_NOTIFY); @@ -840,8 +873,13 @@ impl ChartPanel { let now = Instant::now(); let (sig, settings_sig, panic_rev, fav_rev) = { let b = backend.read(cx); + let sig = if this.scene_visible { + this.chart.notify_signature(&b.session) + } else { + this.data_sig + }; ( - this.chart.notify_signature(&b.session), + sig, chart_settings_sig( &b, this.chart_graphics, @@ -882,15 +920,19 @@ impl ChartPanel { crate::diag::bump(&crate::diag::CHART_OBS_NOTIFY); cx.notify(); } - this.data_sig = sig; - // Numbered AddToChart and Custom panels are background charts, so cap GPUI notification - // and their top-down Orders redraw at 1 Hz. `gpu_canvas.frame()` handles frequent GPU - // data and state without notification, while the local TTL timer performs time-based - // pruning of unpinned panes. - let notify_due = this - .last_adaptive_notify_at - .is_none_or(|last| now.duration_since(last) >= Duration::from_millis(1_000)); - if sig != this.last_axis_notify_data_sig && notify_due { + if this.scene_visible { + this.data_sig = sig; + } + // Numbered panels cap the axis overlay at 1 Hz. A hidden tile still receives this + // observation, and the helper refuses the wake. The TTL timer prunes unpinned panes + // on its own clock. Settings and orders above stay immediate. + if chart_axis_notify_due( + this.scene_visible, + sig != this.last_axis_notify_data_sig, + this.last_adaptive_notify_at, + now, + Duration::from_millis(1_000), + ) { this.last_axis_notify_data_sig = sig; this.last_adaptive_notify_at = Some(now); crate::diag::bump(&crate::diag::CHART_OBS_NOTIFY); diff --git a/crates/moon-ui-gpui/src/panels/chart/tests.rs b/crates/moon-ui-gpui/src/panels/chart/tests.rs index 1e44cc73..53ee459b 100644 --- a/crates/moon-ui-gpui/src/panels/chart/tests.rs +++ b/crates/moon-ui-gpui/src/panels/chart/tests.rs @@ -215,3 +215,36 @@ fn mouse_down_handlers_offer_a_press_to_their_layers_in_a_fixed_order() { } } } + +/// `panels/chart/mod.rs:chart_axis_notify_due` dropping `visible &&` wakes a hidden chart. +/// +/// The user-visible consequence is axis preparation on every covered chart. Hidden, unchanged, +/// and inside-floor cases are the boundary around that gate. +#[test] +fn hidden_axis_notify_stays_off_until_a_visible_change_clears_the_floor() { + use std::time::{Duration, Instant}; + + let now = Instant::now(); + let floor = Duration::from_millis(250); + let aged = now.checked_sub(floor).expect("floor fits in the clock"); + assert!( + !super::chart_axis_notify_due(false, true, None, now, floor), + "a hidden chart must not wake" + ); + assert!(super::chart_axis_notify_due(true, true, None, now, floor)); + assert!(!super::chart_axis_notify_due(true, false, None, now, floor)); + assert!(!super::chart_axis_notify_due( + true, + true, + Some(now), + now, + floor + )); + assert!(super::chart_axis_notify_due( + true, + true, + Some(aged), + now, + floor + )); +}