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"