From edc1ec9c0806a207e1dcb2241f83c2312e6cd184 Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:42:31 +0200 Subject: [PATCH 1/2] fix(trade-window): hide the candles under the tick trace and enlarge the source caption Where the tick trace covered a span, the 1-minute candles were still painted underneath it, so entry and exit read as two layers on top of each other. The replay series now carries the span the tick walk actually covered, and the window withholds every bar lying wholly inside it; a bar straddling either edge stays, so the edges the caption promises are still closed by candles. The span comes from the walk itself rather than from the tick rows' extrema, because a covered minute in which the venue published no trade would otherwise show a candle in the middle of the trace. The bottom-left source caption moves from the caption size to the body size: it is the one line that says what the window is drawing and was the smallest text on screen. --- .../moon-core/src/market/trade_replay/mod.rs | 59 +++++++++++++++++++ .../src/market/trade_replay/tests.rs | 53 +++++++++++++++++ .../src/market/trade_replay/worker.rs | 8 +++ .../moon-ui-gpui/src/trade_window/render.rs | 9 ++- 4 files changed, 128 insertions(+), 1 deletion(-) 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/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), ) From 5f786b0ae46c173c5ea9c5de6546202f12666626 Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:44:38 +0200 Subject: [PATCH 2/2] fix(detects): wrap the empty-state sentence instead of clipping it in a narrow dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Detects panel's empty-state sentence sat in a centred flex row. GPUI derives a text element's wrap width only from a definite width, so the layout probe measured the whole one-line string as the item's minimum: it could not shrink, overflowed a ~290 px side dock symmetrically and was clipped on both sides, leaving a fragment like «а нет — карточка появится, когда ср» on screen. The element is now a column whose sentence box carries a definite full width with a horizontal inset and centred text, so the sentence wraps onto as many lines as the panel needs and stays one line where the panel is wide. All three empty-state sentences share the site. A static contract pins the shape. --- crates/moon-ui-gpui/src/panels/detects/mod.rs | 48 ++++++++++------ .../tests/theme_contract/detects.rs | 57 +++++++++++++++++++ .../moon-ui-gpui/tests/theme_contract/main.rs | 1 + 3 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 crates/moon-ui-gpui/tests/theme_contract/detects.rs 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/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;