diff --git a/crates/moon-core/src/market/trade_replay/mod.rs b/crates/moon-core/src/market/trade_replay/mod.rs index 36bbc2a0..fa66f57a 100644 --- a/crates/moon-core/src/market/trade_replay/mod.rs +++ b/crates/moon-core/src/market/trade_replay/mod.rs @@ -712,6 +712,10 @@ pub struct TradeReplaySeries { /// a [`TradeReplaySource::Ticks`] series carries these TOO — the EXCHANGE's own one-minute /// klines, not bars aggregated from [`Self::ticks`], so the bar layer covers the WHOLE window /// even where the points, per [`Self::partial`], do not. + /// + /// Stored whole; DRAWN only where the points are not. [`Self::read_into`] withholds every bar + /// lying wholly inside the span [`Self::ticks`] covers, so the two layers never overlay each + /// other and the bars are left holding exactly the edges the points never reached. pub candles: Vec, /// Trade points in ascending time. Empty when [`Self::source`] is /// [`TradeReplaySource::Klines1m`]; carried alongside [`Self::candles`] for @@ -730,6 +734,19 @@ pub struct TradeReplaySeries { /// Whether [`Self::ticks`] covers only PART of [`Self::window`] — the bars always cover all /// of it. Always `false` on a [`TradeReplaySource::Klines1m`] series. pub partial: bool, + /// The inclusive span [`Self::ticks`] is guaranteed EXHAUSTIVE over, or `None` when there was + /// no tick walk at all ([`TradeReplaySource::Klines1m`]). + /// + /// Carried straight from `worker::TickHarvest::covered`, the walk's own answer, and NOT + /// re-derived from the rows: clipping proves every row is inside the span, never that the + /// first and last rows ARE its edges. A completed boundary slice whose opening minute simply + /// saw no trade is exhaustively covered while carrying no point there, and only this field + /// knows it — which is what lets [`Self::read_into`] withhold that minute's bar instead of + /// leaving one stray candle floating inside the tick trace. + /// + /// [`Self::partial`] is the BOOLEAN read of this same span against [`Self::window`]; this is + /// the span itself. + pub covered: Option<(i64, i64)>, } impl TradeReplaySeries { @@ -859,6 +876,26 @@ impl TradeReplaySeries { true => crate::market::candles::resample(&clipped, tf_ms, &mut out.candles), false => out.candles.extend(clipped), } + // WHERE THE POINTS ARE, THE BARS STEP ASIDE. A tick series carries the exchange's + // own one-minute klines for the WHOLE window (see `Self::candles`), which is what + // keeps the edges the points never reached drawn — but inside the covered span + // the two layers are the same trades told twice, drawn on top of each other. + // + // Decided from the WALK's own interval, never from pixels and never from the rows: + // `Self::covered` is what the tick stage proved exhaustive, while the extrema of + // the points are merely a subset of it — a covered minute the venue happened to + // publish no trade in would keep its bar under a row-derived rule and read as a + // stray candle floating inside the trace. `None` there is a `Klines1m` series, + // which is what keeps a still-loading window whole: the bar-only stage walked no + // ticks, so nothing is hidden until the upgrade lands. + // + // Applied AFTER the aggregation above so one rule covers both paths, and to the + // OUTPUT timeframe, which is the width the caller actually draws. `candle_tf_ms` + // is never filled on this path, so there is no parallel array to desync. + if let Some(covered) = self.covered { + out.candles + .retain(|c| !bar_inside(c.t_open_ms, tf_ms, covered)); + } read.candles_changed = true; } } @@ -880,6 +917,28 @@ impl TradeReplaySeries { } } +/// Whether one bar lies WHOLLY inside a covered span. +/// +/// A bar that STRADDLES an edge stays drawn: half of it is over ground the points never reached, +/// so it is context rather than an overlay, and dropping it would leave a gap the user reads as +/// missing data. That is also what makes the window's own caption honest — the edges really are +/// the part still closed by candles. +/// +/// Args: +/// t_open_ms: The bar's opening stamp; a non-finite one is never inside anything. +/// tf_ms: The bar's width, at the timeframe it is DRAWN at. +/// covered: Inclusive span from [`TradeReplaySeries::covered`]. +/// +/// Returns: +/// `true` when the whole bar sits inside the span. +fn bar_inside(t_open_ms: f64, tf_ms: i64, covered: (i64, i64)) -> bool { + if !t_open_ms.is_finite() { + return false; + } + let open = t_open_ms as i64; + open >= covered.0 && open.saturating_add(tf_ms.max(1)) - 1 <= covered.1 +} + /// Lowest and highest finite positive price across a run of trade points. /// /// Args: diff --git a/crates/moon-core/src/market/trade_replay/tests.rs b/crates/moon-core/src/market/trade_replay/tests.rs index c1bbbd72..813b2ff0 100644 --- a/crates/moon-core/src/market/trade_replay/tests.rs +++ b/crates/moon-core/src/market/trade_replay/tests.rs @@ -36,6 +36,7 @@ fn bars_only_series() -> TradeReplaySeries { tick_status: TickStatus::Pending, bucket_ms: 0, partial: false, + covered: None, } } @@ -156,6 +157,58 @@ fn replay_repeat_keeps_candle_range_after_bars_are_already_shipped() { ); } +/// `market/trade_replay/mod.rs:bar_inside` must reject only wholly contained bars; relaxing it +/// to an overlap drops the right edge candle and leaves a blank gutter beside the tick trace. +#[test] +fn replay_ticks_keep_both_straddling_edge_candles() { + let mut series = bars_only_series(); + series.source = TradeReplaySource::Ticks; + series.covered = Some((MINUTE_MS / 2, 5 * MINUTE_MS / 2)); + let mut out = ChartHistoryBuffers::default(); + + series.read_into( + 0.0, + 0.0, + (2 * MINUTE_MS) as f32, + Some(&candle_params(0)), + &mut out, + ); + + assert_eq!( + out.candles + .iter() + .map(|candle| candle.t_open_ms as i64) + .collect::>(), + vec![0, 2 * MINUTE_MS], + "only the wholly covered middle candle may step aside; both straddling edge candles close the tick trace" + ); +} + +/// `market/trade_replay/mod.rs:TradeReplaySeries::read_into` must leave a `covered: None` +/// Klines1m series whole; applying the hide with its window span blanks the fallback chart while ticks load. +#[test] +fn replay_bars_only_series_keeps_every_candle_without_tick_coverage() { + let series = bars_only_series(); + let mut out = ChartHistoryBuffers::default(); + + series.read_into( + 0.0, + 0.0, + (2 * MINUTE_MS) as f32, + Some(&candle_params(0)), + &mut out, + ); + + assert_eq!( + out.candles + .iter() + .map(|candle| candle.t_open_ms as i64) + .collect::>(), + vec![0, MINUTE_MS, 2 * MINUTE_MS], + "a bars-only fallback must keep every one-minute candle instead of rendering an empty chart" + ); +} + /// `market/trade_replay/mod.rs:cache_covers` must enforce both edges and its one-bar allowance; /// widening the allowance or dropping the right-edge check silently reuses incomplete exit bars. #[test] diff --git a/crates/moon-core/src/market/trade_replay/worker.rs b/crates/moon-core/src/market/trade_replay/worker.rs index 047be1f9..3a624da2 100644 --- a/crates/moon-core/src/market/trade_replay/worker.rs +++ b/crates/moon-core/src/market/trade_replay/worker.rs @@ -919,6 +919,7 @@ fn serve_ticks( ticks, bucket_ms, partial, + covered, stage.candles.clone(), ), venue_refused, @@ -1148,6 +1149,9 @@ where /// ticks: Trade points, ascending, already clipped to the harvest's covered range. /// bucket_ms: The bucket [`fit_ticks`] thinned the points to; `0` means raw. /// partial: Whether `ticks` covers only part of `request.window`. +/// covered: The walk's own exhaustive span, carried onto the series verbatim — the chart +/// withholds the bars lying inside it, and only this range knows that a covered minute +/// with no trade in it is still covered. /// candles: The exchange klines to carry as the bar layer. /// /// Returns: @@ -1158,6 +1162,7 @@ fn compose_ticks( ticks: Vec, bucket_ms: i64, partial: bool, + covered: (i64, i64), candles: Vec, ) -> TradeReplaySeries { TradeReplaySeries { @@ -1171,6 +1176,7 @@ fn compose_ticks( tick_status: TickStatus::Served, bucket_ms, partial, + covered: Some(covered), } } @@ -1223,6 +1229,8 @@ fn compose( tick_status: TickStatus::Pending, bucket_ms: 0, partial: false, + // No tick walk ran, so there is no covered span and the chart keeps every bar. + covered: None, } } diff --git a/crates/moon-ui-gpui/src/panels/detects/mod.rs b/crates/moon-ui-gpui/src/panels/detects/mod.rs index 8d65ce3a..891b5ace 100644 --- a/crates/moon-ui-gpui/src/panels/detects/mod.rs +++ b/crates/moon-ui-gpui/src/panels/detects/mod.rs @@ -696,32 +696,44 @@ impl Render for DetectsPanel { shown += 1; } - // An empty feed states WHY it is empty instead of painting a blank pane under the gear. - // Same element the Log panel uses for `log.empty_filtered` (`panels/log/view.rs`): one - // centred, muted line filling the space the card grid would have taken. It replaces the - // scroll box rather than sitting inside it — an empty scroll container would still own the - // `flex_1` slot and leave the sentence pinned to the top-left corner. + // An empty feed states WHY it is empty instead of painting a blank pane under the gear. It + // replaces the scroll box rather than sitting inside it — an empty scroll container would + // still own the `flex_1` slot and leave the sentence pinned to the top-left corner. + // + // A COLUMN, not the Log panel's centred row, and the text carries its own definite width. + // These sentences are whole clauses where `log.empty_filtered` is two words, so at a ~290px + // side dock they have to WRAP — and a centred flex ROW cannot wrap them. GPUI measures text + // with `wrap_width = known_dimensions.width.or(Definite(available))` (moon-gpui + // `elements/text.rs:650`), so the min-content probe taffy runs to fix a row item's automatic + // minimum width comes back with the whole one-line sentence: the item refuses to shrink, + // overflows a narrow panel symmetrically under `justify_center`, and is clipped on BOTH + // sides. `.text_center()` cannot save it — it centres lines inside a box already wider than + // the panel. Giving the text `w_full` makes its width DEFINITE, which is the one input the + // measure above needs; `max_w` then keeps a wide, undocked panel from stretching one clause + // across the whole pane. Same shape as `analytics/render.rs`'s `quote_split_note`, and the + // row-axis mirror of the rule pinned in `tests/theme_contract/shell.rs`. let body: AnyElement = if shown == 0 { - div() + v_flex() .flex_1() .w_full() .min_h(px(0.0)) - .flex() .items_center() .justify_center() - // `items_center` + `justify_center` centre the text BOX, not the lines inside it. - // The Log panel needs no more than that because its empty copy is two words; these - // sentences wrap at any realistic dock width, and without this the centred empty - // state reads left-aligned exactly where it is most cramped. - .text_center() - .p_2() + .px_3() + .py_2() .text_size(crate::design::t_body(cx)) .text_color(rgb(p.text_soft)) - .child(empty_feed_text( - &marker, - retained_reachable, - available_cores, - )) + .child( + div() + .w_full() + .max_w(crate::design::font_w_px(cx, 560.0)) + .text_center() + .child(empty_feed_text( + &marker, + retained_reachable, + available_cores, + )), + ) .into_any_element() } else { div() diff --git a/crates/moon-ui-gpui/src/trade_window/render.rs b/crates/moon-ui-gpui/src/trade_window/render.rs index bd443696..3d2da7e9 100644 --- a/crates/moon-ui-gpui/src/trade_window/render.rs +++ b/crates/moon-ui-gpui/src/trade_window/render.rs @@ -261,12 +261,19 @@ impl TradeWindowView { .child(self.panel.clone()) // The caption is a requirement, not decoration: a one-minute picture of a // forty-second scalp is an honest answer only while it says which it is. + // + // `t_body`, NOT `t_caption`: this is the one line saying WHAT is on screen — + // ticks, bucketed ticks, or candles and the reason for them — so it must not be + // the smallest text in the window. It now matches the figures rail's VALUES + // (`figures.rs`, `t_body`) rather than its field labels, which is the right + // company for it. A design step, never a hard-coded size, so the Font slider and + // the UI scale keep carrying it. .child( div() .absolute() .left(design::ui_px(cx, 8.0)) .bottom(design::ui_px(cx, 6.0)) - .text_size(design::t_caption(cx)) + .text_size(design::t_body(cx)) .text_color(moon(p.text_muted)) .child(caption), ) diff --git a/crates/moon-ui-gpui/tests/theme_contract/detects.rs b/crates/moon-ui-gpui/tests/theme_contract/detects.rs new file mode 100644 index 00000000..4570049e --- /dev/null +++ b/crates/moon-ui-gpui/tests/theme_contract/detects.rs @@ -0,0 +1,57 @@ +//! Static contracts for the Detects panel's narrow-dock empty state. + +use super::support::*; + +/// `panels/detects/mod.rs:DetectsPanel::render` must keep the empty feed in a column and give its +/// sentence a definite width. Replacing `v_flex()` with a centred flex row or hanging +/// `empty_feed_text(...)` directly on that row makes every empty-state sentence render as one line +/// clipped on both sides in the reported roughly 290-pixel side-dock screenshot. +#[test] +fn detects_empty_state_keeps_a_column_and_a_definite_text_width() { + let source = read_src("panels/detects/mod.rs"); + let render = code_only(braced_body( + &source, + "fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement", + )); + let empty_state = render + .split_once("let body: AnyElement = if shown == 0 {") + .unwrap_or_else(|| { + panic!("the Detects render must retain its shown == 0 empty-state branch") + }) + .1 + .split_once("} else {") + .unwrap_or_else(|| { + panic!("the Detects empty state must remain separate from its scroll box") + }) + .0 + .trim_start(); + + assert!( + empty_state.starts_with("v_flex()"), + "the Detects empty state must stay a column so its sentence can wrap instead of clipping on both sides in a narrow side dock" + ); + assert!( + !empty_state.starts_with("div().flex().items_center()"), + "the Detects empty state must not restore the centred flex row that produced the clipped narrow-dock screenshot" + ); + + let sentence_box = empty_state + .split_once("div()") + .unwrap_or_else(|| { + panic!("the Detects empty sentence needs its own box to avoid the clipped narrow-dock screenshot") + }) + .1 + .split_once(".child(empty_feed_text(") + .unwrap_or_else(|| { + panic!("the Detects empty sentence must stay inside its own box instead of clipping in a narrow side dock") + }) + .0; + assert!( + sentence_box.contains(".w_full()"), + "the Detects empty sentence box must have a definite width so every empty state wraps instead of clipping on both sides" + ); + assert!( + sentence_box.contains(".text_center()"), + "wrapped Detects empty-state lines must remain centred in the narrow-dock screenshot" + ); +} diff --git a/crates/moon-ui-gpui/tests/theme_contract/main.rs b/crates/moon-ui-gpui/tests/theme_contract/main.rs index 4bb7e194..f685fb7a 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/main.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/main.rs @@ -18,6 +18,7 @@ mod analytics; mod chart; mod core_pick; mod core_status; +mod detects; mod naming; mod shared_config; mod shell;