Skip to main content

egui_charts/widget/pan_zoom/
mod.rs

1//! Pan, zoom, and scroll interaction logic for the chart widget.
2//!
3//! This module implements all viewport manipulation interactions:
4//!
5//! - **Mouse wheel** -- vertical scroll zooms the time axis (or the price axis
6//!   when hovering over it); horizontal scroll pans; Shift+wheel pans.
7//! - **Pinch-to-zoom** -- trackpad / mobile two-finger pinch gesture.
8//! - **Drag-to-pan** -- left-click drag scrolls the chart; dragging on the
9//!   price axis scales prices; dragging on the time axis zooms time.
10//! - **Kinetic scrolling** -- momentum-based animation after a flick gesture.
11//! - **Box zoom** -- left-click drag (in zoom mode) zooms into the selected
12//!   rectangular region.
13//! - **Double-click reset** -- double-click on the time axis jumps to latest;
14//!   double-click on the price axis re-enables auto-scale.
15//! - **Timescale config** -- applies pending visible-bar and width settings.
16
17mod box_zoom;
18pub(super) mod state;
19
20pub(super) use box_zoom::render_box_zoom;
21
22use egui::{Event, MouseWheelUnit, Pos2, Rect, Response, Ui, Vec2, vec2};
23use web_time::Instant;
24
25use crate::scales::PriceTransform;
26use crate::widget::Chart;
27
28/// This frame's wheel scroll in points before egui smooths it: line and page units converted, and
29/// the axis forced by egui's horizontal and vertical scroll modifiers. Unlike
30/// `smooth_scroll_delta` it includes scrolling with the zoom modifier (Ctrl, or Cmd on macOS),
31/// which egui turns into zoom instead.
32pub(super) fn raw_wheel_delta(ui: &Ui) -> Vec2 {
33    let options = ui.ctx().options(|o| o.input_options);
34    ui.input(|i| {
35        let mut sum = Vec2::ZERO;
36        for event in &i.events {
37            if let Event::MouseWheel {
38                unit,
39                delta,
40                modifiers,
41                ..
42            } = event
43            {
44                let mut delta = match unit {
45                    MouseWheelUnit::Point => *delta,
46                    MouseWheelUnit::Line => options.line_scroll_speed * *delta,
47                    MouseWheelUnit::Page => i.viewport_rect().height() * *delta,
48                };
49                let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier);
50                let vertical = modifiers.matches_any(options.vertical_scroll_modifier);
51                if horizontal && !vertical {
52                    delta = vec2(delta.x + delta.y, 0.0);
53                } else if vertical && !horizontal {
54                    delta = vec2(0.0, delta.x + delta.y);
55                }
56                sum += delta;
57            }
58        }
59        sum
60    })
61}
62
63impl Chart {
64    /// Handle mouse wheel events for time-axis zoom, price-axis zoom, and horizontal pan.
65    ///
66    /// Returns `Some(delta_y)` when the wheel was over the price axis, signaling
67    /// that price zoom should be applied downstream (deferred to avoid borrow conflicts).
68    pub(crate) fn handle_mouse_wheel(
69        &mut self,
70        ui: &Ui,
71        response: &Response,
72        chart_width: f32,
73        chart_rect_min_x: f32,
74        price_axis_rect: Rect,
75    ) -> Option<f32> {
76        let mut pending_price_zoom = None;
77        let scale = self.chart_options.handle_scale;
78        let scroll = self.chart_options.handle_scroll;
79        let scale_wheel = scale.enabled && scale.mouse_wheel;
80        let scroll_wheel = scroll.enabled && scroll.mouse_wheel;
81
82        if !response.hovered() || (!scale_wheel && !scroll_wheel) {
83            return pending_price_zoom;
84        }
85
86        let raw = raw_wheel_delta(ui);
87        let scroll_input = ui.input(|i| {
88            let mut dx = if i.smooth_scroll_delta.x.abs() > 0.0 {
89                i.smooth_scroll_delta.x
90            } else {
91                raw.x
92            };
93            let mut dy = if i.smooth_scroll_delta.y.abs() > 0.0 {
94                i.smooth_scroll_delta.y
95            } else {
96                raw.y
97            };
98
99            // Treat Shift+Wheel as horizontal scroll for standard mice
100            if i.modifiers.shift && dx.abs() < 0.1 && dy.abs() > 0.1 {
101                dx = dy;
102                dy = 0.0;
103            }
104            (
105                dy,
106                dx,
107                i.pointer.hover_pos(),
108                i.modifiers.command || i.modifiers.ctrl,
109            )
110        });
111        let (delta_y, delta_x, hover_pos, _is_modifier_held) = scroll_input;
112
113        let mut did_zoom = false;
114        if delta_y.abs() > 0.1 {
115            let shift_held = ui.input(|i| i.modifiers.shift);
116
117            if shift_held && scroll_wheel {
118                let pan_amount = -delta_y * 2.0;
119                self.state.time_scale_mut().scroll_pixels(pan_amount);
120            } else if scale_wheel && let Some(hp) = hover_pos.or_else(|| response.hover_pos()) {
121                if price_axis_rect.contains(hp) {
122                    pending_price_zoom = Some(delta_y);
123                } else {
124                    let zoom_scale = (-delta_y / 100.0).clamp(-0.5, 0.5);
125                    // Zoom around the pointer, or the right edge while the right bar stays on scroll
126                    let zoom_point_x = self.zoom_anchor(hp.x - chart_rect_min_x, chart_width);
127
128                    log::debug!(
129                        "[ZOOM INPUT] mouse_x={:.1}, chart_min_x={:.1}, anchor_x={:.1}, chart_width={:.1}",
130                        hp.x,
131                        chart_rect_min_x,
132                        zoom_point_x,
133                        chart_width
134                    );
135
136                    self.state.time_scale_mut().zoom(
137                        zoom_scale,
138                        zoom_point_x,
139                        chart_rect_min_x,
140                        chart_width,
141                    );
142                    did_zoom = true;
143
144                    // CRITICAL: Reset drag state after zoom to prevent jump
145                    // If user was dragging while zooming, the old start_offset is now invalid
146                    if self.scroll_start_offset.is_some() {
147                        self.scroll_start_offset =
148                            Some(self.state.time_scale().right_offset_precise());
149                        self.scroll_start_pos = response.interact_pointer_pos();
150                    }
151                }
152            }
153        }
154
155        if !did_zoom && delta_y.abs() < 0.1 && delta_x.abs() > 0.1 && scroll_wheel {
156            self.state.time_scale_mut().scroll_pixels(delta_x);
157        }
158
159        pending_price_zoom
160    }
161
162    /// Handles pinch-to-zoom for touch/trackpad gestures
163    ///
164    /// Native zoom events and contact-based pinches share the pointer anchor.
165    /// Contact gestures take precedence when a backend reports both.
166    pub(crate) fn handle_pinch_zoom(
167        &mut self,
168        ui: &Ui,
169        response: &Response,
170        chart_width: f32,
171        chart_rect_min_x: f32,
172    ) {
173        if !response.hovered() {
174            return;
175        }
176
177        // Get multi-touch zoom delta (1.0 = no zoom, >1.0 = zoom in, <1.0 = zoom out)
178        let zoom_info = ui.input(|i| {
179            i.multi_touch()
180                .map(|mt| (mt.zoom_delta, mt.translation_delta, i.pointer.hover_pos()))
181                .or_else(|| {
182                    // Wheel zoom is handled separately, including its Ctrl/Cmd variant.
183                    let native_zoom: f32 = i
184                        .events
185                        .iter()
186                        .filter_map(|event| {
187                            if let Event::Zoom(delta) = event {
188                                Some(*delta)
189                            } else {
190                                None
191                            }
192                        })
193                        .product();
194                    (native_zoom != 1.0).then_some((native_zoom, Vec2::ZERO, i.pointer.hover_pos()))
195                })
196        });
197
198        if let Some((zoom_delta, translation_delta, hover_pos)) = zoom_info {
199            // Handle pinch zoom
200            if self.chart_options.handle_scale.enabled
201                && self.chart_options.handle_scale.pinch
202                && (zoom_delta - 1.0).abs() > 0.001
203            {
204                // Convert zoom_delta to our zoom scale format
205                // zoom_delta > 1.0 means fingers spreading (zoom in)
206                // zoom_delta < 1.0 means fingers pinching (zoom out)
207                let zoom_scale = (zoom_delta - 1.0) * 2.0;
208                self.pending_scroll_time = None;
209                self.tracking_mode_active = false;
210                self.kinetic_scroll.is_active = false;
211
212                // Use hover position or center of chart as zoom anchor
213                let zoom_point_x = self.zoom_anchor(
214                    hover_pos.map_or(chart_width / 2.0, |p| p.x - chart_rect_min_x),
215                    chart_width,
216                );
217
218                log::debug!(
219                    "[PINCH ZOOM] zoom_delta={zoom_delta:.4}, zoom_scale={zoom_scale:.4}, anchor_x={zoom_point_x:.1}"
220                );
221
222                self.state.time_scale_mut().zoom(
223                    zoom_scale,
224                    zoom_point_x,
225                    chart_rect_min_x,
226                    chart_width,
227                );
228
229                // Reset drag state to prevent jump after pinch
230                if self.scroll_start_offset.is_some() {
231                    self.scroll_start_offset = Some(self.state.time_scale().right_offset_precise());
232                    self.scroll_start_pos = response.interact_pointer_pos();
233                }
234            }
235
236            // Handle two-finger pan (translation during pinch)
237            if self.chart_options.handle_scroll.enabled
238                && self.chart_options.handle_scroll.horz_touch_drag
239                && translation_delta.x.abs() > 0.5
240            {
241                self.pending_scroll_time = None;
242                self.state
243                    .time_scale_mut()
244                    .scroll_pixels(translation_delta.x);
245            }
246            if self.chart_options.handle_scroll.enabled
247                && self.chart_options.handle_scroll.vert_touch_drag
248                && self.last_rendered_price_rect.height() > 0.0
249                && self.state.price_scale().is_manual()
250                && translation_delta.y.abs() > 0.5
251            {
252                let y = hover_pos.map_or(0.0, |pos| pos.y);
253                let map = self.last_price_map;
254                let shift = map.y_to_scale(y - translation_delta.y) - map.y_to_scale(y);
255                let range = self.state.price_scale().range();
256                self.state
257                    .price_scale_mut()
258                    .set_range(range.min + shift, range.max + shift);
259            }
260        }
261    }
262
263    /// Handle drag-to-pan, price-axis scaling, and time-axis drag-zoom.
264    ///
265    /// When the drag starts on the main chart area, horizontal movement pans the
266    /// time axis and, while auto-scale is off, vertical movement pans the price
267    /// axis. Starting on the price axis enables price scale dragging.
268    /// Starting on the time axis enables drag-to-zoom the time scale. Also
269    /// tracks velocity for kinetic scrolling continuation after release.
270    pub(crate) fn handle_drag_pan(
271        &mut self,
272        ui: &Ui,
273        response: &Response,
274        price_axis_rect: Rect,
275        time_axis_rect: Rect,
276        chart_rect_min_x: f32,
277        has_active_drawing_tool: bool,
278    ) {
279        // CRITICAL: Skip panning if box zoom is active (right-click drag)
280        let box_zoom_active = self.box_zoom.active;
281        let (is_touch, multi_touch) = ui.input(|i| {
282            (
283                i.any_touches()
284                    || i.events
285                        .iter()
286                        .any(|event| matches!(event, Event::Touch { .. })),
287                i.multi_touch().is_some(),
288            )
289        });
290
291        // Touch and mouse drags pan and continue under their own options.
292        let kinetic = self.chart_options.handle_scroll.enabled
293            && if is_touch {
294                self.chart_options.handle_scroll.horz_touch_drag
295                    && self.chart_options.kinetic_scroll.enabled
296            } else {
297                self.chart_options.handle_scroll.pressed_mouse_move
298                    && self.chart_options.kinetic_scroll.mouse
299            };
300
301        if !response.dragged() || multi_touch || has_active_drawing_tool || box_zoom_active {
302            // Drag ended - handle kinetic scrolling
303            if response.drag_stopped()
304                && !has_active_drawing_tool
305                && !multi_touch
306                && kinetic
307                && self.scroll_start_pos.is_some()
308            {
309                let move_distance = self
310                    .kinetic_scroll
311                    .last_pos
312                    .and_then(|last| self.scroll_start_pos.map(|start| (last.x - start.x).abs()))
313                    .unwrap_or(0.0);
314
315                let min_v_px_s = self.chart_options.kinetic_scroll.min_scroll_speed * 60.0;
316                let max_v_px_s = self.chart_options.kinetic_scroll.max_scroll_speed * 60.0;
317
318                if move_distance >= self.chart_options.kinetic_scroll.scroll_min_move
319                    && self.kinetic_scroll.velocity.abs() >= min_v_px_s
320                {
321                    self.kinetic_scroll.is_active = true;
322                    self.kinetic_scroll.velocity =
323                        self.kinetic_scroll.velocity.clamp(-max_v_px_s, max_v_px_s);
324                    self.kinetic_scroll.anim_last_time = Some(Instant::now());
325                }
326            }
327
328            if multi_touch {
329                self.kinetic_scroll.is_active = false;
330                // Rebase the pointer drag while contacts own translation, including finger lifts.
331                self.scroll_start_pos = response.interact_pointer_pos();
332                self.scroll_start_offset = Some(self.state.time_scale().right_offset_precise());
333            } else {
334                self.scroll_start_pos = None;
335                self.scroll_start_offset = None;
336            }
337            self.kinetic_scroll.last_pos = None;
338            self.kinetic_scroll.last_time = None;
339            return;
340        }
341
342        // Stop kinetic animation when user starts dragging
343        self.kinetic_scroll.is_active = false;
344
345        if self.scroll_start_pos.is_none() {
346            self.scroll_start_pos = ui.input(|i| i.pointer.press_origin());
347            self.scroll_start_offset = Some(self.state.time_scale().right_offset_precise());
348        }
349
350        if let (Some(start_pos), Some(start_offset), Some(curr_pos)) = (
351            self.scroll_start_pos,
352            self.scroll_start_offset,
353            response.interact_pointer_pos(),
354        ) {
355            let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(start_pos);
356            if price_axis_rect.contains(origin) {
357                if self.chart_options.handle_scale.enabled
358                    && self
359                        .chart_options
360                        .handle_scale
361                        .axis_pressed_mouse_move
362                        .price
363                {
364                    // Zoom by this frame's movement around the point where the drag began.
365                    self.price_axis_drag = Some((origin.y, response.drag_delta().y));
366                }
367            } else if time_axis_rect.contains(origin) {
368                if !self.chart_options.handle_scale.enabled {
369                    self.scroll_start_pos = Some(curr_pos);
370                    return;
371                }
372                if self.chart_options.handle_scale.axis_pressed_mouse_move.time {
373                    let dx = curr_pos.x - start_pos.x;
374                    if dx.abs() > 0.1 {
375                        let zoom_point_x = curr_pos.x - chart_rect_min_x;
376                        let zoom_scale = (dx / 100.0).signum() * (dx / 100.0).abs().min(1.0);
377                        let chart_width = time_axis_rect.width();
378                        self.state.time_scale_mut().zoom(
379                            zoom_scale,
380                            zoom_point_x,
381                            chart_rect_min_x,
382                            chart_width,
383                        );
384                        self.scroll_start_pos = Some(curr_pos);
385                    }
386                }
387            } else {
388                let scroll = self.chart_options.handle_scroll;
389                if !scroll.enabled {
390                    // Rebase while disabled so re-enabling does not replay the blocked movement.
391                    self.scroll_start_pos = Some(curr_pos);
392                    self.scroll_start_offset = Some(self.state.time_scale().right_offset_precise());
393                    return;
394                }
395                let horizontal = if is_touch {
396                    scroll.horz_touch_drag
397                } else {
398                    scroll.pressed_mouse_move
399                };
400                let vertical = if is_touch {
401                    scroll.vert_touch_drag
402                } else {
403                    scroll.pressed_mouse_move
404                };
405                if horizontal {
406                    let drag_delta_x = curr_pos.x - start_pos.x;
407                    let bar_spacing = self.state.time_scale().bar_spacing();
408                    let drag_in_bars = drag_delta_x / bar_spacing;
409                    let new_offset = start_offset - f64::from(drag_in_bars);
410                    self.state.time_scale_mut().set_right_offset(new_offset);
411
412                    if kinetic {
413                        let now = Instant::now();
414                        if let (Some(last_pos), Some(last_time)) =
415                            (self.kinetic_scroll.last_pos, self.kinetic_scroll.last_time)
416                        {
417                            let dt = now.duration_since(last_time).as_secs_f32();
418                            if dt > 0.0 {
419                                self.kinetic_scroll.velocity = (curr_pos.x - last_pos.x) / dt;
420                            }
421                        }
422                        self.kinetic_scroll.last_pos = Some(curr_pos);
423                        self.kinetic_scroll.last_time = Some(now);
424                    }
425                }
426
427                // Manual prices follow only the movement enabled for this input device.
428                if vertical
429                    && self.last_rendered_price_rect.height() > 0.0
430                    && self.state.price_scale().is_manual()
431                {
432                    let map = self.last_price_map;
433                    let dy = response.drag_delta().y;
434                    let shift = map.y_to_scale(curr_pos.y - dy) - map.y_to_scale(curr_pos.y);
435                    let range = self.state.price_scale().range();
436                    self.state
437                        .price_scale_mut()
438                        .set_range(range.min + shift, range.max + shift);
439                }
440            }
441        }
442    }
443
444    /// Advance the kinetic scrolling animation by one frame.
445    ///
446    /// Applies the current velocity to the right-offset, then damps the velocity
447    /// using the configured damping coefficient. Stops when velocity drops below
448    /// the minimum threshold or motion becomes non-finite. Requests a repaint
449    /// while the animation is running.
450    pub(crate) fn apply_kinetic_scroll(&mut self, ui: &Ui) {
451        if !self.chart_options.handle_scroll.enabled {
452            self.kinetic_scroll = state::KineticScrollState::new();
453            return;
454        }
455        let options = self.chart_options.kinetic_scroll;
456        if !(options.enabled || options.mouse) || !self.kinetic_scroll.is_active {
457            return;
458        }
459        if !self.kinetic_scroll.velocity.is_finite() {
460            self.kinetic_scroll = state::KineticScrollState::new();
461            return;
462        }
463
464        let now = Instant::now();
465        let dt = if let Some(t0) = self.kinetic_scroll.anim_last_time {
466            now.duration_since(t0).as_secs_f32().max(0.0)
467        } else {
468            self.kinetic_scroll.anim_last_time = Some(now);
469            0.0
470        };
471        self.kinetic_scroll.anim_last_time = Some(now);
472
473        if dt > 0.0 {
474            let velocity_in_bars_per_s =
475                self.kinetic_scroll.velocity / self.state.time_scale().bar_spacing();
476            let delta_offset = velocity_in_bars_per_s * dt;
477            let curr_offset = self.state.time_scale().right_offset_precise();
478            let new_offset = curr_offset - f64::from(delta_offset);
479
480            if !new_offset.is_finite() {
481                self.kinetic_scroll = state::KineticScrollState::new();
482                return;
483            }
484            self.state.time_scale_mut().set_right_offset(new_offset);
485
486            let frames = (dt * 60.0).max(0.0);
487            let damping = self.chart_options.kinetic_scroll.damping_coeff.powf(frames);
488            self.kinetic_scroll.velocity *= damping;
489
490            let min_v_px_s = self.chart_options.kinetic_scroll.min_scroll_speed * 60.0;
491            if self.kinetic_scroll.velocity.abs() < min_v_px_s {
492                self.kinetic_scroll = state::KineticScrollState::new();
493                return;
494            }
495        }
496
497        ui.ctx().request_repaint();
498    }
499
500    /// Reset axes on double-click: time axis jumps to latest, price axis re-enables auto-scale.
501    pub(crate) fn handle_double_click(
502        &mut self,
503        response: &Response,
504        price_axis_rect: Rect,
505        time_axis_rect: Rect,
506    ) {
507        if !self.chart_options.handle_scale.enabled || !response.double_clicked() {
508            return;
509        }
510
511        if let Some(pos) = response.interact_pointer_pos() {
512            if self.chart_options.handle_scale.axis_double_click_reset.time
513                && time_axis_rect.contains(pos)
514            {
515                // Spacing first: a pixel right offset is converted at the restored spacing.
516                self.state
517                    .time_scale_mut()
518                    .set_bar_spacing(self.chart_options.time_scale.bar_spacing);
519                self.state.time_scale_mut().jump_to_latest();
520                self.pending_scroll_time = None;
521            }
522            if self
523                .chart_options
524                .handle_scale
525                .axis_double_click_reset
526                .price
527                && price_axis_rect.contains(pos)
528            {
529                self.state.price_scale_mut().reset();
530            }
531        }
532    }
533
534    /// Handle box-zoom interaction (left-click drag when zoom mode is active).
535    ///
536    /// On press, starts tracking the selection rectangle. On release, zooms into the rectangle.
537    /// Returns `true` if a zoom was successfully applied this frame.
538    pub(crate) fn handle_box_zoom(
539        &mut self,
540        ui: &Ui,
541        response: &Response,
542        chart_rect: Rect,
543        chart_width: f32,
544        zoom_mode_active: bool,
545    ) -> bool {
546        // Use left-click (primary) when zoom mode is active, otherwise don't trigger
547        let btn_pressed = zoom_mode_active && ui.input(|i| i.pointer.primary_pressed());
548        let btn_down = zoom_mode_active && ui.input(|i| i.pointer.primary_down());
549        let btn_released = zoom_mode_active && ui.input(|i| i.pointer.primary_released());
550
551        if btn_pressed
552            && let Some(pos) = response.interact_pointer_pos()
553            && chart_rect.contains(pos)
554        {
555            self.box_zoom.active = true;
556            self.box_zoom.start_pos = Some(pos);
557            self.box_zoom.curr_pos = Some(pos);
558        }
559
560        if btn_down
561            && self.box_zoom.active
562            && let Some(pos) = response.interact_pointer_pos()
563        {
564            self.box_zoom.curr_pos = Some(pos);
565        }
566
567        let mut zoom_applied = false;
568        if btn_released && self.box_zoom.active {
569            if let (Some(start), Some(end)) = (self.box_zoom.start_pos, self.box_zoom.curr_pos)
570                && (chart_rect.contains(start) || chart_rect.contains(end))
571            {
572                zoom_applied = self.execute_box_zoom(start, end, chart_rect, chart_width);
573            }
574            self.box_zoom.reset();
575        }
576
577        zoom_applied
578    }
579
580    /// Zoom programmatically to a screen rectangle from `start` to `end`, filling the
581    /// viewport with the selected time and price ranges. [`Self::zoom_back`] undoes it.
582    ///
583    /// Returns `false` if either dimension of the selection is 20 points or less, or if a
584    /// corner or the width is non-finite.
585    pub fn execute_box_zoom(
586        &mut self,
587        start: Pos2,
588        end: Pos2,
589        chart_rect: Rect,
590        chart_width: f32,
591    ) -> bool {
592        if !(start.is_finite() && end.is_finite() && chart_width.is_finite()) {
593            return false;
594        }
595        let min_x = start.x.min(end.x);
596        let max_x = start.x.max(end.x);
597        let min_y = start.y.min(end.y);
598        let max_y = start.y.max(end.y);
599
600        if (max_x - min_x).abs() <= 20.0 || (max_y - min_y).abs() <= 20.0 {
601            return false; // Zoom box too small
602        }
603
604        // Save current zoom state to history before applying new zoom
605        self.state.push_zoom_state();
606
607        // Horizontal zoom: the box's bars fill the width (up to the maximum spacing), centred.
608        // A box inside one bar shows that bar across the width, or keeps a wider spacing.
609        let time_scale = self.state.time_scale_mut();
610        let center_idx =
611            time_scale.coord_to_idx_precise((min_x + max_x) / 2.0, chart_rect.min.x, chart_width);
612        let old = time_scale.bar_spacing();
613        time_scale.set_bar_spacing((old * chart_width / (max_x - min_x)).min(chart_width.max(old)));
614        // The inverse of `coord_to_idx` at the middle of the width.
615        let spacing = time_scale.bar_spacing();
616        let base_idx = time_scale.base_idx() as f64;
617        time_scale.set_right_offset(
618            center_idx - base_idx + (f64::from(chart_width) / 2.0 - 1.0) / f64::from(spacing) - 0.5,
619        );
620
621        // Vertical zoom: the box's prices, in the units of the window it selects, whose first
622        // visible close (the base of the relative modes) can differ from the one it was drawn on.
623        let (p, q) = (
624            self.last_price_map.y_to_price(min_y),
625            self.last_price_map.y_to_price(max_y),
626        );
627        let transform = self.state.fit_price_scale(
628            self.studies
629                .indicators()
630                .iter()
631                .zip(&self.study_autoscale)
632                .filter(|(_, autoscale)| **autoscale && !self.chart_type.is_time_independent())
633                .map(|(study, _)| study.as_ref()),
634        );
635        let (a, b) = (transform.to_scale(p), transform.to_scale(q));
636        let scale = self.state.price_scale_mut();
637        // Widened around its middle to the span zooming stops at.
638        let pad = (scale.min_span() - (a - b).abs()).max(0.0) / 2.0;
639        scale.set_range(a.min(b) - pad, a.max(b) + pad);
640        self.pending_scroll_time = None;
641
642        true // Zoom successfully applied
643    }
644
645    /// Apply deferred price-axis zoom from mouse wheel or price-axis drag.
646    ///
647    /// Called after all input processing to avoid mutable borrow conflicts. A
648    /// movement of `dy` points scales the range by `exp(dy / height)`, so a drag
649    /// zooms by the same amount however many frames it spans.
650    pub(crate) fn apply_price_zoom(
651        &mut self,
652        pending_price_zoom: Option<f32>,
653        response: &Response,
654        price_rect: Rect,
655        transform: PriceTransform,
656    ) {
657        let wheel = pending_price_zoom.zip(response.hover_pos().map(|p| p.y));
658        let drag = self.price_axis_drag.take().map(|(y, dy)| (dy, y));
659        for (dy, anchor_y) in wheel.into_iter().chain(drag) {
660            let scale = self.state.price_scale();
661            let anchor = scale
662                .map(transform, price_rect.min.y, price_rect.max.y)
663                .y_to_scale(anchor_y);
664            let factor = (dy as f64 / price_rect.height() as f64).exp();
665            self.state.price_scale_mut().zoom(factor, anchor);
666        }
667    }
668
669    /// Apply pending timescale configuration: width, initial visible bars,
670    /// lock-on-resize behavior, and pending start-index jumps.
671    pub(crate) fn apply_timescale_config(&mut self, chart_width: f32) {
672        // Hidden or collapsed layouts cannot establish a zoom or a resize baseline.
673        if !chart_width.is_finite() || chart_width <= 1.0 {
674            return;
675        }
676        self.state.time_scale_mut().set_width(chart_width);
677
678        if self.apply_visible_bars_once {
679            if let Some(desired) = self.desired_visible_bars
680                && desired > 0
681            {
682                let spacing = self.calculate_bar_spacing(chart_width, desired);
683                self.state.time_scale_mut().set_bar_spacing(spacing);
684                if self.prev_width.is_none() {
685                    self.state.time_scale_mut().jump_to_latest();
686                }
687            }
688            self.apply_visible_bars_once = false;
689        } else if self
690            .chart_options
691            .time_scale
692            .lock_visible_time_range_on_resize
693            && let Some(prev_width) = self.prev_width
694            && (chart_width - prev_width).abs() > 1.0
695        {
696            let spacing = self.state.time_scale().bar_spacing() * chart_width / prev_width;
697            self.state.time_scale_mut().set_bar_spacing(spacing);
698        }
699        self.prev_width = Some(chart_width);
700
701        if let Some(time) = self.pending_scroll_time
702            && let Some(first) = self.state.data().bars.first()
703        {
704            let bars = &self.state.data().bars;
705            let next = bars.partition_point(|bar| bar.time < time);
706            let index = if next == 0 {
707                0
708            } else if next == bars.len() || time - bars[next - 1].time <= bars[next].time - time {
709                next - 1
710            } else {
711                next
712            };
713            let needs_history = time < first.time
714                && !self.symbol.is_empty()
715                && !self.feed.history_complete
716                && self
717                    .feed_link
718                    .as_ref()
719                    .is_some_and(|link| !link.paging_stopped);
720            let scale = self.state.time_scale_mut();
721            let half_window = (chart_width / scale.bar_spacing() - 1.0) / 2.0;
722            scale.set_right_offset(index as f64 - scale.base_idx() as f64 + f64::from(half_window));
723            if !needs_history {
724                self.pending_scroll_time = None;
725            }
726        }
727
728        // Apply pending start index
729        if let Some(target_start) = self.pending_start_idx.take() {
730            let bar_spacing = self.state.time_scale().bar_spacing();
731            if bar_spacing > 0.0 {
732                let visible_len = chart_width / bar_spacing;
733                let base_idx = self.state.time_scale().base_idx() as f64;
734                let desired_right_border = target_start as f64 + f64::from(visible_len) - 1.0;
735                let desired_offset = desired_right_border - base_idx;
736                self.state.time_scale_mut().set_right_offset(desired_offset);
737            }
738        }
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use crate::chart::series::SeriesSettings;
745    use crate::scales::PriceScaleMode;
746    use crate::widget::Chart;
747    use crate::widget::test_frames::{chart, frame};
748    use egui::{Event, Modifiers, PointerButton, Pos2, Rect};
749
750    /// Invalid damping falls back at both option entry points and the animation still ends.
751    #[test]
752    fn kinetic_damping_options_decay_to_a_stop() {
753        for runtime in [false, true] {
754            for (coefficient, expected) in [
755                (f32::NAN, 0.997),
756                (f32::INFINITY, 0.997),
757                (f32::NEG_INFINITY, 0.997),
758                (-0.5, 0.997),
759                (1.0, 0.997),
760                (1.5, 0.997),
761                (0.0, 0.0),
762                (0.5, 0.5),
763                (0.997, 0.997),
764            ] {
765                let ctx = egui::Context::default();
766                let mut chart = chart(&[95.0], &[105.0]);
767                let mut options = chart.chart_options().clone();
768                options.kinetic_scroll.enabled = true;
769                options.kinetic_scroll.damping_coeff = coefficient;
770                if runtime {
771                    chart.set_chart_options(options);
772                } else {
773                    chart = chart.with_chart_options(options);
774                }
775                frame(&ctx, &mut chart, 0.0, vec![]);
776                chart.kinetic_scroll.is_active = true;
777                chart.kinetic_scroll.velocity = 600.0;
778                // At the default damping, 600 * 0.997^(40 * 60) < 12 px/s.
779                for i in 1..=40 {
780                    chart.kinetic_scroll.anim_last_time =
781                        Some(web_time::Instant::now() - std::time::Duration::from_secs(1));
782                    frame(&ctx, &mut chart, f64::from(i), vec![]);
783                    assert!(chart.state.time_scale().right_offset_precise().is_finite());
784                }
785                assert!(
786                    !chart.kinetic_scroll.is_active,
787                    "runtime={runtime}, damping={coefficient}"
788                );
789                assert_eq!(chart.kinetic_scroll.velocity, 0.0);
790                assert_eq!(chart.chart_options().kinetic_scroll.damping_coeff, expected);
791            }
792        }
793    }
794
795    /// Invalid motion and a disabled scroll group stop momentum without changing the viewport.
796    #[test]
797    fn kinetic_invalid_or_disabled_motion_stops_before_scrolling() {
798        for (velocity, elapsed, enabled) in [
799            (f32::NAN, None, true),
800            (f32::INFINITY, Some(0.05), true),
801            (f32::NEG_INFINITY, Some(0.05), true),
802            (f32::MAX, Some(0.05), true),
803            (600.0, Some(1.0), false),
804        ] {
805            let ctx = egui::Context::default();
806            let mut chart = chart(&[95.0], &[105.0]);
807            chart.chart_options.kinetic_scroll.enabled = true;
808            frame(&ctx, &mut chart, 0.0, vec![]);
809            chart.state.time_scale_mut().set_bar_spacing(0.5);
810            let offset = chart.state.time_scale().right_offset_precise();
811            chart.kinetic_scroll.is_active = true;
812            chart.kinetic_scroll.velocity = velocity;
813            chart.kinetic_scroll.anim_last_time = elapsed.map(|seconds| {
814                web_time::Instant::now() - std::time::Duration::from_secs_f32(seconds)
815            });
816            chart.chart_options.handle_scroll.enabled = enabled;
817            frame(&ctx, &mut chart, 0.05, vec![]);
818            assert!(!chart.kinetic_scroll.is_active, "velocity={velocity}");
819            assert_eq!(chart.kinetic_scroll.velocity, 0.0);
820            assert!(chart.kinetic_scroll.anim_last_time.is_none());
821            assert_eq!(chart.state.time_scale().right_offset_precise(), offset);
822            chart.chart_options.handle_scroll.enabled = true;
823            frame(&ctx, &mut chart, 0.1, vec![]);
824            assert_eq!(chart.state.time_scale().right_offset_precise(), offset);
825        }
826    }
827
828    /// Native zoom and contact pinch each scale once, retaining the bar under the pointer.
829    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
830    #[cfg_attr(not(target_arch = "wasm32"), test)]
831    fn native_and_contact_pinch_keep_the_zoom_anchor() {
832        for (contacts, native, enabled, tracking, kinetic, maximized, group_enabled) in [
833            (false, true, true, false, false, false, true),
834            (true, false, true, false, false, false, true),
835            (true, true, true, false, false, false, true),
836            (false, true, false, false, false, false, true),
837            (false, true, true, true, false, false, true),
838            (false, true, true, false, true, false, true),
839            (false, true, true, false, false, true, true),
840            (true, false, true, false, false, true, true),
841            (false, true, true, false, false, false, false),
842            (true, false, true, false, false, false, false),
843        ] {
844            let ctx = egui::Context::default();
845            let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]);
846            chart.chart_options.handle_scale.pinch = enabled;
847            chart.chart_options.handle_scale.enabled = group_enabled;
848            let enabled = enabled && group_enabled;
849            if maximized {
850                let study = chart.add_indicator(Box::new(
851                    crate::studies::CustomIndicator::new("Pane", |bars| {
852                        vec![crate::studies::IndicatorValue::Single(50.0); bars.len()]
853                    })
854                    .with_overlay(false),
855                ));
856                chart.sync_panes();
857                chart.pane_layout.maximized = chart.series_pane(study);
858            }
859            frame(&ctx, &mut chart, 0.0, vec![]);
860            let rect = if maximized {
861                chart.get_rendered_indicator_panes()[0].chart_rect
862            } else {
863                chart.last_rendered_price_rect
864            };
865            let pointer = Pos2::new(rect.left() + rect.width() * 0.25, rect.center().y);
866            frame(&ctx, &mut chart, 0.02, vec![Event::PointerMoved(pointer)]);
867            let touch = |id, phase, x| Event::Touch {
868                device_id: egui::TouchDeviceId(1),
869                id: egui::TouchId(id),
870                phase,
871                pos: Pos2::new(x, pointer.y),
872                force: None,
873            };
874            if contacts {
875                frame(
876                    &ctx,
877                    &mut chart,
878                    0.04,
879                    vec![
880                        touch(1, egui::TouchPhase::Start, pointer.x - 50.0),
881                        touch(2, egui::TouchPhase::Start, pointer.x + 50.0),
882                    ],
883                );
884            }
885            if tracking {
886                chart.enable_tracking_mode();
887            }
888            if kinetic {
889                chart.chart_options.kinetic_scroll.enabled = true;
890                chart.kinetic_scroll.is_active = true;
891                chart.kinetic_scroll.velocity = 120.0;
892                chart.kinetic_scroll.anim_last_time = Some(web_time::Instant::now());
893            }
894            let (spacing, offset) = chart.get_time_scale_state();
895            let mut events = Vec::new();
896            if contacts {
897                events.extend([
898                    touch(1, egui::TouchPhase::Move, pointer.x - 75.0),
899                    touch(2, egui::TouchPhase::Move, pointer.x + 75.0),
900                ]);
901            }
902            if native {
903                events.push(Event::Zoom(1.5));
904            }
905            frame(&ctx, &mut chart, 0.06, events);
906            assert!(!chart.kinetic_scroll.is_active);
907            let (zoomed, moved) = chart.get_time_scale_state();
908            // The existing pinch sensitivity maps a 1.5 gesture to a 10% spacing increase.
909            let expected_spacing = if enabled { spacing * 1.1 } else { spacing };
910            assert!(
911                (zoomed - expected_spacing).abs() < 1e-5,
912                "contacts={contacts}, native={native}, enabled={enabled}: {zoomed}"
913            );
914            let expected_offset =
915                offset + (rect.right() - pointer.x - 1.0) * (1.0 / zoomed - 1.0 / spacing);
916            assert!((moved - expected_offset).abs() < 1e-4);
917            frame(&ctx, &mut chart, 0.08, vec![]);
918            assert_eq!(chart.get_time_scale_state(), (zoomed, moved));
919            if tracking {
920                assert!(!chart.is_tracking_mode_active());
921            }
922        }
923    }
924
925    /// Startup and suspended layouts must not consume the requested zoom or move the right edge.
926    #[test]
927    fn degenerate_width_preserves_pending_view_and_resize_anchor() {
928        for lock in [false, true] {
929            for pixels in [None, Some(60.0)] {
930                let ctx = egui::Context::default();
931                let mut chart = chart(&[95.0], &[105.0]).visible_bars(100);
932                let options = &mut chart.chart_options.time_scale;
933                options.min_bar_spacing = 0.01;
934                options.lock_visible_time_range_on_resize = lock;
935                options.right_offset_pixels = pixels;
936                let options = chart.chart_options.clone();
937                chart = chart.with_chart_options(options);
938                let show = |chart: &mut Chart, width| {
939                    ctx.run_ui(
940                        egui::RawInput {
941                            screen_rect: Some(Rect::from_min_size(
942                                Pos2::ZERO,
943                                egui::vec2(width, 600.0),
944                            )),
945                            ..Default::default()
946                        },
947                        |ui| {
948                            chart.show(ui);
949                        },
950                    )
951                    .drop_without_applying_deltas();
952                };
953                show(&mut chart, 0.0);
954                show(&mut chart, 1.0);
955                assert_eq!(chart.state.time_scale().bar_spacing(), 6.0);
956                show(&mut chart, 1064.0);
957                let width = chart.last_rendered_price_rect.width();
958                let scale = chart.state.time_scale();
959                let spacing = width / 100.0;
960                assert!((scale.bar_spacing() - spacing).abs() < 1e-5);
961                let offset = pixels.map_or(2.5, |px| px / spacing);
962                assert!((scale.right_offset() - offset).abs() < 1e-5);
963                chart.state.time_scale_mut().set_right_offset(-20.0);
964                show(&mut chart, 0.0);
965                show(&mut chart, 1.0);
966                show(&mut chart, 1064.0);
967                assert!((chart.state.time_scale().bar_spacing() - spacing).abs() < 1e-5);
968                assert_eq!(chart.state.time_scale().right_offset(), -20.0);
969                show(&mut chart, 1564.0);
970                let expected = if lock {
971                    chart.last_rendered_price_rect.width() / 100.0
972                } else {
973                    spacing
974                };
975                assert!((chart.state.time_scale().bar_spacing() - expected).abs() < 1e-5);
976                assert_eq!(chart.state.time_scale().right_offset(), -20.0);
977                chart.set_visible_bars(80);
978                show(&mut chart, 1264.0);
979                assert!(
980                    (chart.state.time_scale().bar_spacing()
981                        - chart.last_rendered_price_rect.width() / 80.0)
982                        .abs()
983                        < 1e-5,
984                    "a pending view must take precedence over a locked resize"
985                );
986            }
987        }
988    }
989
990    /// Drags down by 100 points in `steps` frames from the point `start` picks given the price pane.
991    fn drag_down(chart: &mut Chart, start: fn(Rect) -> Pos2, steps: usize, touch: bool) {
992        let ctx = egui::Context::default();
993        frame(&ctx, chart, 0.0, Vec::new());
994        let start = start(chart.last_rendered_price_rect);
995        let button = |pos, pressed| Event::PointerButton {
996            pos,
997            button: PointerButton::Primary,
998            pressed,
999            modifiers: Modifiers::NONE,
1000        };
1001        let mut t = 0.0;
1002        let mut step = |chart: &mut _, events| {
1003            t += 0.02;
1004            frame(&ctx, chart, t, events);
1005        };
1006        step(chart, vec![Event::PointerMoved(start)]);
1007        let contact = |phase, pos| Event::Touch {
1008            device_id: egui::TouchDeviceId(1),
1009            id: egui::TouchId(1),
1010            phase,
1011            pos,
1012            force: None,
1013        };
1014        let mut events = vec![button(start, true)];
1015        if touch {
1016            events.push(contact(egui::TouchPhase::Start, start));
1017        }
1018        step(chart, events);
1019        let mut pos = start;
1020        for _ in 0..steps {
1021            pos.y += 100.0 / steps as f32;
1022            let mut events = vec![Event::PointerMoved(pos)];
1023            if touch {
1024                events.push(contact(egui::TouchPhase::Move, pos));
1025            }
1026            step(chart, events);
1027        }
1028        let mut events = vec![button(pos, false)];
1029        if touch {
1030            events.push(contact(egui::TouchPhase::End, pos));
1031        }
1032        step(chart, events);
1033    }
1034
1035    /// Drags the price axis down by 100 points in `steps` frames and returns the resulting range.
1036    fn drag_price_axis(steps: usize, left: bool) -> (f64, f64) {
1037        let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]);
1038        chart.config.show_left_axis = left;
1039        chart.config.show_right_axis = !left;
1040        drag_down(
1041            &mut chart,
1042            if left {
1043                |rect| Pos2::new(rect.left() - 10.0, rect.center().y)
1044            } else {
1045                |rect| Pos2::new(rect.right() + 10.0, rect.center().y)
1046            },
1047            steps,
1048            false,
1049        );
1050        let range = chart.state.price_scale().range();
1051        (range.min, range.max)
1052    }
1053
1054    /// A wheel notch over the chart zooms the time axis, also with the zoom modifier (Ctrl, or
1055    /// Cmd on macOS) held, which egui leaves out of its smoothed scroll delta.
1056    /// While the right bar stays on scroll, the zoom keeps the right offset.
1057    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1058    #[cfg_attr(not(target_arch = "wasm32"), test)]
1059    fn wheel_zooms_the_time_axis_with_and_without_the_zoom_modifier() {
1060        for (modifiers, right_bar_stays, fraction, group_enabled) in [
1061            (Modifiers::NONE, false, 0.25, true),
1062            (Modifiers::NONE, false, 0.25, false),
1063            (Modifiers::COMMAND, false, 0.75, true),
1064            (Modifiers::COMMAND, false, 0.75, false),
1065            (Modifiers::NONE, true, 0.25, true),
1066        ] {
1067            let ctx = egui::Context::default();
1068            let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]);
1069            chart.chart_options.handle_scale.enabled = group_enabled;
1070            chart.chart_options.handle_scroll.enabled = !group_enabled;
1071            chart.chart_options.time_scale.right_bar_stays_on_scroll = right_bar_stays;
1072            frame(&ctx, &mut chart, 0.0, Vec::new());
1073            let (spacing, offset) = chart.get_time_scale_state();
1074            let rect = chart.last_rendered_price_rect;
1075            let pointer = Pos2::new(rect.left() + rect.width() * fraction, rect.center().y);
1076            frame(&ctx, &mut chart, 0.02, vec![Event::PointerMoved(pointer)]);
1077            let wheel = Event::MouseWheel {
1078                unit: egui::MouseWheelUnit::Line,
1079                delta: egui::vec2(0.0, -1.0),
1080                phase: egui::TouchPhase::Move,
1081                source: egui::MouseWheelSource::Wheel,
1082                modifiers,
1083            };
1084            frame(&ctx, &mut chart, 0.04, vec![wheel]);
1085            for i in 3..10 {
1086                frame(&ctx, &mut chart, i as f64 * 0.02, Vec::new());
1087            }
1088            let (zoomed, moved) = chart.get_time_scale_state();
1089            assert!(
1090                if group_enabled {
1091                    zoomed > spacing
1092                } else {
1093                    zoomed == spacing
1094                },
1095                "{modifiers:?}: spacing {spacing} -> {zoomed}"
1096            );
1097            // Zooming around the centre moves the right edge unless the right bar stays.
1098            assert_eq!(
1099                moved == offset,
1100                right_bar_stays || !group_enabled,
1101                "offset {offset} -> {moved}"
1102            );
1103            let anchor = if right_bar_stays {
1104                rect.right() - 1.0
1105            } else {
1106                pointer.x
1107            };
1108            let expected = offset + (rect.right() - anchor - 1.0) * (1.0 / zoomed - 1.0 / spacing);
1109            assert!(
1110                (moved - expected).abs() < 1e-4,
1111                "the logical bar at the zoom anchor must stay at the same pixel"
1112            );
1113        }
1114    }
1115
1116    /// Wheel pan and time/price zoom follow only their group, including after re-enabling it.
1117    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1118    #[cfg_attr(not(target_arch = "wasm32"), test)]
1119    fn wheel_groups_toggle_without_losing_individual_permissions() {
1120        for (scroll_wheel, scale_wheel) in [(true, true), (false, true), (true, false)] {
1121            for (horizontal, shift, price) in [
1122                (true, false, false),
1123                (false, true, false),
1124                (false, false, false),
1125                (false, false, true),
1126            ] {
1127                let ctx = egui::Context::default();
1128                let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]);
1129                let mut options = chart.chart_options().clone();
1130                options.handle_scroll.mouse_wheel = scroll_wheel;
1131                options.handle_scroll.vert_touch_drag = false;
1132                options.handle_scale.mouse_wheel = scale_wheel;
1133                options.handle_scale.pinch = false;
1134                chart.set_chart_options(options.clone());
1135                chart.state.price_scale_mut().set_range(90.0, 110.0);
1136                frame(&ctx, &mut chart, 0.0, vec![]);
1137                let rect = chart.last_rendered_price_rect;
1138                let pos = if price {
1139                    Pos2::new(rect.right() + 10.0, rect.center().y)
1140                } else {
1141                    rect.center()
1142                };
1143                let mut t = 0.0;
1144                for (scroll, scale) in [(false, false), (true, false), (false, true), (true, true)]
1145                {
1146                    options.handle_scroll.enabled = scroll;
1147                    options.handle_scale.enabled = scale;
1148                    chart.set_chart_options(options.clone());
1149                    t += 1.0;
1150                    frame(&ctx, &mut chart, t, vec![Event::PointerMoved(pos)]);
1151                    let before = chart.get_time_scale_state();
1152                    let range = chart.state.price_scale().range();
1153                    let modifiers = if shift {
1154                        Modifiers::SHIFT
1155                    } else {
1156                        Modifiers::NONE
1157                    };
1158                    t += 0.02;
1159                    frame(
1160                        &ctx,
1161                        &mut chart,
1162                        t,
1163                        vec![Event::MouseWheel {
1164                            unit: egui::MouseWheelUnit::Point,
1165                            delta: if horizontal {
1166                                egui::vec2(30.0, 0.0)
1167                            } else {
1168                                egui::vec2(0.0, -30.0)
1169                            },
1170                            phase: egui::TouchPhase::Move,
1171                            source: egui::MouseWheelSource::Wheel,
1172                            modifiers,
1173                        }],
1174                    );
1175                    for _ in 0..20 {
1176                        t += 0.1;
1177                        frame(&ctx, &mut chart, t, vec![]);
1178                        if !ctx.input(|input| input.is_scrolling()) {
1179                            break;
1180                        }
1181                    }
1182                    assert!(
1183                        !ctx.input(|input| input.is_scrolling()),
1184                        "wheel smoothing must finish before toggling groups"
1185                    );
1186                    let after = chart.get_time_scale_state();
1187                    let pan = horizontal || shift;
1188                    assert_eq!(after.0 != before.0, !pan && !price && scale && scale_wheel);
1189                    if pan {
1190                        assert_eq!(after.1 != before.1, scroll && scroll_wheel);
1191                    }
1192                    assert_eq!(
1193                        chart.state.price_scale().range() != range,
1194                        !pan && price && scale && scale_wheel
1195                    );
1196                    assert_eq!(chart.chart_options().handle_scroll, options.handle_scroll);
1197                    assert_eq!(chart.chart_options().handle_scale, options.handle_scale);
1198                }
1199            }
1200        }
1201    }
1202
1203    /// Axis resets follow both permissions; the time reset restores spacing and a pixel offset.
1204    #[test]
1205    fn axis_resets_respect_permissions_and_configured_right_offset() {
1206        for target in ["time", "price", "pane"] {
1207            for individual in [false, true] {
1208                let ctx = egui::Context::default();
1209                let options = crate::config::ChartOptions {
1210                    time_scale: crate::config::TimeScaleOptions {
1211                        bar_spacing: 6.0,
1212                        right_offset_pixels: Some(60.0),
1213                        ..Default::default()
1214                    },
1215                    ..Default::default()
1216                };
1217                let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]).with_chart_options(options);
1218                if target == "pane" {
1219                    chart.add_indicator(Box::new(
1220                        crate::studies::CustomIndicator::new("Pane", |bars| {
1221                            vec![crate::studies::IndicatorValue::Single(50.0); bars.len()]
1222                        })
1223                        .with_overlay(false),
1224                    ));
1225                }
1226                chart.chart_options.handle_scroll.enabled = false;
1227                chart
1228                    .chart_options
1229                    .handle_scale
1230                    .axis_double_click_reset
1231                    .time = individual;
1232                chart
1233                    .chart_options
1234                    .handle_scale
1235                    .axis_double_click_reset
1236                    .price = individual;
1237                frame(&ctx, &mut chart, 0.0, vec![]);
1238                chart.state.time_scale_mut().set_bar_spacing(12.0);
1239                chart.state.time_scale_mut().set_right_offset(-10.0);
1240                chart.state.price_scale_mut().set_range(90.0, 110.0);
1241                let rect = chart.last_rendered_price_rect;
1242                let pos = match target {
1243                    "time" => Pos2::new(
1244                        rect.center().x,
1245                        rect.bottom().max(chart.last_rendered_volume_rect.bottom()) + 5.0,
1246                    ),
1247                    "price" => Pos2::new(rect.right() + 10.0, rect.center().y),
1248                    _ => {
1249                        chart.pane_layout.panes[1].scale.set_range(40.0, 60.0);
1250                        let pane = &chart.get_rendered_indicator_panes()[0];
1251                        Pos2::new(pane.chart_rect.right() + 10.0, pane.panel_rect.center().y)
1252                    }
1253                };
1254                let mut t = 0.0;
1255                for enabled in [false, true] {
1256                    chart.chart_options.handle_scale.enabled = enabled;
1257                    // Separate the two double-clicks so egui does not count a triple-click.
1258                    t += 1.0;
1259                    frame(&ctx, &mut chart, t, vec![Event::PointerMoved(pos)]);
1260                    for pressed in [true, false, true, false] {
1261                        t += 0.02;
1262                        frame(
1263                            &ctx,
1264                            &mut chart,
1265                            t,
1266                            vec![Event::PointerButton {
1267                                pos,
1268                                button: PointerButton::Primary,
1269                                pressed,
1270                                modifiers: Modifiers::NONE,
1271                            }],
1272                        );
1273                    }
1274                    let reset = enabled && individual;
1275                    match target {
1276                        "time" => {
1277                            assert_eq!(
1278                                chart.state.time_scale().bar_spacing(),
1279                                if reset { 6.0 } else { 12.0 }
1280                            );
1281                            assert_eq!(
1282                                chart.state.time_scale().right_offset(),
1283                                if reset { 10.0 } else { -10.0 }
1284                            );
1285                        }
1286                        "price" => assert_eq!(chart.state.price_scale().is_manual(), !reset),
1287                        _ => assert_eq!(chart.pane_layout.panes[1].scale.is_manual(), !reset),
1288                    }
1289                    let reset = chart.chart_options.handle_scale.axis_double_click_reset;
1290                    assert_eq!(reset.time, individual);
1291                    assert_eq!(reset.price, individual);
1292                }
1293            }
1294        }
1295    }
1296
1297    /// Study-pane and secondary price-axis drags obey the same scale group as the main axis.
1298    #[test]
1299    fn scale_group_controls_other_price_axis_drags() {
1300        for pane in [false, true] {
1301            let ctx = egui::Context::default();
1302            let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]);
1303            let key = crate::model::SeriesScale::Separate(1);
1304            if pane {
1305                chart.add_indicator(Box::new(
1306                    crate::studies::CustomIndicator::new("Pane", |bars| {
1307                        vec![crate::studies::IndicatorValue::Single(50.0); bars.len()]
1308                    })
1309                    .with_overlay(false),
1310                ));
1311            } else {
1312                let series = chart.add_series(crate::widget::SeriesOptions {
1313                    scale: key,
1314                    ..Default::default()
1315                });
1316                chart.set_data(series, chart.state.data().bars.clone());
1317            }
1318            frame(&ctx, &mut chart, 0.0, vec![]);
1319            let rect = if pane {
1320                chart.get_rendered_indicator_panes()[0].chart_rect
1321            } else {
1322                chart.last_rendered_price_rect
1323            };
1324            let start = Pos2::new(
1325                if pane {
1326                    rect.right() + 10.0
1327                } else {
1328                    rect.left() - 10.0
1329                },
1330                rect.center().y,
1331            );
1332            let mut t = 0.0;
1333            for enabled in [false, true] {
1334                chart.chart_options.handle_scale.enabled = enabled;
1335                let range = |chart: &Chart| {
1336                    if pane {
1337                        chart.pane_layout.panes[1].scale.range()
1338                    } else {
1339                        chart.series_scales[&key].range()
1340                    }
1341                };
1342                let before = range(&chart);
1343                let time_scale = chart.get_time_scale_state();
1344                let button = |pos, pressed| Event::PointerButton {
1345                    pos,
1346                    button: PointerButton::Primary,
1347                    pressed,
1348                    modifiers: Modifiers::NONE,
1349                };
1350                for events in [
1351                    vec![Event::PointerMoved(start)],
1352                    vec![button(start, true)],
1353                    vec![Event::PointerMoved(start + egui::vec2(30.0, 30.0))],
1354                    vec![button(start + egui::vec2(30.0, 30.0), false)],
1355                ] {
1356                    t += 0.02;
1357                    frame(&ctx, &mut chart, t, events);
1358                }
1359                assert_eq!(chart.get_time_scale_state(), time_scale);
1360                let after = range(&chart);
1361                assert_eq!(
1362                    after.max - after.min > before.max - before.min,
1363                    enabled,
1364                    "pane={pane}, enabled={enabled}"
1365                );
1366            }
1367        }
1368    }
1369
1370    /// Re-enabling during a drag applies only the movement since the last disabled frame.
1371    #[test]
1372    fn reenabled_drag_does_not_replay_disabled_movement() {
1373        for time_axis in [false, true] {
1374            let ctx = egui::Context::default();
1375            let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]);
1376            frame(&ctx, &mut chart, 0.0, vec![]);
1377            let rect = chart.last_rendered_price_rect;
1378            let start = if time_axis {
1379                Pos2::new(
1380                    rect.center().x,
1381                    rect.bottom().max(chart.last_rendered_volume_rect.bottom()) + 5.0,
1382                )
1383            } else {
1384                rect.center()
1385            };
1386            let button = Event::PointerButton {
1387                pos: start,
1388                button: PointerButton::Primary,
1389                pressed: true,
1390                modifiers: Modifiers::NONE,
1391            };
1392            frame(&ctx, &mut chart, 0.02, vec![Event::PointerMoved(start)]);
1393            frame(&ctx, &mut chart, 0.04, vec![button]);
1394            let initial = chart.get_time_scale_state();
1395            chart.chart_options.handle_scroll.enabled = false;
1396            chart.chart_options.handle_scale.enabled = false;
1397            frame(
1398                &ctx,
1399                &mut chart,
1400                0.06,
1401                vec![Event::PointerMoved(start + egui::vec2(100.0, 0.0))],
1402            );
1403            let before = chart.get_time_scale_state();
1404            assert_eq!(before, initial);
1405            chart.chart_options.handle_scroll.enabled = true;
1406            chart.chart_options.handle_scale.enabled = true;
1407            frame(
1408                &ctx,
1409                &mut chart,
1410                0.08,
1411                vec![Event::PointerMoved(start + egui::vec2(110.0, 0.0))],
1412            );
1413            let after = chart.get_time_scale_state();
1414            if time_axis {
1415                // The time-axis sensitivity scales spacing by 1 + (dx/100)*0.1.
1416                assert!((after.0 - before.0 * 1.01).abs() < 1e-5);
1417            } else {
1418                assert_eq!(after.0, before.0);
1419                assert!((after.1 - (before.1 - 10.0 / before.0)).abs() < 1e-5);
1420            }
1421        }
1422    }
1423
1424    /// Zoom speed must not depend on how many frames a drag spans.
1425    #[test]
1426    fn price_axis_drag_zoom_is_frame_independent() {
1427        for left in [false, true] {
1428            let (one, ten) = (drag_price_axis(1, left), drag_price_axis(10, left));
1429            let span = one.1 - one.0;
1430            // Dragging down zooms out from the autoscaled 93.5..111.5 (95..110 plus 10% margins).
1431            assert!(span > 18.0, "1 frame: {one:?}");
1432            assert!(
1433                (one.0 - ten.0).abs() < span * 1e-9 && (one.1 - ten.1).abs() < span * 1e-9,
1434                "1 frame: {one:?}, 10 frames: {ten:?}"
1435            );
1436        }
1437    }
1438
1439    /// With auto-scale off, the prices follow a drag on the chart: 100 points down moves the
1440    /// 20-wide range up by 20 * 100 / height, or down by as much on an inverted scale.
1441    #[test]
1442    fn chart_drag_pans_a_manual_price_range() {
1443        for (invert_scale, sign) in [(false, 1.0), (true, -1.0)] {
1444            for (touch, mouse, vertical, group_enabled) in [
1445                (false, true, false, true),
1446                (false, false, true, true),
1447                (true, false, true, true),
1448                (true, true, false, true),
1449                (false, true, true, false),
1450                (true, true, true, false),
1451            ] {
1452                let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]);
1453                chart.apply_series_settings(&SeriesSettings {
1454                    invert_scale,
1455                    ..SeriesSettings::default()
1456                });
1457                chart.chart_options.handle_scroll.pressed_mouse_move = mouse;
1458                chart.chart_options.handle_scroll.enabled = group_enabled;
1459                chart.chart_options.handle_scale.enabled = false;
1460                chart.chart_options.handle_scroll.vert_touch_drag = vertical;
1461                chart.chart_options.handle_scroll.horz_touch_drag = false;
1462                chart.chart_options.kinetic_scroll.enabled = false;
1463                chart.state.price_scale_mut().set_range(90.0, 110.0);
1464                drag_down(&mut chart, |rect| rect.center(), 10, touch);
1465                let enabled = group_enabled && if touch { vertical } else { mouse };
1466                let shift = if enabled {
1467                    sign * 20.0 * 100.0 / chart.last_rendered_price_rect.height() as f64
1468                } else {
1469                    0.0
1470                };
1471                let range = chart.state.price_scale().range();
1472                assert!(
1473                    (range.min - (90.0 + shift)).abs() < 1e-4
1474                        && (range.max - (110.0 + shift)).abs() < 1e-4,
1475                    "inverted {invert_scale}, touch={touch}, mouse={mouse}, vertical={vertical}: {range:?}, expected a shift of {shift}"
1476                );
1477            }
1478        }
1479    }
1480
1481    /// A grabbed bar follows the entire horizontal drag, including its first movement frame.
1482    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1483    #[cfg_attr(not(target_arch = "wasm32"), test)]
1484    fn chart_drag_keeps_the_grabbed_bar_under_the_pointer() {
1485        for steps in [1, 10] {
1486            for (touch, mouse, horizontal, group_enabled) in [
1487                (false, true, false, true),
1488                (false, false, true, true),
1489                (true, false, true, true),
1490                (true, true, false, true),
1491                (false, true, true, false),
1492                (true, true, true, false),
1493            ] {
1494                for distance in [-60.0, 60.0] {
1495                    let ctx = egui::Context::default();
1496                    let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]);
1497                    frame(&ctx, &mut chart, 0.0, vec![]);
1498                    chart.chart_options.handle_scroll.pressed_mouse_move = mouse;
1499                    chart.chart_options.handle_scroll.enabled = group_enabled;
1500                    chart.chart_options.handle_scale.enabled = false;
1501                    chart.chart_options.handle_scroll.horz_touch_drag = horizontal;
1502                    chart.chart_options.handle_scroll.vert_touch_drag = false;
1503                    chart.chart_options.kinetic_scroll.enabled = false;
1504                    let contact = |phase, pos| Event::Touch {
1505                        device_id: egui::TouchDeviceId(1),
1506                        id: egui::TouchId(1),
1507                        phase,
1508                        pos,
1509                        force: None,
1510                    };
1511                    let start = chart.last_rendered_price_rect.center();
1512                    let (spacing, offset) = chart.get_time_scale_state();
1513                    let button = |pos, pressed| Event::PointerButton {
1514                        pos,
1515                        button: PointerButton::Primary,
1516                        pressed,
1517                        modifiers: Modifiers::NONE,
1518                    };
1519                    frame(&ctx, &mut chart, 0.02, vec![Event::PointerMoved(start)]);
1520                    let mut events = vec![button(start, true)];
1521                    if touch {
1522                        events.push(contact(egui::TouchPhase::Start, start));
1523                    }
1524                    frame(&ctx, &mut chart, 0.04, events);
1525                    let mut end = start;
1526                    for i in 1..=steps {
1527                        end.x = start.x + distance * i as f32 / steps as f32;
1528                        let mut events = vec![Event::PointerMoved(end)];
1529                        if touch {
1530                            events.push(contact(egui::TouchPhase::Move, end));
1531                        }
1532                        frame(&ctx, &mut chart, 0.04 + i as f64 * 0.02, events);
1533                    }
1534                    let mut events = vec![button(end, false)];
1535                    if touch {
1536                        events.push(contact(egui::TouchPhase::End, end));
1537                    }
1538                    frame(&ctx, &mut chart, 0.5, events);
1539                    // Moving 60 pixels at six pixels per bar moves the right offset by ten bars.
1540                    let enabled = group_enabled && if touch { horizontal } else { mouse };
1541                    let expected = offset - if enabled { distance / spacing } else { 0.0 };
1542                    let (after_spacing, after_offset) = chart.get_time_scale_state();
1543                    assert_eq!(after_spacing, spacing);
1544                    assert!(
1545                        (after_offset - expected).abs() < 1e-4,
1546                        "touch={touch}, mouse={mouse}, horizontal={horizontal}, {steps} steps, {distance} pixels: {after_offset}, expected {expected}"
1547                    );
1548                }
1549            }
1550        }
1551    }
1552
1553    /// A fast horizontal drag continues after release only when inertia is enabled for its
1554    /// device, and a new drag stops it.
1555    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1556    #[cfg_attr(not(target_arch = "wasm32"), test)]
1557    fn released_drag_continues_under_its_device_inertia() {
1558        for touch in [false, true] {
1559            for (touch_inertia, mouse_inertia) in
1560                [(false, false), (true, false), (false, true), (true, true)]
1561            {
1562                let ctx = egui::Context::default();
1563                let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]);
1564                chart.chart_options.kinetic_scroll.enabled = touch_inertia;
1565                chart.chart_options.kinetic_scroll.mouse = mouse_inertia;
1566                frame(&ctx, &mut chart, 0.0, vec![]);
1567                let start = chart.last_rendered_price_rect.center();
1568                let button = |pos, pressed| Event::PointerButton {
1569                    pos,
1570                    button: PointerButton::Primary,
1571                    pressed,
1572                    modifiers: Modifiers::NONE,
1573                };
1574                let contact = |phase, pos| Event::Touch {
1575                    device_id: egui::TouchDeviceId(1),
1576                    id: egui::TouchId(1),
1577                    phase,
1578                    pos,
1579                    force: None,
1580                };
1581                let input = |pointer: Event, phase, pos| {
1582                    let mut events = vec![pointer];
1583                    if touch {
1584                        events.push(contact(phase, pos));
1585                    }
1586                    events
1587                };
1588                frame(&ctx, &mut chart, 0.02, vec![Event::PointerMoved(start)]);
1589                let press = input(button(start, true), egui::TouchPhase::Start, start);
1590                frame(&ctx, &mut chart, 0.04, press);
1591                let mut end = start;
1592                for i in 1..=4 {
1593                    end.x = start.x - 15.0 * i as f32;
1594                    let events = input(Event::PointerMoved(end), egui::TouchPhase::Move, end);
1595                    frame(&ctx, &mut chart, 0.04 + i as f64 * 0.02, events);
1596                }
1597                let release = input(button(end, false), egui::TouchPhase::End, end);
1598                frame(&ctx, &mut chart, 0.2, release);
1599                let expected = if touch { touch_inertia } else { mouse_inertia };
1600                assert_eq!(
1601                    chart.kinetic_scroll.is_active, expected,
1602                    "touch={touch}, touch inertia={touch_inertia}, mouse inertia={mouse_inertia}"
1603                );
1604
1605                let press = input(button(end, true), egui::TouchPhase::Start, end);
1606                frame(&ctx, &mut chart, 0.22, press);
1607                end.x += 20.0;
1608                let events = input(Event::PointerMoved(end), egui::TouchPhase::Move, end);
1609                frame(&ctx, &mut chart, 0.24, events);
1610                assert!(!chart.kinetic_scroll.is_active);
1611            }
1612        }
1613    }
1614
1615    /// Two contacts translate each enabled axis once, independently of pinch scaling.
1616    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1617    #[cfg_attr(not(target_arch = "wasm32"), test)]
1618    fn two_finger_translation_respects_touch_permissions() {
1619        for (pinch, group_enabled) in [(false, true), (true, true), (true, false)] {
1620            for (horizontal, vertical) in
1621                [(false, false), (false, true), (true, false), (true, true)]
1622            {
1623                let ctx = egui::Context::default();
1624                let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]);
1625                chart.chart_options.handle_scroll.horz_touch_drag = horizontal;
1626                chart.chart_options.handle_scroll.vert_touch_drag = vertical;
1627                chart.chart_options.handle_scale.pinch = pinch;
1628                chart.chart_options.handle_scale.enabled = false;
1629                chart.chart_options.handle_scroll.enabled = group_enabled;
1630                let horizontal = horizontal && group_enabled;
1631                let vertical = vertical && group_enabled;
1632                chart.chart_options.kinetic_scroll.enabled = false;
1633                chart.state.price_scale_mut().set_range(90.0, 110.0);
1634                frame(&ctx, &mut chart, 0.0, vec![]);
1635                let rect = chart.last_rendered_price_rect;
1636                let start = rect.center();
1637                let (spacing, offset) = chart.get_time_scale_state();
1638                let contact = |id, phase, pos| Event::Touch {
1639                    device_id: egui::TouchDeviceId(1),
1640                    id: egui::TouchId(id),
1641                    phase,
1642                    pos,
1643                    force: None,
1644                };
1645                frame(&ctx, &mut chart, 0.01, vec![Event::PointerMoved(start)]);
1646                frame(
1647                    &ctx,
1648                    &mut chart,
1649                    0.02,
1650                    vec![
1651                        Event::PointerMoved(start),
1652                        Event::PointerButton {
1653                            pos: start,
1654                            button: PointerButton::Primary,
1655                            pressed: true,
1656                            modifiers: Modifiers::NONE,
1657                        },
1658                        contact(1, egui::TouchPhase::Start, start),
1659                        contact(2, egui::TouchPhase::Start, start + egui::vec2(100.0, 0.0)),
1660                    ],
1661                );
1662                for step in 1..=2 {
1663                    let pos = start + egui::vec2(30.0, 30.0) * step as f32;
1664                    frame(
1665                        &ctx,
1666                        &mut chart,
1667                        0.02 + step as f64 * 0.02,
1668                        vec![
1669                            Event::PointerMoved(pos),
1670                            contact(1, egui::TouchPhase::Move, pos),
1671                            contact(2, egui::TouchPhase::Move, pos + egui::vec2(100.0, 0.0)),
1672                        ],
1673                    );
1674                }
1675                let pos = start + egui::vec2(60.0, 60.0);
1676                frame(
1677                    &ctx,
1678                    &mut chart,
1679                    0.08,
1680                    vec![contact(
1681                        2,
1682                        egui::TouchPhase::End,
1683                        pos + egui::vec2(100.0, 0.0),
1684                    )],
1685                );
1686                let pos = start + egui::vec2(90.0, 90.0);
1687                frame(
1688                    &ctx,
1689                    &mut chart,
1690                    0.10,
1691                    vec![
1692                        Event::PointerMoved(pos),
1693                        contact(1, egui::TouchPhase::Move, pos),
1694                    ],
1695                );
1696                let expected_offset = offset - if horizontal { 90.0 / spacing } else { 0.0 };
1697                let shift = if vertical {
1698                    20.0 * 90.0 / rect.height() as f64
1699                } else {
1700                    0.0
1701                };
1702                let range = chart.state.price_scale().range();
1703                assert!(
1704                    (chart.state.time_scale().right_offset() - expected_offset).abs() < 1e-4,
1705                    "pinch={pinch}, horizontal={horizontal}, vertical={vertical}"
1706                );
1707                assert!(
1708                    (range.min - 90.0 - shift).abs() < 1e-4
1709                        && (range.max - 110.0 - shift).abs() < 1e-4,
1710                    "pinch={pinch}, horizontal={horizontal}, vertical={vertical}: {range:?}"
1711                );
1712                assert_eq!(chart.state.time_scale().bar_spacing(), spacing);
1713            }
1714        }
1715    }
1716
1717    /// Axis drags keep their own permission after leaving the axis, including in narrow layouts.
1718    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1719    #[cfg_attr(not(target_arch = "wasm32"), test)]
1720    fn axis_drag_permissions_are_independent_of_plot_drag() {
1721        for (theme, width) in [
1722            (crate::theme::Theme::light(), 800.0),
1723            (crate::theme::Theme::dark(), 800.0),
1724            (crate::theme::Theme::light(), 360.0),
1725            (crate::theme::Theme::dark(), 360.0),
1726        ] {
1727            // Keep the font atlas across cases, as a host does across chart replacements.
1728            let ctx = egui::Context::default();
1729            let frame = |ctx: &egui::Context, chart: &mut Chart, time, events| {
1730                ctx.run_ui(
1731                    egui::RawInput {
1732                        screen_rect: Some(Rect::from_min_size(
1733                            Pos2::ZERO,
1734                            egui::vec2(width, 600.0),
1735                        )),
1736                        time: Some(time),
1737                        events,
1738                        ..Default::default()
1739                    },
1740                    |ui| {
1741                        egui::CentralPanel::default().show(ui, |ui| {
1742                            chart.show(ui);
1743                        });
1744                    },
1745                )
1746                .drop_without_applying_deltas();
1747            };
1748            for price in [false, true] {
1749                for plot in [false, true] {
1750                    for (axis, group_enabled) in [(true, true), (false, true), (true, false)] {
1751                        let time = ctx.input(|input| input.time) + 1.0;
1752                        let mut chart = chart(&[95.0, 100.0], &[105.0, 110.0]);
1753                        chart.set_theme(&ctx, &theme);
1754                        chart.chart_options.handle_scroll.pressed_mouse_move = plot;
1755                        chart.chart_options.handle_scroll.enabled = plot;
1756                        chart.chart_options.handle_scale.enabled = group_enabled;
1757                        chart
1758                            .chart_options
1759                            .handle_scale
1760                            .axis_pressed_mouse_move
1761                            .price = axis;
1762                        chart
1763                            .chart_options
1764                            .handle_scale
1765                            .axis_pressed_mouse_move
1766                            .time = axis;
1767                        let axis = axis && group_enabled;
1768                        frame(&ctx, &mut chart, time, vec![]);
1769                        let rect = chart.last_rendered_price_rect;
1770                        let start = if price {
1771                            Pos2::new(rect.right() + 10.0, rect.center().y)
1772                        } else {
1773                            Pos2::new(
1774                                rect.center().x,
1775                                rect.bottom().max(chart.last_rendered_volume_rect.bottom()) + 5.0,
1776                            )
1777                        };
1778                        let end = start
1779                            + if price {
1780                                egui::vec2(-100.0, 100.0)
1781                            } else {
1782                                egui::vec2(100.0, -100.0)
1783                            };
1784                        let before = chart.state.price_scale().range();
1785                        let before = before.max - before.min;
1786                        let (spacing, offset) = chart.get_time_scale_state();
1787                        let button = |pos, pressed| Event::PointerButton {
1788                            pos,
1789                            button: PointerButton::Primary,
1790                            pressed,
1791                            modifiers: Modifiers::NONE,
1792                        };
1793                        frame(
1794                            &ctx,
1795                            &mut chart,
1796                            time + 0.02,
1797                            vec![Event::PointerMoved(start)],
1798                        );
1799                        frame(&ctx, &mut chart, time + 0.04, vec![button(start, true)]);
1800                        frame(
1801                            &ctx,
1802                            &mut chart,
1803                            time + 0.06,
1804                            vec![Event::PointerMoved(start.lerp(end, 0.5))],
1805                        );
1806                        frame(
1807                            &ctx,
1808                            &mut chart,
1809                            time + 0.08,
1810                            vec![Event::PointerMoved(end)],
1811                        );
1812                        frame(&ctx, &mut chart, time + 0.10, vec![button(end, false)]);
1813                        let expected_span = if price && axis {
1814                            before * (100.0 / rect.height() as f64).exp()
1815                        } else {
1816                            before
1817                        };
1818                        let expected_spacing = if !price && axis {
1819                            spacing * 1.05 * 1.05
1820                        } else {
1821                            spacing
1822                        };
1823                        assert!(
1824                            ((chart.state.price_scale().range().max
1825                                - chart.state.price_scale().range().min)
1826                                - expected_span)
1827                                .abs()
1828                                < 1e-5,
1829                            "price={price}, plot={plot}, axis={axis}"
1830                        );
1831                        assert!(
1832                            (chart.state.time_scale().bar_spacing() - expected_spacing).abs()
1833                                < 1e-5,
1834                            "price={price}, plot={plot}, axis={axis}"
1835                        );
1836                        if !axis {
1837                            assert_eq!(chart.state.time_scale().right_offset(), offset);
1838                        }
1839                    }
1840                }
1841            }
1842        }
1843    }
1844
1845    /// The bars and prices inside a zoom box fill the pane on the next frame. The box re-centres
1846    /// the time window, so the relative modes rebase on another first visible close than the box
1847    /// was drawn on.
1848    #[test]
1849    fn box_zoom_fills_the_pane_with_the_selected_bars_and_prices() {
1850        let lows: Vec<f64> = (0..100).map(|i| 100.0 + i as f64).collect();
1851        let highs: Vec<f64> = lows.iter().map(|low| low + 1.0).collect();
1852        for mode in [PriceScaleMode::Normal, PriceScaleMode::Percentage] {
1853            let ctx = egui::Context::default();
1854            let mut chart = chart(&lows, &highs);
1855            chart.apply_series_settings(&SeriesSettings {
1856                price_scale_mode: mode,
1857                ..SeriesSettings::default()
1858            });
1859            frame(&ctx, &mut chart, 0.0, Vec::new());
1860            let rect = chart.last_rendered_price_rect;
1861            let before = chart.get_chart_mapping().price;
1862            let (y_hi, y_lo) = (rect.top() + 100.0, rect.top() + 200.0);
1863            let (hi, lo) = (before.y_to_price(y_hi), before.y_to_price(y_lo));
1864            let (start, end) = (
1865                Pos2::new(rect.right() - 150.0, y_hi),
1866                Pos2::new(rect.right() - 50.0, y_lo),
1867            );
1868            let idx = |x| {
1869                let ts = chart.state.time_scale();
1870                ts.coord_to_idx(x, rect.left(), rect.width())
1871            };
1872            let (left, right) = (idx(start.x), idx(end.x));
1873            assert!(chart.execute_box_zoom(start, end, rect, rect.width()));
1874            frame(&ctx, &mut chart, 0.1, Vec::new());
1875            let ts = chart.state.time_scale();
1876            let x = |idx| ts.idx_to_coord_precise(idx, rect.left(), rect.width());
1877            let (x_left, x_right) = (x(left), x(right));
1878            assert!(
1879                (x_left - rect.left()).abs() < 0.5 && (x_right - rect.right()).abs() < 0.5,
1880                "{mode:?}: bars {left}..{right} at x {x_left}..{x_right}, pane {rect:?}"
1881            );
1882            let after = chart.get_chart_mapping().price;
1883            let (top, bottom) = after.extent();
1884            let (y_hi, y_lo) = (after.price_to_y(hi), after.price_to_y(lo));
1885            assert!(
1886                (y_hi - top).abs() < 0.5 && (y_lo - bottom).abs() < 0.5,
1887                "{mode:?}: {lo}..{hi} at y {y_lo}..{y_hi}, pane {top}..{bottom}"
1888            );
1889        }
1890
1891        // A box inside one bar zooms to one bar across the pane, no further.
1892        let ctx = egui::Context::default();
1893        let mut one_bar = chart(&lows, &highs);
1894        frame(&ctx, &mut one_bar, 0.0, Vec::new());
1895        let rect = one_bar.last_rendered_price_rect;
1896        one_bar.state.time_scale_mut().set_bar_spacing(40.0);
1897        let (x, y) = (rect.center().x, rect.top() + 100.0);
1898        let (start, end) = (Pos2::new(x, y), Pos2::new(x + 25.0, y + 100.0));
1899        assert!(one_bar.execute_box_zoom(start, end, rect, rect.width()));
1900        assert_eq!(one_bar.state.time_scale().bar_spacing(), rect.width());
1901
1902        // Invalid corners or width must leave both scales and zoom history unchanged.
1903        let view = one_bar.get_time_scale_state();
1904        let range = one_bar.state.price_scale().range();
1905        one_bar.state.clear_zoom_history();
1906        for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1907            for (start, end, width) in [
1908                (
1909                    Pos2::new(invalid, invalid),
1910                    Pos2::new(invalid, invalid),
1911                    rect.width(),
1912                ),
1913                (Pos2::new(invalid, y), end, rect.width()),
1914                (start, Pos2::new(x + 25.0, invalid), rect.width()),
1915                (start, end, invalid),
1916            ] {
1917                assert!(!one_bar.execute_box_zoom(start, end, rect, width));
1918                assert_eq!(one_bar.get_time_scale_state(), view);
1919                assert_eq!(one_bar.state.price_scale().range(), range);
1920                assert!(!one_bar.state.has_zoom_history());
1921            }
1922        }
1923
1924        // A box over a range narrower than the zoom floor widens to it: a millionth of the
1925        // visible data's highest price, 110.
1926        let ctx = egui::Context::default();
1927        let mut narrow = chart(&[95.0, 100.0], &[105.0, 110.0]);
1928        narrow.state.price_scale_mut().set_range(100.0, 100.00001);
1929        frame(&ctx, &mut narrow, 0.0, Vec::new());
1930        let rect = narrow.last_rendered_price_rect;
1931        let (start, end) = (
1932            Pos2::new(rect.right() - 150.0, rect.top() + 100.0),
1933            Pos2::new(rect.right() - 50.0, rect.top() + 200.0),
1934        );
1935        assert!(narrow.execute_box_zoom(start, end, rect, rect.width()));
1936        let range = narrow.state.price_scale().range();
1937        assert!(
1938            (range.max - range.min - 1.1e-4).abs() < 1e-10,
1939            "{range:?}, expected a span of 1.1e-4"
1940        );
1941    }
1942}