From 7abc54069f0ff355ae030c24150bc2f43c5e8a92 Mon Sep 17 00:00:00 2001 From: guyverino Date: Thu, 3 Sep 2026 14:32:36 +0200 Subject: [PATCH] fix(chart): give a broomed compare pane to its order book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comparison broom collapses a follower chart to nothing but its order book: the engine hides the price axis, floors the plot at one pixel and hands the book the whole pane. Input never learned that. The panel's hit testing derived the book's width from the Order Book toggle alone, so it kept the book a strip along the right edge — and four fifths of a pane that is entirely order book answered clicks as chart. Orders could only be placed, moved, dragged or cancelled in that strip, while a click further left panned a plot nobody can see, opened the coin on Main or collapsed the stack's fullscreen. The width was derived a second time because there was nowhere to read it from. There is now: `pane_layout` returns both areas — plot and book, placement and size — and `prepare` draws them while the panel hit-tests them, so what is drawn and what is clickable are one statement. Chart navigation in `chartdx/input.rs` had two further copies of the same arithmetic; they are gone too, which fixes the double click that opened a coin over the book's own left part whenever the price axis sat outboard of it, and the wheel that zoomed against a width the engine does not draw when the book is disabled. On a broomed pane every chart gesture is now refused, whatever the "separate control zones" setting says: there is no plot there to pan, zoom, open on Main or leave fullscreen from, and Shift+middle would have published the scale of a one-pixel plot to every chart in the window. "Is this chart or is this the book" is one predicate, `chart_gesture_pane_at`, because figure drawing, figure hit testing and the order-cross gate each used to answer it separately. Two adjacent defects the same flag caused. Broom mode forces the book on even with the window's Order Book toggle cleared, but the subscription followed the toggle, so such a pane rendered an empty book — and, with the whole pane now its trading zone, invited orders against no depth; demand now follows `orderbook_drawn()`, with historical viewers still excluded. And the book's left edge was floored one pixel inside the pane by an expression that cannot return zero, which in broom mode both misplaced what is drawn and left a column of chart at the pane's edge. --- .../src/chartdx/data_state/market.rs | 51 +--- crates/moon-ui-gpui/src/chartdx/engine.rs | 4 +- crates/moon-ui-gpui/src/chartdx/input.rs | 59 +++-- crates/moon-ui-gpui/src/chartdx/mod.rs | 95 ++++++-- crates/moon-ui-gpui/src/chartdx/tests.rs | 168 +++++++++++++ .../src/panels/chart/figures/mod.rs | 11 +- crates/moon-ui-gpui/src/panels/chart/geom.rs | 228 ++++++++++-------- crates/moon-ui-gpui/src/panels/chart/mod.rs | 7 +- crates/moon-ui-gpui/src/panels/chart/refs.rs | 13 +- .../moon-ui-gpui/src/panels/chart/render.rs | 38 ++- .../src/panels/chart/render_input.rs | 13 +- crates/moon-ui-gpui/src/panels/chart/trade.rs | 6 +- .../tests/theme_contract/chart.rs | 33 +++ locales/general.yml | 6 +- 14 files changed, 491 insertions(+), 241 deletions(-) create mode 100644 crates/moon-ui-gpui/src/chartdx/tests.rs 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 31501746..fb95ac5c 100644 --- a/crates/moon-ui-gpui/src/chartdx/data_state/market.rs +++ b/crates/moon-ui-gpui/src/chartdx/data_state/market.rs @@ -248,53 +248,16 @@ impl ChartDataState { pr.gpu_prepare_dirty = true; pixels_changed = true; } - let (axis_pos, price_axis_w, glass_w, chart_w) = horizontal_chart_layout( - rect.w, + let areas = pane_layout( + *rect, self.orderbook_only, self.orderbook_enabled, + self.time_axis_visible, self.price_axis_pos, self.last_ppp, ); - // A hidden time axis reserves no label gutter, allowing the plot to use the full height. - let time_axis_h = if self.time_axis_visible { - moon_chart::TIME_AXIS_H * self.last_ppp - } else { - 0.0 - }; - let plot_h = (rect.h - time_axis_h).max(1.0); - // Left places the axis gutter on the left, shifts the plot right, and keeps the book at - // the right edge. Right starts the plot at the left edge, then places the book and the - // axis gutter to its right. Hide removes the axis, starts the plot at the left edge, - // and keeps the book at the right edge. - let axis_on_left = matches!( - axis_pos, - crate::persistence::chart_persist::PriceAxisPos::Left - ); - let chart_x = if axis_on_left { - rect.x + price_axis_w - } else { - rect.x - }; - let glass_x = if matches!( - axis_pos, - crate::persistence::chart_persist::PriceAxisPos::Right - ) { - chart_x + chart_w - } else { - rect.x + (rect.w - glass_w).max(1.0) - }; - let chart_area = Rect { - x: chart_x, - y: rect.y, - w: chart_w, - h: plot_h, - }; - let glass_area = Rect { - x: glass_x, - y: rect.y, - w: glass_w, - h: plot_h, - }; + let (chart_area, glass_area) = (areas.plot, areas.glass); + let plot_h = chart_area.h; pane.view .ensure_default_window(chart_area.w, self.present_rate_hz, self.default_x_ppm); // A framing request asked for outside a prepared frame lands HERE, at the first width @@ -907,7 +870,7 @@ impl ChartDataState { let mut next_view = view::view_gpu(&pane.view, area_win, res, self.last_ppp, view_style); next_view.pad = view_time0 - + (chart_area.w + glass_w) + + (chart_area.w + glass_area.w) / pane.view.px_per_ms.max(moon_chart::view::MIN_PX_PER_MS); if pr.view != next_view { pr.view = next_view; @@ -1062,7 +1025,7 @@ impl ChartDataState { // Order-book-only mode forces the book on even when the Order Book toggle is cleared. pr.orderbook_only = self.orderbook_only; // Store the effective axis position, including forced hiding in book-only mode, for labels. - pr.price_axis_pos = axis_pos; + pr.price_axis_pos = areas.axis_pos; pr.time_axis_visible = self.time_axis_visible; pr.prospective_usd = self.prospective_usd; let orderbook_on = self.orderbook_enabled || self.orderbook_only; diff --git a/crates/moon-ui-gpui/src/chartdx/engine.rs b/crates/moon-ui-gpui/src/chartdx/engine.rs index fdb3295d..08c1c6b0 100644 --- a/crates/moon-ui-gpui/src/chartdx/engine.rs +++ b/crates/moon-ui-gpui/src/chartdx/engine.rs @@ -1108,8 +1108,8 @@ impl ChartEngine { /// /// The interval is REQUESTED rather than applied: the plot width is knowable only inside a /// prepared frame, and this method is reached from application code that commonly runs before - /// the first present. It used to measure the width itself, through `pane_rects` and - /// `horizontal_chart_layout`, both of which floor an unpresented slot at ONE PIXEL rather than + /// the first present. It used to measure the width itself, through `pane_rects` and the shared + /// pane layout, both of which floor an unpresented slot at ONE PIXEL rather than /// reporting that they do not know - so the interval was framed for a one-pixel plot and the /// visible span at the real width came out wider by the ratio of the two, drawing correct axes /// over an empty plot. The view now holds the request until a real width exists, and re-applies diff --git a/crates/moon-ui-gpui/src/chartdx/input.rs b/crates/moon-ui-gpui/src/chartdx/input.rs index a92a7cfd..964781bd 100644 --- a/crates/moon-ui-gpui/src/chartdx/input.rs +++ b/crates/moon-ui-gpui/src/chartdx/input.rs @@ -8,7 +8,6 @@ use crate::chartdx::pane::Container; use moon_chart::paint::now_unix_ms; use moon_chart::view::{ChartView, Rect}; -use moon_chart::{GLASS_ZONE_PX, PRICE_AXIS_W}; use moon_core::session::CoreId; /// Mouse button subset used by chart navigation instead of `winit::MouseButton`. @@ -37,11 +36,22 @@ pub struct ChartInput { pub hovered_pane: Option, /// Pane layout from the previous render, in device pixels, used for input hit testing. pub pane_rects: Vec<(usize, Rect)>, - /// Price-axis position published during render. + /// Price-axis position published during render, as CONFIGURED — book-only broom mode hides it, + /// and that is resolved by the shared layout, not here. /// /// This controls plot width and the left offset used to calculate `cursor_x`; its default is /// `Left`, matching the panel default. pub price_axis_pos: crate::persistence::chart_persist::PriceAxisPos, + /// Whether this panel draws only its order book, published during render. Broom mode gives the + /// book the whole pane and floors the plot at one pixel, which navigation has to know: the + /// width it pans and zooms against is the drawn one or none of it agrees. + pub orderbook_only: bool, + /// Whether this panel's order book is drawn at all, published during render. With it off the + /// book's width goes back to the plot, so navigation must not keep subtracting it. + pub orderbook_enabled: bool, + /// Whether the time axis reserves its gutter, published during render. Navigation reads only + /// horizontal extents, but the layout it shares with the engine answers for both. + pub time_axis_visible: bool, /// Market queued by an eligible chart double-click for the caller to take and open on Main. pub pending_to_main: Option<(CoreId, String)>, @@ -63,6 +73,20 @@ pub struct ChartInput { } impl ChartInput { + /// This pane's areas as the ENGINE lays them out. Navigation asks the same function `prepare` + /// does, or it pans and zooms against a width nothing drew — a cramped pane's narrowed book, a + /// disabled book's width handed back to the plot, a broom pane's plot floored at one pixel. + fn areas_of(&self, rect: &Rect, ppp: f32) -> crate::chartdx::PaneAreas { + crate::chartdx::pane_layout( + *rect, + self.orderbook_only, + self.orderbook_enabled, + self.time_axis_visible, + self.price_axis_pos, + ppp, + ) + } + fn plot_metrics_for(&self, pane: Option, fallback_w: f32, ppp: f32) -> (f32, f32) { let Some(idx) = pane else { return ( @@ -76,22 +100,9 @@ impl ChartInput { self.last_ptr.0.clamp(0.0, fallback_w.max(1.0)), ); }; - use crate::persistence::chart_persist::PriceAxisPos; - let price_axis_w = if matches!(self.price_axis_pos, PriceAxisPos::Hide) { - 0.0 - } else { - PRICE_AXIS_W * ppp - }; - let glass_w = GLASS_ZONE_PX.min(r.w * 0.5); - let plot_w = (r.w - price_axis_w - glass_w).max(1.0); - // Reserve a left offset only for a left-side axis; right-side or hidden axes start at the slot edge. - let left_off = if matches!(self.price_axis_pos, PriceAxisPos::Left) { - price_axis_w - } else { - 0.0 - }; - let cursor_x = (self.last_ptr.0 - r.x - left_off).clamp(0.0, plot_w); - (plot_w, cursor_x) + let plot = self.areas_of(r, ppp).plot; + let cursor_x = (self.last_ptr.0 - plot.x).clamp(0.0, plot.w); + (plot.w, cursor_x) } /// Return the hovered pane's mutable view for pan or zoom operations. @@ -111,14 +122,16 @@ impl ChartInput { } /// Queue the hovered pane's market after a chart-area double-click. - fn try_dblclick_to_main(&mut self, container: &Container) { + fn try_dblclick_to_main(&mut self, container: &Container, ppp: f32) { let Some(idx) = self.hovered_pane else { return }; let Some((_, r)) = self.pane_rects.iter().find(|(i, _)| *i == idx) else { return; }; - // Ignore double-clicks in the right-side order-book/glass zone. - let glass_w = GLASS_ZONE_PX.min(r.w * 0.5); - if self.last_ptr.0 >= r.x + r.w - glass_w { + // Ignore double-clicks in the order book's own RECTANGLE, wherever the engine put it: left + // of an outboard right-side axis gutter, narrowed on a cramped pane, the whole pane in + // broom mode — where this gesture therefore has nowhere left to fire, which is the intent. + let glass = self.areas_of(r, ppp).glass; + if glass.w > 0.0 && self.last_ptr.0 >= glass.x { return; } self.pending_to_main = container.target(idx); @@ -209,7 +222,7 @@ impl ChartInput { self.last_lmb_ms = now; self.last_lmb_pos = (px, py); if dbl && allow_dbl_to_main { - self.try_dblclick_to_main(container); + self.try_dblclick_to_main(container, ppp); } self.lmb_down = true; self.lmb_x_active = false; diff --git a/crates/moon-ui-gpui/src/chartdx/mod.rs b/crates/moon-ui-gpui/src/chartdx/mod.rs index 802877d0..1d5c8624 100644 --- a/crates/moon-ui-gpui/src/chartdx/mod.rs +++ b/crates/moon-ui-gpui/src/chartdx/mod.rs @@ -48,6 +48,8 @@ mod text; /// The caption editor formats its sample line with the chart's OWN formatter, never a second /// spelling of it. pub(crate) use text::preview_row; +#[cfg(test)] +mod tests; pub mod types; #[cfg(windows)] pub mod userdata; @@ -1090,47 +1092,60 @@ impl ChartDataHandle { } } -/// Resolve the horizontal plot and order-book widths shared by data preparation and navigation. +/// Where a pane's plot and order book sit, in device pixels. +/// +/// Both rectangles together, because they answer one question: a caller handed only widths has to +/// place them itself, which is how three copies of the placement came to exist. The engine draws +/// these rectangles and the panel hit-tests them, so what is DRAWN and what is CLICKABLE are the +/// same arithmetic or they drift — book-only broom mode, where the book takes the whole pane, is +/// the case that punished the drift hardest. +#[derive(Clone, Copy)] +pub(crate) struct PaneAreas { + /// Effective axis position: broom mode hides the price axis whatever the tab configured. + pub axis_pos: crate::persistence::chart_persist::PriceAxisPos, + /// The plot area. Its width is floored at one pixel, so an unpresented slot and a broom pane + /// both report a plot that exists but holds nothing. + pub plot: Rect, + /// The order book's area, `w == 0.0` when no book is drawn. + pub glass: Rect, +} + +/// Lay one pane out into its plot and order-book areas. /// /// Args: -/// rect_w: Full pane width in device pixels. -/// orderbook_only: Whether the plot collapses behind the order book. -/// orderbook_enabled: Whether the normal order-book zone is visible. +/// rect: The pane's full rectangle in device pixels. +/// orderbook_only: Whether the plot collapses behind the order book (broom mode). +/// orderbook_enabled: Whether the ordinary order-book zone is drawn. +/// time_axis_visible: Whether the time axis reserves its gutter under both areas. /// price_axis_pos: Configured per-tab price-axis position. /// pixel_scale: Device pixels per logical pixel. /// /// Returns: -/// Effective axis position, axis width, order-book width, and plot width. -fn horizontal_chart_layout( - rect_w: f32, +/// The pane's [`PaneAreas`]. +pub(crate) fn pane_layout( + rect: Rect, orderbook_only: bool, orderbook_enabled: bool, + time_axis_visible: bool, price_axis_pos: crate::persistence::chart_persist::PriceAxisPos, pixel_scale: f32, -) -> ( - crate::persistence::chart_persist::PriceAxisPos, - f32, - f32, - f32, -) { +) -> PaneAreas { + use crate::persistence::chart_persist::PriceAxisPos; let axis_pos = if orderbook_only { - crate::persistence::chart_persist::PriceAxisPos::Hide + PriceAxisPos::Hide } else { price_axis_pos }; - let price_axis_w = if matches!( - axis_pos, - crate::persistence::chart_persist::PriceAxisPos::Hide - ) { + let price_axis_w = if matches!(axis_pos, PriceAxisPos::Hide) { 0.0 } else { moon_chart::PRICE_AXIS_W * pixel_scale }; - let glass_cap = rect_w * 0.5; + let glass_cap = rect.w * 0.5; let glass_base = moon_chart::GLASS_ZONE_PX.min(glass_cap); - let chart_w_base = rect_w - price_axis_w - glass_base; + let chart_w_base = rect.w - price_axis_w - glass_base; let glass_w = if orderbook_only { - (rect_w - price_axis_w).max(1.0) + (rect.w - price_axis_w).max(1.0) } else if !orderbook_enabled { 0.0 } else if chart_w_base < glass_base * 2.0 { @@ -1138,8 +1153,42 @@ fn horizontal_chart_layout( } else { glass_base }; - let chart_w = (rect_w - price_axis_w - glass_w).max(1.0); - (axis_pos, price_axis_w, glass_w, chart_w) + let chart_w = (rect.w - price_axis_w - glass_w).max(1.0); + // Left puts the axis gutter on the left and shifts the plot right; Right and Hide start the + // plot at the pane's edge. The book follows the plot only for a right-side axis, which leaves + // that gutter outboard of it; otherwise it sits against the pane's right edge. + let chart_x = if matches!(axis_pos, PriceAxisPos::Left) { + rect.x + price_axis_w + } else { + rect.x + }; + let glass_x = if matches!(axis_pos, PriceAxisPos::Right) { + chart_x + chart_w + } else { + rect.x + (rect.w - glass_w).max(0.0) + }; + // A hidden time axis reserves no label gutter, letting both areas use the full height. + let time_axis_h = if time_axis_visible { + moon_chart::TIME_AXIS_H * pixel_scale + } else { + 0.0 + }; + let h = (rect.h - time_axis_h).max(1.0); + PaneAreas { + axis_pos, + plot: Rect { + x: chart_x, + y: rect.y, + w: chart_w, + h, + }, + glass: Rect { + x: glass_x, + y: rect.y, + w: glass_w, + h, + }, + } } struct ChartDataState { diff --git a/crates/moon-ui-gpui/src/chartdx/tests.rs b/crates/moon-ui-gpui/src/chartdx/tests.rs new file mode 100644 index 00000000..a2eb50c9 --- /dev/null +++ b/crates/moon-ui-gpui/src/chartdx/tests.rs @@ -0,0 +1,168 @@ +//! Unit tests for the pane layout every chart surface shares. + +use super::*; +use crate::persistence::chart_persist::PriceAxisPos; + +const PANE: Rect = Rect { + x: 40.0, + y: 12.0, + w: 900.0, + h: 400.0, +}; + +fn areas(pane: Rect, broom: bool, book: bool, axis: PriceAxisPos) -> PaneAreas { + pane_layout(pane, broom, book, true, axis, 1.0) +} + +/// Both areas stay inside the pane and neither overlaps the other, whatever the flags — the +/// property every hit test depends on, checked across the combinations rather than per case. +#[test] +fn the_two_areas_tile_the_pane_without_overlapping() { + for broom in [false, true] { + for book in [false, true] { + for axis in [PriceAxisPos::Left, PriceAxisPos::Right, PriceAxisPos::Hide] { + let a = areas(PANE, broom, book, axis); + let case = format!("broom={broom} book={book} axis={axis:?}"); + assert!(a.plot.x >= PANE.x, "plot starts left of the pane ({case})"); + assert!( + a.plot.x + a.plot.w <= PANE.x + PANE.w, + "plot runs past the pane ({case})" + ); + assert!(a.glass.x >= PANE.x, "book starts left of the pane ({case})"); + assert!( + a.glass.x + a.glass.w <= PANE.x + PANE.w, + "book runs past the pane ({case})" + ); + // A plot floored at one pixel is the collapsed one broom mode leaves behind; it + // sits inside the book by construction and has nothing to overlap. + if a.glass.w > 0.0 && a.plot.w > 1.0 { + assert!( + a.plot.x + a.plot.w <= a.glass.x || a.glass.x + a.glass.w <= a.plot.x, + "plot and book overlap ({case})" + ); + } + } + } + } +} + +/// The ordinary pane: an axis gutter on the left, the book flush against the right edge, and the +/// plot filling everything between them. +#[test] +fn a_left_axis_leaves_the_plot_between_its_gutter_and_the_book() { + let a = areas(PANE, false, true, PriceAxisPos::Left); + assert!(matches!(a.axis_pos, PriceAxisPos::Left)); + assert_eq!(a.glass.w, moon_chart::GLASS_ZONE_PX); + assert_eq!(a.glass.x + a.glass.w, PANE.x + PANE.w); + assert_eq!(a.plot.x, PANE.x + moon_chart::PRICE_AXIS_W); + assert_eq!(a.plot.x + a.plot.w, a.glass.x); +} + +/// A right-side axis puts its gutter OUTBOARD of the book, so the book is not flush right. Measuring +/// the book back from the pane's right edge instead — which two hit tests used to do — left its +/// left part answering as chart. +#[test] +fn a_right_axis_sits_outboard_of_the_book() { + let a = areas(PANE, false, true, PriceAxisPos::Right); + assert_eq!(a.plot.x, PANE.x); + assert_eq!(a.glass.x, a.plot.x + a.plot.w); + assert_eq!( + a.glass.x + a.glass.w + moon_chart::PRICE_AXIS_W, + PANE.x + PANE.w + ); +} + +/// A pane too narrow to seat a full book beside a usable plot gets a narrower book rather than no +/// plot at all. +#[test] +fn a_cramped_pane_narrows_the_book_and_keeps_a_plot() { + let narrow = Rect { + w: moon_chart::PRICE_AXIS_W + moon_chart::GLASS_ZONE_PX * 2.5, + ..PANE + }; + let a = areas(narrow, false, true, PriceAxisPos::Left); + assert!(a.glass.w < moon_chart::GLASS_ZONE_PX && a.glass.w > 0.0); + assert!(a.plot.w > a.glass.w); +} + +#[test] +fn a_disabled_book_gives_its_width_back_to_the_plot() { + let a = areas(PANE, false, false, PriceAxisPos::Left); + assert_eq!(a.glass.w, 0.0); + assert_eq!(a.plot.x + a.plot.w, PANE.x + PANE.w); +} + +/// The case the panel's hit testing exists to agree with: in broom mode the book IS the pane, edge +/// to edge, so a click anywhere on it is a book click and there is no plot left to pan. +#[test] +fn broom_mode_hands_the_whole_pane_to_the_book() { + let a = areas(PANE, true, true, PriceAxisPos::Left); + assert!(matches!(a.axis_pos, PriceAxisPos::Hide)); + assert_eq!(a.glass.x, PANE.x); + assert_eq!(a.glass.w, PANE.w); + assert_eq!(a.plot.w, 1.0); +} + +/// Broom mode draws the book even with the window's own Order Book toggle cleared — `ChartDataState` +/// sets `orderbook_on = orderbook_enabled || orderbook_only` — so the layout must not take the +/// disabled branch and hand the pane to a plot nobody draws. +#[test] +fn broom_mode_outranks_a_cleared_order_book_toggle() { + let a = areas(PANE, true, false, PriceAxisPos::Left); + assert_eq!(a.glass.x, PANE.x); + assert_eq!(a.glass.w, PANE.w); +} + +/// A right-side axis is hidden by broom mode like any other, so the book reaches both edges instead +/// of leaving a gutter nothing draws into. +#[test] +fn broom_mode_hides_a_right_side_axis_too() { + let a = areas(PANE, true, true, PriceAxisPos::Right); + assert!(matches!(a.axis_pos, PriceAxisPos::Hide)); + assert_eq!(a.glass.w, PANE.w); +} + +/// The time axis reserves its gutter under BOTH areas, and hiding it gives that height back — +/// the vertical half of the same answer, so a caller cannot take one half from here and derive the +/// other itself. +#[test] +fn the_time_axis_gutter_shortens_both_areas() { + let with = pane_layout(PANE, false, true, true, PriceAxisPos::Left, 1.0); + let without = pane_layout(PANE, false, true, false, PriceAxisPos::Left, 1.0); + assert_eq!(with.plot.h, with.glass.h); + assert_eq!(without.plot.h, PANE.h); + assert_eq!(PANE.h - with.plot.h, moon_chart::TIME_AXIS_H); +} + +/// Both areas scale with the display: at 2x device pixels the reserved gutters double, which is what +/// keeps a hit test in device pixels agreeing with what was drawn on a HiDPI screen. +#[test] +fn the_reserved_gutters_follow_the_pixel_scale() { + let one = pane_layout(PANE, false, true, true, PriceAxisPos::Left, 1.0); + let two = pane_layout(PANE, false, true, true, PriceAxisPos::Left, 2.0); + assert_eq!(two.plot.x - PANE.x, (one.plot.x - PANE.x) * 2.0); + assert_eq!(PANE.h - two.plot.h, (PANE.h - one.plot.h) * 2.0); +} + +/// An unpresented slot reports a width of zero. Every number still has to come back finite, because +/// hit tests run against this layout before the first frame is drawn — and a broom pane's book has +/// to start at the pane rather than beside a gutter that mode does not reserve. +/// +/// The plot is deliberately NOT asserted to be inside such a pane: a left-side axis reserves its +/// gutter regardless, which puts the plot past the right edge of a zero-width pane. Harmless, +/// because a pane of no width holds no pointer, and the first real frame replaces these numbers. +#[test] +fn an_unpresented_slot_stays_finite() { + for broom in [false, true] { + let a = areas(Rect { w: 0.0, ..PANE }, broom, true, PriceAxisPos::Left); + for v in [ + a.plot.x, a.plot.w, a.plot.h, a.glass.x, a.glass.w, a.glass.h, + ] { + assert!(v.is_finite(), "non-finite geometry for broom={broom}"); + } + assert_eq!( + a.glass.x, PANE.x, + "book starts off the pane for broom={broom}" + ); + } +} diff --git a/crates/moon-ui-gpui/src/panels/chart/figures/mod.rs b/crates/moon-ui-gpui/src/panels/chart/figures/mod.rs index 5f5f9c52..bac1d923 100644 --- a/crates/moon-ui-gpui/src/panels/chart/figures/mod.rs +++ b/crates/moon-ui-gpui/src/panels/chart/figures/mod.rs @@ -95,8 +95,8 @@ impl ChartPanel { let Some(pane) = self.input.pane_at(pos.0, pos.1) else { return false; }; - // Leave the order-book/reserved control zone to trading input. - if self.glass_pane_at(pos).is_some() { + // Leave trading space — the order book, its reserved strip, a whole broom pane — alone. + if self.chart_gesture_pane_at(pos).is_none() { return false; } let Some(map) = self.pane_map(pane) else { @@ -629,10 +629,7 @@ impl ChartPanel { /// Return the nearest figure-body ID under the cursor within the scaled hit threshold. fn fig_hit_at(&self, pos: (f32, f32), cx: &Context) -> Option { - let pane = self.input.pane_at(pos.0, pos.1)?; - if self.glass_pane_at(pos).is_some() { - return None; - } + let pane = self.chart_gesture_pane_at(pos)?; let (core, market) = self.fig_pane_key(pane)?; let map = self.pane_map(pane)?; let threshold = HIT_PX * self.last_ppp.max(1.0); @@ -691,7 +688,7 @@ impl ChartPanel { window: &mut Window, cx: &mut Context, ) -> bool { - // `fig_hit_at` excludes the order-book zone through `glass_pane_at`. + // `fig_hit_at` excludes trading space through `chart_gesture_pane_at`. let Some(id) = self.fig_hit_at(local_pos, cx) else { return false; }; diff --git a/crates/moon-ui-gpui/src/panels/chart/geom.rs b/crates/moon-ui-gpui/src/panels/chart/geom.rs index c5f2b5d2..e1a480fa 100644 --- a/crates/moon-ui-gpui/src/panels/chart/geom.rs +++ b/crates/moon-ui-gpui/src/panels/chart/geom.rs @@ -91,6 +91,11 @@ impl ChartPanel { .separate_control_zones } + /// Returns whether a window position lies over the order book, or over the strip reserved for + /// it, whatever the zone setting says: the Main stack asks this to hand the WHEEL to the stack + /// instead of zooming the chart under it. It reads the same rectangle as + /// [`Self::control_zone_rect`], so a book-only broom pane — book across its whole width — gives + /// the stack the wheel everywhere on it rather than only along its right edge. pub(crate) fn window_pos_in_glass_zone(&self, pos: Point) -> bool { let Some(((x, y), within)) = self.chart_local(pos) else { return false; @@ -98,33 +103,67 @@ impl ChartPanel { if !within { return false; } - let rects = if self.input.pane_rects.is_empty() { - self.chart.pane_rects() - } else { - self.input.pane_rects.clone() - }; - rects.iter().any(|(_, r)| { - if x < r.x || x > r.x + r.w || y < r.y || y > r.y + r.h { - return false; - } - let glass_w = moon_chart::GLASS_ZONE_PX.min(r.w * 0.5); - x >= r.x + r.w - glass_w + self.with_pane_rects(|rects| { + rects.iter().any(|(_, r)| { + if x < r.x || x > r.x + r.w || y < r.y || y > r.y + r.h { + return false; + } + // Everything from the zone's left edge rightwards, over the pane's FULL height: the + // wheel belongs to the stack over the book, over an axis gutter beside it and over + // the time-axis band beneath it alike. Only that left edge is a boundary here. + x >= self.control_zone_of(*r).x + }) }) } - /// Returns whether a window position lies in the trading control zone while zones are separate: - /// the visible order book or the reserved right strip when it is hidden. This is the same - /// `control_zone_rect` used for order placement. Trading actions, order dragging, order menus, - /// and hotkeys remain active there, while pan, zoom, Main navigation, and fullscreen toggling are - /// suppressed. Returns false when Main uses unified zones. + /// Returns whether a window position is closed to chart gestures — pan, zoom, the open-on-Main + /// double click, fullscreen toggling — because it belongs to trading instead. + /// + /// Two different reasons answer yes, and they are separate questions. A book-only broom pane + /// says yes over ALL of it: there is no plot on it to pan or open, so nothing there can mean + /// chart, and the Settings toggle has no say — it governs whether to split a pane that HAS + /// both. An ordinary pane says yes inside its control zone while that toggle is on, where + /// trading actions, order dragging, order menus and hotkeys stay live. pub(crate) fn window_pos_in_control_zone(&self, pos: Point, cx: &App) -> bool { - if !self.separate_zones(cx) { + // Cheapest first: an ordinary pane under unified zones answers no without touching geometry, + // and that is the common case on Main. + if !self.orderbook_only && !self.separate_zones(cx) { return false; } let Some((local, within)) = self.chart_local(pos) else { return false; }; - within && self.glass_pane_at(local).is_some() + // On no pane at all — an empty stack slot, the gap between panes — the answer is no. Chart + // gestures have nothing to act on there either, but claiming the point would swallow the + // press instead of leaving it to whoever owns that space. + within + && self.pane_at_with_fallback(local).is_some() + && self.chart_gesture_pane_at(local).is_none() + } + + /// The pane holding a local point, from the render's published rectangles or, before the first + /// render publishes any, the engine's current layout. + /// + /// `ChartInput::pane_at` answers the same question WITHOUT that fallback; every hit test in + /// this file goes through here so the two halves of one gesture cannot disagree on a chart + /// whose first frame has not landed. + pub(super) fn pane_at_with_fallback(&self, pos: (f32, f32)) -> Option { + self.with_pane_rects(|rects| local_pane_rect_at(pos.0, pos.1, rects)) + .map(|(idx, _)| idx) + } + + /// The pane whose CHART SPACE holds this point, or `None` when the point belongs to trading — + /// the order book, the strip reserved for it, or anywhere on a book-only broom pane. + /// + /// The one statement of "this is chart, not book", so a gesture added later cannot get the + /// question half right: figure drawing, figure hit testing and the chart-space order-cross gate + /// all ask it, and each of them used to spell it out again. + pub(super) fn chart_gesture_pane_at(&self, pos: (f32, f32)) -> Option { + let pane = self.pane_at_with_fallback(pos)?; + if self.orderbook_only || self.glass_pane_at(pos).is_some() { + return None; + } + Some(pane) } /// Returns whether a position is inside any pane rectangle, including its glass/order-book zone. @@ -137,12 +176,7 @@ impl ChartPanel { if !within { return false; } - let rects = if self.input.pane_rects.is_empty() { - self.chart.pane_rects() - } else { - self.input.pane_rects.clone() - }; - local_pos_in_any_pane_rect(x, y, &rects) + self.with_pane_rects(|rects| local_pos_in_any_pane_rect(x, y, rects)) } /// Returns whether the latest right-button gesture moved the price scale rather than clicking. @@ -150,6 +184,20 @@ impl ChartPanel { self.input.rmb_moved() } + /// Run `f` over this panel's pane rectangles in device pixels. + /// + /// Input arrives before the first render has published `input.pane_rects` — a wheel event over + /// a freshly opened chart — so the engine's current layout stands in for them. Borrowed rather + /// than returned: hit testing runs on the pointer path, and the steady-state branch must not + /// allocate a vector per event. + fn with_pane_rects(&self, f: impl FnOnce(&[(usize, moon_chart::view::Rect)]) -> R) -> R { + if self.input.pane_rects.is_empty() { + f(&self.chart.pane_rects()) + } else { + f(&self.input.pane_rects) + } + } + fn local_pane_rect(&self, pane: usize) -> Option { self.input .pane_rects @@ -165,71 +213,23 @@ impl ChartPanel { }) } - fn local_pane_areas( - &self, - pane: usize, - ) -> Option<(moon_chart::view::Rect, moon_chart::view::Rect)> { - let rect = self.local_pane_rect(pane)?; - // Approximate the ordinary pane split for input hit-testing: Left, Right, or Hide shifts the - // plot, book, and axis gutter. Broom mode hides the local axis, but unlike ChartDataState - // this helper still derives glass width from `orderbook_enabled` and does not model the - // engine's full-width book-only rendering. - use crate::persistence::chart_persist::PriceAxisPos; - let axis_pos = if self.orderbook_only { - PriceAxisPos::Hide - } else { - self.price_axis_pos - }; - let price_axis_w = if matches!(axis_pos, PriceAxisPos::Hide) { - 0.0 - } else { - moon_chart::PRICE_AXIS_W * self.last_ppp - }; - let time_axis_h = if self.time_axis_visible { - moon_chart::TIME_AXIS_H * self.last_ppp - } else { - 0.0 - }; - let plot_h = (rect.h - time_axis_h).max(1.0); - let glass_cap = rect.w * 0.5; - let glass_base = moon_chart::GLASS_ZONE_PX.min(glass_cap); - let chart_w_base = rect.w - price_axis_w - glass_base; - let glass_w = if !self.orderbook_enabled { - 0.0 - } else if chart_w_base < glass_base * 2.0 { - (moon_chart::GLASS_ZONE_PX * 0.8).min(glass_cap) - } else { - glass_base - }; - let axis_on_left = matches!(axis_pos, PriceAxisPos::Left); - let chart_x = if axis_on_left { - rect.x + price_axis_w - } else { - rect.x - }; - let chart_w = (rect.w - price_axis_w - glass_w).max(1.0); - let glass_x = if matches!(axis_pos, PriceAxisPos::Right) { - chart_x + chart_w - } else { - rect.x + (rect.w - glass_w).max(1.0) - }; - let plot = moon_chart::view::Rect { - x: chart_x, - y: rect.y, - w: chart_w, - h: plot_h, - }; - let glass = moon_chart::view::Rect { - x: glass_x, - y: rect.y, - w: glass_w, - h: plot_h, - }; - Some((plot, glass)) + /// This pane's areas as the ENGINE lays them out — the same call `prepare` makes, so hit + /// testing cannot answer for a layout that was never drawn. The copy that used to live here + /// derived the book's width from the Order Book toggle alone and never learned about book-only + /// broom mode, where the book takes the whole pane. + fn local_pane_areas(&self, rect: moon_chart::view::Rect) -> crate::chartdx::PaneAreas { + crate::chartdx::pane_layout( + rect, + self.orderbook_only, + self.orderbook_enabled, + self.time_axis_visible, + self.price_axis_pos, + self.last_ppp, + ) } pub(super) fn local_plot_rect(&self, pane: usize) -> Option { - self.local_pane_areas(pane).map(|(plot, _)| plot) + Some(self.local_pane_areas(self.local_pane_rect(pane)?).plot) } /// Build a pane's plot mapping, or return `None` when the pane has no valid view. @@ -261,37 +261,41 @@ impl ChartPanel { }) } - fn local_glass_rect(&self, pane: usize) -> Option { - self.local_pane_areas(pane).map(|(_, glass)| glass) + /// Whether this panel DRAWS an order book, which is not the same question as its Order Book + /// toggle: broom mode draws one regardless, exactly as `ChartDataState` decides it. + pub(super) fn orderbook_drawn(&self) -> bool { + self.orderbook_enabled || self.orderbook_only } - /// Returns a pane's order-control zone in device pixels. With the book visible on a narrow pane, - /// the local glass width is `(GLASS_ZONE_PX * 0.8).min(rect.w * 0.5)`. With the book hidden it - /// reserves the full capped base width, `GLASS_ZONE_PX.min(rect.w * 0.5)`, over the chart's right - /// edge so order interaction and the boundary marker remain available. pub(super) fn control_zone_rect(&self, pane: usize) -> Option { - if self.orderbook_enabled { - return self.local_glass_rect(pane).filter(|g| g.w > 0.0); + Some(self.control_zone_of(self.local_pane_rect(pane)?)) + } + + /// A pane's order-control zone in device pixels, taken from the pane rectangle the caller + /// already holds. + /// + /// The book's OWN area whenever one is drawn, so a cramped pane's narrowed book and a book-only + /// broom pane's full-width one are each exactly the zone they look like. With no book at all it + /// reserves `GLASS_ZONE_PX.min(rect.w * 0.5)` over the chart's right edge instead, so order + /// interaction and the boundary marker still have somewhere to live. + pub(super) fn control_zone_of(&self, rect: moon_chart::view::Rect) -> moon_chart::view::Rect { + let areas = self.local_pane_areas(rect); + if self.orderbook_drawn() { + return areas.glass; } - let rect = self.local_pane_rect(pane)?; - let time_axis_h = if self.time_axis_visible { - moon_chart::TIME_AXIS_H * self.last_ppp - } else { - 0.0 - }; - let plot_h = (rect.h - time_axis_h).max(1.0); let w = moon_chart::GLASS_ZONE_PX.min(rect.w * 0.5); - Some(moon_chart::view::Rect { - x: rect.x + (rect.w - w).max(1.0), + moon_chart::view::Rect { + x: rect.x + (rect.w - w).max(0.0), y: rect.y, w, - h: plot_h, - }) + h: areas.plot.h, + } } pub(super) fn glass_pane_at(&self, pos: (f32, f32)) -> Option { - let pane = self.input.pane_at(pos.0, pos.1)?; + let pane = self.pane_at_with_fallback(pos)?; let zone = self.control_zone_rect(pane)?; + // A zone of zero width is a pane with no book and no reserved strip; nothing to be inside. (zone.w > 0.0 && pos.0 >= zone.x && pos.0 <= zone.x + zone.w @@ -320,7 +324,17 @@ impl ChartPanel { } fn local_pos_in_any_pane_rect(x: f32, y: f32, rects: &[(usize, moon_chart::view::Rect)]) -> bool { + local_pane_rect_at(x, y, rects).is_some() +} + +/// The pane rectangle holding a point, if any. +fn local_pane_rect_at( + x: f32, + y: f32, + rects: &[(usize, moon_chart::view::Rect)], +) -> Option<(usize, moon_chart::view::Rect)> { rects .iter() - .any(|(_, r)| x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) + .find(|(_, r)| x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) + .copied() } diff --git a/crates/moon-ui-gpui/src/panels/chart/mod.rs b/crates/moon-ui-gpui/src/panels/chart/mod.rs index 670a37f3..04a7cea9 100644 --- a/crates/moon-ui-gpui/src/panels/chart/mod.rs +++ b/crates/moon-ui-gpui/src/panels/chart/mod.rs @@ -455,7 +455,7 @@ impl ChartPanel { // rather than a rule every future Live call site has to remember. panel.chart.set_historical(true); // Turned off through the same field the live path uses, so the engine's own layout gives - // the book's width back to the plot (`horizontal_chart_layout` sets `glass_w = 0`). + // the book's width back to the plot (`pane_layout` gives the book zero width). panel.orderbook_enabled = false; // `new_main` retained a book reference for the focus market a moment ago; this releases it // before any coordination tick can read the demand set, so a historical viewer never @@ -1471,10 +1471,15 @@ impl ChartPanel { } /// Enables book-only broom mode: rendering hides the plot and price axis and expands the book. + /// + /// The book-reference sync runs for the same reason [`Self::set_orderbook_enabled`] runs it: + /// this mode draws the book whether or not that toggle is set, and depth arrives only for a + /// subscribed market. pub fn set_orderbook_only(&mut self, only: bool, cx: &mut Context) { if self.orderbook_only != only { self.orderbook_only = only; self.view_dirty = true; + self.sync_orderbook_refs(cx); cx.notify(); } } diff --git a/crates/moon-ui-gpui/src/panels/chart/refs.rs b/crates/moon-ui-gpui/src/panels/chart/refs.rs index 5d586731..4a5e8360 100644 --- a/crates/moon-ui-gpui/src/panels/chart/refs.rs +++ b/crates/moon-ui-gpui/src/panels/chart/refs.rs @@ -75,10 +75,17 @@ impl ChartPanel { } } - /// Synchronizes backend order-book references to this panel's markets when the book is enabled, - /// or to an empty set when disabled. Called after market-set changes and book toggles. + /// Synchronizes backend order-book references to this panel's markets when the book is drawn, + /// or to an empty set when it is not. Called after market-set changes and book toggles. + /// + /// "Drawn" is [`ChartPanel::orderbook_drawn`], not this window's Order Book toggle: broom mode + /// shows the book with that toggle cleared, and a subscription following the toggle alone left + /// such a pane rendering an empty book — with, since the whole pane is its trading zone, an + /// invitation to place orders against no depth. pub(super) fn sync_orderbook_refs(&mut self, cx: &mut App) { - let want: HashSet<(CoreId, String)> = if self.orderbook_enabled { + // A HISTORICAL viewer holds no live subscription whatever the flags say: `new_historical` + // clears `orderbook_enabled` for that reason, and broom mode must not be a way back in. + let want: HashSet<(CoreId, String)> = if !self.historical && self.orderbook_drawn() { self.registered_markets.clone() } else { HashSet::new() diff --git a/crates/moon-ui-gpui/src/panels/chart/render.rs b/crates/moon-ui-gpui/src/panels/chart/render.rs index e7a589ea..d38e26ba 100644 --- a/crates/moon-ui-gpui/src/panels/chart/render.rs +++ b/crates/moon-ui-gpui/src/panels/chart/render.rs @@ -240,12 +240,13 @@ impl Render for ChartPanel { // bounds. Input hit testing receives the engine's current pane rectangles separately. let axis_panes = self.chart.axis_panes(); self.input.pane_rects = self.chart.pane_rects(); - // Input hit testing needs the axis side to account for plot inset/width. Broom mode hides it. - self.input.price_axis_pos = if self.orderbook_only { - crate::persistence::chart_persist::PriceAxisPos::Hide - } else { - self.price_axis_pos - }; + // Input hit testing takes the inputs `chartdx::pane_layout` needs and lets it derive the + // effective axis position — including the hiding broom mode applies — rather than being + // handed a pre-resolved one that only half the arithmetic knew about. + self.input.price_axis_pos = self.price_axis_pos; + self.input.orderbook_only = self.orderbook_only; + self.input.orderbook_enabled = self.orderbook_enabled; + self.input.time_axis_visible = self.time_axis_visible; // Place each corner close button on its graph pane in Main and AddToChart. Closing Main's // coin returns it to the logo. Convert pane-layout device pixels to slot logical pixels, // and collect these positions once for the overlay list. @@ -306,7 +307,9 @@ impl Render for ChartPanel { }; // With separate zones and a hidden order book, shade the right-side order control zone so // users can distinguish order-placement clicks from chart double-clicks that open Main. - // A visible book already marks this area, so do not duplicate it. Tuple fields are + // A visible book already marks this area, so do not duplicate it. Neither does a book-only + // broom pane, whose book covers the whole slot: there is no boundary left to draw, and a + // strip on the right would name one where the whole pane trades. Tuple fields are // (idx, logical left, logical top, logical width, logical height), converted from axis_panes // device pixels by dividing by ppp, like the close buttons. // `!self.historical` for the reason the strip exists at all: it marks where an @@ -315,26 +318,15 @@ impl Render for ChartPanel { let show_zone_marker = self.show_zone && !self.historical && self.separate_zones(cx) - && !self.orderbook_enabled; + && !self.orderbook_drawn(); let zone_markers: Vec<(usize, f32, f32, f32, f32)> = if show_zone_marker { axis_panes .iter() .map(|(idx, rect, _)| { - let zone_w = moon_chart::GLASS_ZONE_PX.min(rect.w * 0.5); - // A hidden time axis reserves no label gutter, so the zone reaches the slot bottom. - let time_axis_h = if self.time_axis_visible { - moon_chart::TIME_AXIS_H * ppp - } else { - 0.0 - }; - let plot_h = (rect.h - time_axis_h).max(1.0); - ( - *idx, - (rect.x + rect.w - zone_w) / ppp, - rect.y / ppp, - zone_w / ppp, - plot_h / ppp, - ) + // The rectangle the CLICKS use, converted to logical pixels — shading anything + // else would promise a boundary the hit test does not honour. + let zone = self.control_zone_of(*rect); + (*idx, zone.x / ppp, zone.y / ppp, zone.w / ppp, zone.h / ppp) }) .collect() } else { diff --git a/crates/moon-ui-gpui/src/panels/chart/render_input.rs b/crates/moon-ui-gpui/src/panels/chart/render_input.rs index 4c64c0c8..28160c1e 100644 --- a/crates/moon-ui-gpui/src/panels/chart/render_input.rs +++ b/crates/moon-ui-gpui/src/panels/chart/render_input.rs @@ -121,6 +121,12 @@ pub(super) fn scroll_wheel( if this.main_stack_scroll && this.window_pos_in_glass_zone(e.position) { return; } + // A book-only broom pane has no plot to zoom: its X window is one pixel wide, so a wheel here + // moves nothing on screen and only feeds a nonsense scale to whatever reads the view later. + // Left unconsumed on purpose, so a surrounding stack scrolls instead. + if this.orderbook_only { + return; + } let sf = window.scale_factor(); let Some((pos, within)) = this.chart_local(e.position) else { return; @@ -602,8 +608,11 @@ pub(super) fn mouse_down_middle( return; } // Shift+middle-click on the graph synchronizes the time X scale across charts in THIS window, - // matching Moonbot. A trading gesture bound to Shift+middle-click takes priority above. - if within && e.modifiers.shift && this.sync_x_scale_window(window, cx) { + // matching Moonbot. A trading gesture bound to Shift+middle-click takes priority above. Never + // from a book-only broom pane: its plot is floored at one pixel, so `ensure_default_window` has + // already rebuilt `px_per_ms` for a one-pixel window, and publishing THAT would rescale every + // chart in the window to a span of months. + if within && !this.orderbook_only && e.modifiers.shift && this.sync_x_scale_window(window, cx) { cx.stop_propagation(); } } diff --git a/crates/moon-ui-gpui/src/panels/chart/trade.rs b/crates/moon-ui-gpui/src/panels/chart/trade.rs index 69849e9f..8390c011 100644 --- a/crates/moon-ui-gpui/src/panels/chart/trade.rs +++ b/crates/moon-ui-gpui/src/panels/chart/trade.rs @@ -685,7 +685,7 @@ impl ChartPanel { } // Use the hover gate in separate-zone chart space so only the start cross competes. A nearer // Sell line must not shadow a cross that was presented with the pointer cursor. - let cross_only = self.separate_zones(cx) && self.glass_pane_at(pos).is_none(); + let cross_only = self.separate_zones(cx) && self.chart_gesture_pane_at(pos).is_some(); let Some(hit) = self.hit_order_line(pos, cross_only, cx) else { return false; }; @@ -993,7 +993,7 @@ impl ChartPanel { self.order_hover_probe = Some(pos); // In separate-zone mode, full line interaction belongs to the order book. In chart space, // use the reduced hit test for the click-to-cancel start cross only. - let cross_only = self.separate_zones(cx) && self.glass_pane_at(pos).is_none(); + let cross_only = self.separate_zones(cx) && self.chart_gesture_pane_at(pos).is_some(); let next = self .hit_order_line(pos, cross_only, cx) .map(|hit| OrderHoverKey { @@ -1041,7 +1041,7 @@ impl ChartPanel { return false; } // Separate-zone mode permits order-line dragging only inside the order book. - if self.separate_zones(cx) && self.glass_pane_at(pos).is_none() { + if self.separate_zones(cx) && self.chart_gesture_pane_at(pos).is_some() { return false; } let Some(hit) = self.hit_order_line(pos, false, cx) else { diff --git a/crates/moon-ui-gpui/tests/theme_contract/chart.rs b/crates/moon-ui-gpui/tests/theme_contract/chart.rs index e8aaac0a..ec6c0be7 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/chart.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/chart.rs @@ -983,3 +983,36 @@ fn chart_label_name_budget_uses_the_button_s_ui_scale() { "name_budget must subtract MoonButton padding with the UI scale" ); } + +/// Every hit test in the chart reads the engine's own `chartdx::pane_layout` instead of deriving a +/// second copy of the split. `local_pane_areas` answers where a click lands; `plot_metrics_for` +/// feeds wheel zoom, drag pan and snap-to-live; `try_dblclick_to_main` decides where the +/// open-on-Main double click is refused. Each derived its own book width once, which is how they +/// came to work against a plot the engine does not draw — narrowed on a cramped pane, absent when +/// the book is off, and on a book-only broom pane floored at a single pixel while the book covers +/// everything. +#[test] +fn chart_hit_testing_reads_the_engines_own_pane_layout() { + const READERS: &[(&str, &str)] = &[ + ("panels/chart/geom.rs", "fn local_pane_areas("), + ("chartdx/input.rs", "fn areas_of("), + ("chartdx/input.rs", "fn plot_metrics_for("), + ("chartdx/input.rs", "fn try_dblclick_to_main("), + ]; + for (file, signature) in READERS { + let source = code_only(&read_src(file)); + let body = braced_body(&source, signature); + assert!( + !body.contains("GLASS_ZONE_PX"), + "{file}:{signature} must not derive an order-book width of its own" + ); + } + for (file, signature) in READERS { + let source = code_only(&read_src(file)); + let body = braced_body(&source, signature); + assert!( + body.contains("pane_layout(") || body.contains("areas_of("), + "{file}:{signature} must take its geometry from the shared layout" + ); + } +} diff --git a/locales/general.yml b/locales/general.yml index 215bb48e..0bfe7cef 100644 --- a/locales/general.yml +++ b/locales/general.yml @@ -39,9 +39,9 @@ general.separate_control_zones: en: "Separate control zones" es: "Zonas de control separadas" general.separate_control_zones_hint: - ru: "Только для вкладки Main: вкл — ордера/линии ТОЛЬКО в зоне стакана (справа), дабл-клик по чарту разворачивает монету; выкл — по всей области. На Add-вкладках и выносных окнах зоны разделены ВСЕГДА (стакан справа, чарт слева → дабл-клик на Main); при скрытом стакане граница зоны показывается риской." - en: "Main tab only: on — orders/lines ONLY in the order-book zone (right), double-click on the chart expands the coin; off — anywhere. On Add tabs and detached windows the zones are ALWAYS split (order book right, chart left → double-click opens on Main); with the order book hidden the zone boundary is shown as a marker line." - es: "Solo pestaña Main: act — órdenes/líneas SOLO en la zona del libro (derecha), doble clic en el gráfico expande la moneda; des — en toda el área. En pestañas Add y ventanas separadas las zonas SIEMPRE están divididas (libro a la derecha, gráfico a la izquierda → doble clic abre en Main); con el libro oculto el borde de la zona se muestra con una línea." + ru: "Только для вкладки Main: вкл — ордера/линии ТОЛЬКО в зоне стакана (справа), дабл-клик по чарту разворачивает монету; выкл — по всей области. На Add-вкладках и выносных окнах зоны разделены ВСЕГДА (стакан справа, чарт слева → дабл-клик на Main); при скрытом стакане граница зоны показывается риской. Чарт, свёрнутый метлой сравнения до стакана, не делится вовсе: стакан занимает всю его площадь, и чартовых жестов — панорамы, дабл-клика на Main, выхода из фуллскрина — на нём не остаётся." + en: "Main tab only: on — orders/lines ONLY in the order-book zone (right), double-click on the chart expands the coin; off — anywhere. On Add tabs and detached windows the zones are ALWAYS split (order book right, chart left → double-click opens on Main); with the order book hidden the zone boundary is shown as a marker line. A chart broomed down to its order book in comparison mode is not split at all: the book covers every part of it, and no chart gesture — pan, the open-on-Main double click, leaving fullscreen — is left on it." + es: "Solo pestaña Main: act — órdenes/líneas SOLO en la zona del libro (derecha), doble clic en el gráfico expande la moneda; des — en toda el área. En pestañas Add y ventanas separadas las zonas SIEMPRE están divididas (libro a la derecha, gráfico a la izquierda → doble clic abre en Main); con el libro oculto el borde de la zona se muestra con una línea. Un gráfico reducido con la escoba de comparación a su libro no se divide en absoluto: el libro ocupa toda su superficie y no queda en él ningún gesto de gráfico: paneo, doble clic para abrir en Main o salir de pantalla completa." general.main_idle_close: ru: "Автозакрытие графиков Main при неактивности" en: "Auto-close Main charts when idle"