1mod 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
28pub(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 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 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 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 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 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 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 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 if self.chart_options.handle_scale.enabled
201 && self.chart_options.handle_scale.pinch
202 && (zoom_delta - 1.0).abs() > 0.001
203 {
204 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; }
603
604 self.state.push_zoom_state();
606
607 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 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 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 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 }
644
645 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 pub(crate) fn apply_timescale_config(&mut self, chart_width: f32) {
672 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 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 #[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 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 #[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 #[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 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 #[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 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 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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 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 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 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}