1#[cfg(feature = "widget")]
12use chrono::{DateTime, Utc};
13use std::sync::Arc;
14
15#[derive(Debug, Clone)]
21pub struct NumericPositions {
22 points: Arc<[f64]>,
24 #[cfg(feature = "widget")]
25 pub(crate) unit: f64,
26 #[cfg(feature = "widget")]
27 origin: DateTime<Utc>,
28}
29
30impl NumericPositions {
31 #[cfg(feature = "widget")]
32 pub(crate) fn new(points: Vec<f64>, unit: f64, origin: DateTime<Utc>) -> Self {
33 Self {
34 points: points.into(),
35 unit,
36 origin,
37 }
38 }
39
40 #[cfg(feature = "widget")]
41 pub(crate) fn index_at(&self, time: DateTime<Utc>) -> f64 {
42 let elapsed = time - self.origin;
43 let seconds = elapsed.num_seconds() as f64 + f64::from(elapsed.subsec_nanos()) / 1e9;
44 self.index(seconds / self.unit)
45 }
46
47 pub(crate) fn position(&self, index: f64) -> f64 {
48 if self.points.len() < 2 || !index.is_finite() {
49 return index;
50 }
51 let at = (index + 2.0)
52 .floor()
53 .clamp(0.0, (self.points.len() - 2) as f64) as usize;
54 self.points[at] + (index + 2.0 - at as f64) * (self.points[at + 1] - self.points[at])
55 }
56
57 pub(crate) fn index(&self, position: f64) -> f64 {
58 if self.points.len() < 2 || !position.is_finite() {
59 return position;
60 }
61 let right = self
62 .points
63 .partition_point(|&point| point < position)
64 .clamp(1, self.points.len() - 1);
65 let gap = self.points[right] - self.points[right - 1];
66 right as f64 - 3.0
67 + if gap > 0.0 {
68 (position - self.points[right - 1]) / gap
69 } else {
70 0.0
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq)]
81pub struct LogicalRange {
82 pub left: f32,
84 pub right: f32,
86}
87
88impl LogicalRange {
89 pub fn new(left: f32, right: f32) -> Self {
91 Self { left, right }
92 }
93
94 pub fn to_strict_range(&self) -> (usize, usize) {
96 let start = self.left.floor().max(0.0) as usize;
97 let end = (f64::from(self.right).ceil() + 1.0).max(0.0) as usize;
98 (start, end)
99 }
100
101 pub fn length(&self) -> f32 {
103 self.right - self.left
104 }
105}
106
107const DEFAULT_MIN_BAR_SPACING: f32 = 0.5;
109
110const MIN_BAR_SPACING: f32 = 1e-6;
112
113#[derive(Debug, Clone)]
134pub struct TimeScale {
135 numeric_positions: Option<NumericPositions>,
137 #[cfg(feature = "widget")]
138 numeric_revision: Option<(u64, usize, Option<chrono::DateTime<chrono::Utc>>)>,
139 bar_spacing: f64,
141 right_offset: f64,
143 live_right_offset: f32,
145 live_right_offset_pixels: Option<f32>,
147 width: f32,
149 bar_cnt: usize,
151 min_bar_spacing: f32,
153 max_bar_spacing: f32,
155 fix_left_edge: bool,
157 fix_right_edge: bool,
159}
160
161impl TimeScale {
162 pub fn new() -> Self {
164 Self {
165 numeric_positions: None,
166 #[cfg(feature = "widget")]
167 numeric_revision: None,
168 bar_spacing: 8.0,
169 right_offset: 5.0, live_right_offset: 2.5,
171 live_right_offset_pixels: None,
172 width: 800.0,
173 bar_cnt: 0,
174 min_bar_spacing: DEFAULT_MIN_BAR_SPACING,
175 max_bar_spacing: 0.0, fix_left_edge: false,
177 fix_right_edge: false, }
179 }
180
181 pub fn apply_options(
186 &mut self,
187 bar_spacing: f32,
188 min_bar_spacing: f32,
189 max_bar_spacing: f32,
190 fix_left_edge: bool,
191 fix_right_edge: bool,
192 right_offset_bars: f32,
193 right_offset_pixels: Option<f32>,
194 ) {
195 self.min_bar_spacing = min_bar_spacing;
197 self.max_bar_spacing = max_bar_spacing;
198 self.fix_left_edge = fix_left_edge;
199 self.fix_right_edge = fix_right_edge;
200
201 self.set_bar_spacing(bar_spacing);
203
204 if right_offset_bars.is_finite() {
205 self.live_right_offset = right_offset_bars;
206 }
207 if right_offset_pixels.is_none_or(f32::is_finite) {
208 self.live_right_offset_pixels = right_offset_pixels;
209 }
210 self.set_right_offset(self.live_edge_offset());
211 }
212
213 pub(crate) fn live_edge_offset(&self) -> f32 {
216 self.live_right_offset_pixels
217 .map_or(self.live_right_offset as f64, |px| {
218 px as f64 / self.bar_spacing
219 }) as f32
220 }
221
222 pub fn set_width(&mut self, width: f32) {
225 if !width.is_finite() {
226 return;
227 }
228 self.width = width;
229 self.apply_constraints();
231 }
232
233 pub fn set_bar_cnt(&mut self, count: usize) {
235 self.bar_cnt = count;
236 self.apply_constraints();
238 }
239
240 pub fn numeric_positions(&self) -> Option<&NumericPositions> {
242 self.numeric_positions.as_ref()
243 }
244
245 #[cfg(feature = "widget")]
246 pub(crate) fn set_numeric_positions(&mut self, positions: Option<NumericPositions>) {
247 self.numeric_positions = positions;
248 self.numeric_revision = None;
249 self.apply_constraints();
250 }
251
252 #[cfg(feature = "widget")]
253 pub(crate) fn refresh_numeric_positions(&mut self, bars: &[crate::model::Bar], revision: u64) {
254 let key = (revision, bars.len(), bars.last().map(|bar| bar.time));
255 if self.numeric_positions.is_some() && self.numeric_revision != Some(key) {
256 self.numeric_positions = Some(crate::model::numeric_positions(bars));
257 self.numeric_revision = Some(key);
258 }
259 }
260
261 pub(crate) fn position(&self, index: f64) -> f64 {
262 self.numeric_positions
263 .as_ref()
264 .map_or(index, |positions| positions.position(index))
265 }
266
267 fn index(&self, position: f64) -> f64 {
268 self.numeric_positions
269 .as_ref()
270 .map_or(position, |positions| positions.index(position))
271 }
272
273 pub fn set_bar_spacing(&mut self, spacing: f32) {
275 self.bar_spacing = self.clamp_spacing(spacing as f64);
276 self.apply_constraints();
279 }
280
281 pub fn set_min_bar_spacing(&mut self, min: f32) {
283 if !min.is_finite() {
284 return;
285 }
286 self.min_bar_spacing = min.max(0.0);
287 self.bar_spacing = self.clamp_spacing(self.bar_spacing);
289 self.apply_constraints();
290 }
291
292 pub fn set_max_bar_spacing(&mut self, max: f32) {
295 if !max.is_finite() {
296 return;
297 }
298 self.max_bar_spacing = max.max(0.0);
299 self.bar_spacing = self.clamp_spacing(self.bar_spacing);
301 self.apply_constraints();
302 }
303
304 pub fn set_fix_left_edge(&mut self, fix: bool) {
306 self.fix_left_edge = fix;
307 self.apply_constraints();
308 }
309
310 pub fn set_fix_right_edge(&mut self, fix: bool) {
312 self.fix_right_edge = fix;
313 self.apply_constraints();
314 }
315
316 pub fn set_right_offset(&mut self, offset: impl Into<f64>) {
318 let offset = offset.into();
319 if !offset.is_finite() {
320 return;
321 }
322 self.right_offset = offset;
323 self.apply_constraints();
324 }
325
326 pub fn jump_to_latest(&mut self) {
329 self.scroll_to_realtime();
330 }
331
332 pub fn bar_spacing(&self) -> f32 {
334 self.bar_spacing as f32
335 }
336
337 pub fn right_offset(&self) -> f32 {
339 self.right_offset as f32
340 }
341
342 pub fn right_offset_precise(&self) -> f64 {
344 self.right_offset
345 }
346
347 #[cfg(feature = "widget")]
349 pub(crate) fn is_near_live_edge(&self) -> bool {
350 self.right_offset >= -1.5
351 }
352
353 pub fn width(&self) -> f32 {
355 self.width
356 }
357
358 pub fn base_idx(&self) -> usize {
360 self.bar_cnt.saturating_sub(1)
361 }
362
363 pub fn visible_logical_range(&self) -> LogicalRange {
365 let base_idx = self.base_idx() as f64;
366 let bars_len = self.width as f64 / self.bar_spacing;
367 let right_border = base_idx + self.right_offset;
368 let left_border = right_border - bars_len + 1.0;
369
370 LogicalRange::new(
371 self.index(left_border) as f32,
372 self.index(right_border) as f32,
373 )
374 }
375
376 pub fn idx_to_coord(&self, index: usize, rect_min_x: f32, rect_width: f32) -> f32 {
381 self.idx_to_coord_precise(index as f64, rect_min_x, rect_width)
382 }
383
384 pub fn idx_to_coord_precise(
390 &self,
391 index: impl Into<f64>,
392 rect_min_x: f32,
393 rect_width: f32,
394 ) -> f32 {
395 let delta_from_right =
396 (self.base_idx() as f64 - self.position(index.into())) + self.right_offset;
397 (f64::from(rect_min_x) + f64::from(rect_width)
398 - (delta_from_right + 0.5) * self.bar_spacing
399 - 1.0) as f32
400 }
401
402 pub fn coord_to_idx(&self, x: f32, rect_min_x: f32, rect_width: f32) -> f32 {
407 self.coord_to_idx_precise(x, rect_min_x, rect_width) as f32
408 }
409
410 pub fn coord_to_idx_precise(&self, x: f32, rect_min_x: f32, rect_width: f32) -> f64 {
413 let relative_x = f64::from(x) - f64::from(rect_min_x);
414 let delta_from_right = (f64::from(rect_width) - relative_x - 1.0) / self.bar_spacing - 0.5;
415 self.index(self.base_idx() as f64 + (self.right_offset - delta_from_right))
416 }
417
418 #[cfg(feature = "widget")]
422 pub(crate) fn fit_logical_range(&mut self, left: f64, right: f64, default_max: f64) {
423 let (left, right) = (self.position(left), self.position(right));
424 let (min, max) = self.spacing_bounds();
425 let max = if max > 0.0 { max } else { default_max.max(min) };
426 let spacing = (self.width as f64 - 1.0) / (right - left);
427 self.bar_spacing = self.clamp_spacing(spacing.min(max));
428 let slots = (self.width as f64 - 1.0) / self.bar_spacing;
429 let right = if spacing > max {
430 (left + right) / 2.0 + slots / 2.0
431 } else {
432 right
433 };
434 let last = self.base_idx() as f64;
435 let right = if right - slots <= last {
436 right
437 } else if spacing < min {
438 last + 0.5
439 } else {
440 last + (slots - 0.5).max(slots / 2.0)
442 };
443 self.right_offset = right - last - 0.5;
444 self.apply_constraints();
445 }
446
447 pub fn scroll_bars(&mut self, bars: f32) {
450 if !bars.is_finite() {
451 return;
452 }
453 self.set_right_offset(self.right_offset - f64::from(bars));
454 }
455
456 pub fn scroll_pixels(&mut self, pixels: f32) {
458 if !pixels.is_finite() {
459 return;
460 }
461 self.set_right_offset(self.right_offset - pixels as f64 / self.bar_spacing);
462 }
463
464 pub fn zoom(&mut self, delta: f32, anchor_x: f32, _rect_min_x: f32, rect_width: f32) {
469 if !delta.is_finite() || !anchor_x.is_finite() || !rect_width.is_finite() {
470 return;
471 }
472 let old_spacing = self.bar_spacing;
473 let anchor_distance = f64::from(rect_width) - f64::from(anchor_x) - 1.0;
474
475 let zoom_scale = delta as f64;
477 let new_spacing = old_spacing + zoom_scale * (old_spacing / 10.0);
478
479 self.bar_spacing = self.clamp_spacing(new_spacing);
480
481 let right_offset = self.right_offset
483 + (anchor_distance / self.bar_spacing - anchor_distance / old_spacing);
484
485 if (rect_width - self.width).abs() > 0.1 {
487 log::warn!(
488 "[ZOOM WIDTH MISMATCH] rect_width={:.1}, self.width={:.1}",
489 rect_width,
490 self.width
491 );
492 }
493
494 let before_constraint = right_offset;
496 self.set_right_offset(right_offset);
497
498 if (self.right_offset - before_constraint).abs() > 0.01 {
500 log::debug!(
501 "[ZOOM CONSTRAINT] right_offset changed: {:.3} -> {:.3} (delta={:.3})",
502 before_constraint,
503 self.right_offset,
504 self.right_offset - before_constraint
505 );
506 }
507 }
508
509 pub fn fit_content(&mut self) {
514 if self.bar_cnt > 0 {
515 let (pixels, bars) = self
516 .live_right_offset_pixels
517 .map_or((0.0, self.live_right_offset), |px| (px, 0.0));
518 self.bar_spacing = self.clamp_spacing(
519 (self.width as f64 - pixels as f64) / (self.bar_cnt as f64 + bars as f64).max(1.0),
520 );
521 self.scroll_to_realtime();
522 }
523 }
524
525 pub fn scroll_to_realtime(&mut self) {
530 self.set_right_offset(self.live_edge_offset());
531 }
532
533 fn apply_constraints(&mut self) {
535 if !(self.width > 0.0 && self.bar_spacing > 0.0) || self.bar_cnt == 0 {
536 return;
537 }
538
539 let min_right = self.calculate_min_right_offset();
541 let max_right = self.calculate_max_right_offset();
542
543 let (min_right_offset, max_right_offset) = if let Some(min_val) = min_right {
545 if min_val <= max_right {
546 (min_val, max_right)
547 } else {
548 (max_right, min_val)
549 }
550 } else {
551 (f64::NEG_INFINITY, max_right)
552 };
553
554 self.right_offset = self.right_offset.clamp(min_right_offset, max_right_offset);
556
557 if self.fix_right_edge && self.right_offset.abs() < 1e-6 {
559 self.right_offset = 0.0;
560 }
561 }
562
563 fn calculate_min_right_offset(&self) -> Option<f64> {
568 if self.bar_cnt == 0 || !(self.width > 0.0 && self.bar_spacing > 0.0) {
569 return None;
570 }
571
572 if self.fix_left_edge {
573 Some(self.width as f64 / self.bar_spacing - self.bar_cnt as f64)
575 } else {
576 Some(-(self.bar_cnt as f64 + 100.0))
577 }
578 }
579
580 fn calculate_max_right_offset(&self) -> f64 {
583 if self.bar_cnt == 0 {
584 return 0.0;
585 }
586
587 if self.fix_right_edge {
588 0.0
589 } else {
590 self.width as f64 / self.spacing_bounds().0
594 }
595 }
596
597 fn spacing_bounds(&self) -> (f64, f64) {
601 let max = if self.max_bar_spacing.is_finite() && self.max_bar_spacing > 0.0 {
602 self.max_bar_spacing.max(MIN_BAR_SPACING) as f64
603 } else {
604 0.0
605 };
606 let min = if self.min_bar_spacing.is_finite() {
607 self.min_bar_spacing.max(MIN_BAR_SPACING) as f64
608 } else {
609 DEFAULT_MIN_BAR_SPACING as f64
610 };
611 (if max > 0.0 { min.min(max) } else { min }, max)
612 }
613
614 fn clamp_spacing(&self, spacing: f64) -> f64 {
615 let spacing = if spacing.is_finite() {
616 spacing
617 } else {
618 self.bar_spacing
619 };
620 match self.spacing_bounds() {
621 (min, max) if max > 0.0 => spacing.clamp(min, max),
622 (min, _) => spacing.max(min),
623 }
624 }
625
626 pub fn visible_candles(&self) -> usize {
628 (self.width as f64 / self.bar_spacing).floor() as usize
629 }
630}
631
632impl Default for TimeScale {
633 fn default() -> Self {
634 Self::new()
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
644 fn large_history_keeps_small_pans_and_zoom_anchors() {
645 for (offset, index) in [(0.0, 999_999), (-500_000.0, 499_999)] {
646 let mut scale = TimeScale::new();
647 scale.set_bar_cnt(1_000_000);
648 scale.set_width(800.0);
649 scale.set_bar_spacing(200.0);
650 scale.set_right_offset(offset);
651 assert_eq!(scale.idx_to_coord(index, 0.0, 800.0), 699.0);
652 scale.scroll_pixels(1.0);
653 assert_eq!(scale.idx_to_coord(index, 0.0, 800.0), 700.0);
654 let anchor = scale.idx_to_coord(index - 2, 0.0, 800.0);
655 for delta in [1.0, -1.0, 2.0] {
656 scale.zoom(delta, anchor, 0.0, 800.0);
657 assert!((scale.idx_to_coord(index - 2, 0.0, 800.0) - anchor).abs() < 1e-3);
658 }
659 }
660 }
661
662 #[test]
666 fn live_edge_keeps_the_configured_right_offset() {
667 type Back = fn(&mut TimeScale);
668 for (bars, pixels, spacing, expected) in [
669 (20.0, None, 6.0, 20.0),
670 (20.0, Some(60.0), 6.0, 10.0),
671 (20.0, Some(60.0), 12.0, 5.0),
672 ] {
673 let mut ts = TimeScale::new();
674 ts.set_bar_cnt(100);
675 ts.apply_options(6.0, 0.5, 0.0, false, false, bars, pixels);
676 ts.set_bar_spacing(spacing);
677 let backs: [(&str, Back); 2] = [
678 ("follow", TimeScale::scroll_to_realtime),
679 ("jump", TimeScale::jump_to_latest),
680 ];
681 for (name, back) in backs {
682 ts.scroll_bars(30.0);
683 back(&mut ts);
684 assert_eq!(
685 ts.right_offset(),
686 expected,
687 "{pixels:?} at {spacing}, {name}"
688 );
689 }
690 }
691 }
692
693 #[test]
698 fn fix_left_edge_stops_the_view_at_the_first_bar() {
699 for (fix, bars, expected) in [
700 (true, 1000, -900.0),
701 (false, 1000, -1100.0),
702 (true, 50, 50.0),
703 (false, 50, -150.0),
704 ] {
705 let mut ts = TimeScale::new();
706 ts.set_width(800.0);
707 ts.set_bar_cnt(bars);
708 ts.apply_options(8.0, 0.5, 0.0, fix, false, 2.5, None);
709 ts.scroll_bars(5000.0);
710 assert_eq!(ts.right_offset(), expected, "fix {fix}, {bars} bars");
711 }
712 }
713
714 #[test]
715 fn non_finite_inputs_keep_the_time_scale_state() {
716 type Change = fn(&mut TimeScale, f32);
717 let changes: [(&str, Change); 11] = [
718 ("width", TimeScale::set_width),
719 ("spacing", TimeScale::set_bar_spacing),
720 ("minimum spacing", TimeScale::set_min_bar_spacing),
721 ("maximum spacing", TimeScale::set_max_bar_spacing),
722 ("offset", |ts, value| ts.set_right_offset(value)),
723 ("bars", TimeScale::scroll_bars),
724 ("pixels", TimeScale::scroll_pixels),
725 ("zoom delta", |ts, value| ts.zoom(value, 400.0, 0.0, 800.0)),
726 ("zoom anchor", |ts, value| ts.zoom(1.0, value, 0.0, 800.0)),
727 ("zoom width", |ts, value| ts.zoom(1.0, 400.0, 0.0, value)),
728 ("options", |ts, value| {
729 ts.apply_options(value, 0.5, 50.0, true, false, value, Some(value));
730 }),
731 ];
732 for count in [0, 100] {
733 for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
734 for (name, change) in changes {
735 let mut ts = TimeScale::new();
736 ts.set_bar_cnt(count);
737 ts.apply_options(8.0, 0.5, 50.0, true, false, 7.0, Some(80.0));
738 change(&mut ts, invalid);
739 assert_eq!(ts.width(), 800.0, "{name}, {invalid}");
740 assert_eq!(ts.bar_spacing(), 8.0, "{name}, {invalid}");
741 assert_eq!(ts.right_offset(), 10.0, "{name}, {invalid}");
742 ts.set_bar_cnt(100);
743 ts.scroll_bars(1.0);
744 ts.scroll_to_realtime();
745 assert_eq!(ts.right_offset(), 10.0, "{name}, {invalid}");
746 ts.set_bar_spacing(0.0);
747 assert_eq!(ts.bar_spacing(), 0.5, "{name}, {invalid}");
748 ts.set_bar_spacing(100.0);
749 assert_eq!(ts.bar_spacing(), 50.0, "{name}, {invalid}");
750 ts.apply_options(8.0, 0.5, 50.0, true, false, invalid, None);
751 assert_eq!(ts.right_offset(), 7.0, "bar offset, {invalid}");
752 assert_eq!(ts.visible_logical_range(), LogicalRange::new(7.0, 106.0));
753 }
754 }
755 }
756 }
757
758 #[test]
759 fn inconsistent_spacing_bounds_clamp_without_panicking() {
760 type Setup = fn(&mut TimeScale);
762 let rows: [(&str, Setup, (f32, f32)); 10] = [
763 (
764 "zero minimum",
765 |ts| ts.set_min_bar_spacing(0.0),
766 (8.0, 1e-6),
767 ),
768 (
769 "negative minimum option",
770 |ts| ts.apply_options(6.0, -1.0, 0.0, false, false, 2.0, None),
771 (6.0, 1e-6),
772 ),
773 (
774 "maximum below floor",
775 |ts| ts.set_max_bar_spacing(1e-8),
776 (1e-6, 1e-6),
777 ),
778 (
779 "NaN minimum setter",
780 |ts| ts.set_min_bar_spacing(f32::NAN),
781 (8.0, 0.5),
782 ),
783 (
784 "minimum above maximum",
785 |ts| ts.apply_options(6.0, 10.0, 5.0, false, false, 2.0, None),
786 (5.0, 5.0),
787 ),
788 (
789 "NaN minimum",
790 |ts| ts.apply_options(6.0, f32::NAN, 0.0, false, false, 2.0, None),
791 (6.0, 0.5),
792 ),
793 (
794 "minimum set above maximum",
795 |ts| {
796 ts.set_max_bar_spacing(5.0);
797 ts.set_min_bar_spacing(10.0);
798 },
799 (5.0, 5.0),
800 ),
801 (
802 "NaN spacing with a raised minimum",
803 |ts| ts.apply_options(f32::NAN, 10.0, 0.0, false, false, 2.0, None),
804 (10.0, 10.0),
805 ),
806 (
807 "NaN spacing with a lowered maximum",
808 |ts| ts.apply_options(f32::NAN, 0.5, 5.0, false, false, 2.0, None),
809 (5.0, 0.5),
810 ),
811 (
812 "NaN spacing with a lowered maximum and fixed left edge",
813 |ts| {
814 ts.set_bar_spacing(6.0);
815 ts.apply_options(f32::NAN, 0.5, 4.0, true, false, 2.0, None);
816 },
817 (4.0, 0.5),
818 ),
819 ];
820 for (name, setup, (set, zoomed)) in rows {
821 let mut ts = TimeScale::new();
822 ts.set_bar_cnt(100);
823 setup(&mut ts);
824 assert_eq!(ts.bar_spacing(), set, "{name}");
825 for fix_left in [false, true] {
826 ts.set_fix_left_edge(fix_left);
827 for delta in [-10.0, -9.999_999, -100.0] {
828 for _ in 0..100 {
829 ts.zoom(delta, 799.0, 0.0, 800.0);
830 assert_eq!(ts.bar_spacing(), zoomed, "{name}");
831 let range = ts.visible_logical_range();
832 assert!(range.left.is_finite() && range.right.is_finite(), "{name}");
833 assert!(ts.right_offset().is_finite(), "{name}");
834 assert!(ts.right_offset_precise().is_finite(), "{name}");
835 }
836 }
837 if fix_left {
839 ts.scroll_bars(f32::MAX);
840 let expected = 800.0 / f64::from(zoomed) - 100.0;
841 assert!(
842 (ts.right_offset_precise() - expected).abs() < 1e-6,
843 "{name}"
844 );
845 }
846 ts.zoom(5.0, 400.0, 0.0, 800.0);
847 if set > zoomed {
848 assert!(ts.bar_spacing() > zoomed, "{name}");
849 }
850 ts.set_bar_spacing(set);
851 ts.set_fix_left_edge(false);
852 ts.set_right_offset(0.0);
853 ts.scroll_pixels(10.0);
854 let expected = (-10.0 / f64::from(set)).max(-200.0);
855 assert!(
856 (ts.right_offset_precise() - expected).abs() < 1e-6,
857 "{name}"
858 );
859 }
860 }
861 }
862}