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 9ff6c6fb9..9d9d57404 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 e0e007ec0..aa615bba3 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 d95b548bf..c1d3a12a1 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 e70cd4384..84fa280fc 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 ca7464505..822f3309c 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 1bc26cb32..4740d1a6a 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 d3e088c54..ef112db18 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 b6829f4df..45b4cb150 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 2e7e7bd40..8387adc08 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 40fd53154..99e81e9ee 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 cc22e3c97..6a595f24d 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 ca0d4d652..971bf392e 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 25484e56d..1d8f8f28b 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 959f8fbce..51dd726ee 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 1e44cc73d..53ee459b0 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 + )); +}