Skip to main content

libsurfer/
viewport.rs

1use std::ops::RangeInclusive;
2
3use derive_more::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign};
4use num::{BigInt, BigRational, FromPrimitive, ToPrimitive};
5use serde::{Deserialize, Serialize};
6
7#[derive(
8    Debug,
9    Clone,
10    Copy,
11    Serialize,
12    Deserialize,
13    Add,
14    Sub,
15    Mul,
16    Neg,
17    AddAssign,
18    SubAssign,
19    PartialOrd,
20    PartialEq,
21)]
22pub struct Relative(pub f64);
23
24impl Relative {
25    #[must_use]
26    pub fn absolute(&self, max_timestamp: &BigInt, time_offset: &BigInt) -> Absolute {
27        Absolute(
28            time_offset.to_f64().unwrap()
29                + self.0
30                    * (max_timestamp - time_offset)
31                        .to_f64()
32                        .expect("Failed to convert timestamp to f64"),
33        )
34    }
35
36    #[must_use]
37    pub fn inner(&self) -> f64 {
38        self.0
39    }
40
41    #[must_use]
42    pub fn min(&self, other: &Relative) -> Self {
43        Self(self.0.min(other.0))
44    }
45
46    #[must_use]
47    pub fn max(&self, other: &Relative) -> Self {
48        Self(self.0.max(other.0))
49    }
50}
51
52impl std::ops::Div for Relative {
53    type Output = Relative;
54
55    fn div(self, rhs: Self) -> Self::Output {
56        Self(self.0 / rhs.0)
57    }
58}
59
60#[derive(
61    Debug, Clone, Copy, Serialize, Deserialize, Add, Sub, Mul, Neg, Div, PartialOrd, PartialEq,
62)]
63pub struct Absolute(pub f64);
64
65impl Absolute {
66    #[must_use]
67    pub fn relative(&self, max_timestamp: &BigInt, time_offset: &BigInt) -> Relative {
68        Relative(
69            (self.0 - time_offset.to_f64().unwrap())
70                / (max_timestamp - time_offset)
71                    .to_f64()
72                    .expect("Failed to convert timestamp to f64"),
73        )
74    }
75
76    #[must_use]
77    pub fn inner(&self) -> f64 {
78        self.0
79    }
80}
81
82impl std::ops::Div for Absolute {
83    type Output = Absolute;
84
85    fn div(self, rhs: Self) -> Self::Output {
86        Self(self.0 / rhs.0)
87    }
88}
89
90impl From<&BigInt> for Absolute {
91    fn from(value: &BigInt) -> Self {
92        Self(value.to_f64().expect("Failed to convert timestamp to f64"))
93    }
94}
95
96fn default_edge_space() -> f64 {
97    0.2
98}
99
100fn default_min_width() -> Absolute {
101    Absolute(0.5)
102}
103
104#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
105pub struct Viewport {
106    pub curr_left: Relative,
107    pub curr_right: Relative,
108
109    target_left: Relative,
110    target_right: Relative,
111
112    move_start_left: Relative,
113    move_start_right: Relative,
114
115    // Number of seconds since the the last time a movement happened
116    move_duration: Option<f32>,
117    pub move_strategy: ViewportStrategy,
118    #[serde(skip, default = "default_edge_space")]
119    edge_space: f64,
120
121    #[serde(skip, default = "default_min_width")]
122    min_width: Absolute,
123}
124
125impl Default for Viewport {
126    fn default() -> Self {
127        Self {
128            curr_left: Relative(0.0),
129            curr_right: Relative(1.0),
130            target_left: Relative(0.0),
131            target_right: Relative(1.0),
132            move_start_left: Relative(0.0),
133            move_start_right: Relative(1.0),
134            move_duration: None,
135            move_strategy: ViewportStrategy::Instant,
136            edge_space: default_edge_space(),
137            min_width: default_min_width(),
138        }
139    }
140}
141
142impl Viewport {
143    #[must_use]
144    pub fn new() -> Self {
145        Self::default()
146    }
147    #[must_use]
148    pub fn left_edge_time(self, max_timestamp: &BigInt, time_offset: &BigInt) -> BigInt {
149        BigInt::from(self.curr_left.absolute(max_timestamp, time_offset).0 as i64)
150    }
151    #[must_use]
152    pub fn right_edge_time(self, max_timestamp: &BigInt, time_offset: &BigInt) -> BigInt {
153        BigInt::from(self.curr_right.absolute(max_timestamp, time_offset).0 as i64)
154    }
155
156    /// Converts x-pixel position to absolute time with time offset support
157    #[must_use]
158    pub fn as_absolute_time(
159        &self,
160        x: f64,
161        view_width: f32,
162        max_timestamp: &BigInt,
163        time_offset: &BigInt,
164    ) -> Absolute {
165        let time_spacing = self.width_absolute(max_timestamp, time_offset) / f64::from(view_width);
166        self.curr_left.absolute(max_timestamp, time_offset) + time_spacing * x
167    }
168
169    #[must_use]
170    pub fn as_time_bigint(
171        &self,
172        x: f32,
173        view_width: f32,
174        max_timestamp: &BigInt,
175        time_offset: &BigInt,
176    ) -> BigInt {
177        let Viewport {
178            curr_left: left,
179            curr_right: right,
180            ..
181        } = &self;
182
183        let big_right = BigRational::from_f64(right.absolute(max_timestamp, time_offset).0)
184            .unwrap_or_else(|| BigRational::from_u8(1).unwrap());
185        let big_left = BigRational::from_f64(left.absolute(max_timestamp, time_offset).0)
186            .unwrap_or_else(|| BigRational::from_u8(1).unwrap());
187        let big_width =
188            BigRational::from_f32(view_width).unwrap_or_else(|| BigRational::from_u8(1).unwrap());
189        let big_x = BigRational::from_f32(x).unwrap_or_else(|| BigRational::from_u8(1).unwrap());
190
191        let time = big_left.clone() + (big_right - big_left) / big_width * big_x;
192        time.round().to_integer()
193    }
194
195    /// Computes which x-pixel corresponds to the specified time assuming the viewport is rendered
196    /// into a viewport of `view_width`
197    #[must_use]
198    pub fn pixel_from_time(
199        &self,
200        time: &BigInt,
201        view_width: f32,
202        max_timestamp: &BigInt,
203        time_offset: &BigInt,
204    ) -> f32 {
205        let distance_from_left =
206            Absolute(time.to_f64().unwrap()) - self.curr_left.absolute(max_timestamp, time_offset);
207        let width = self.width_absolute(max_timestamp, time_offset);
208
209        (((distance_from_left / width).0) * f64::from(view_width)) as f32
210    }
211
212    /// Computes which x-pixel corresponds to the specified absolute time with time offset support
213    #[must_use]
214    pub fn pixel_from_absolute_time(
215        &self,
216        time: Absolute,
217        view_width: f32,
218        max_timestamp: &BigInt,
219        time_offset: &BigInt,
220    ) -> f32 {
221        let distance_from_left = time - self.curr_left.absolute(max_timestamp, time_offset);
222        let width = self.width_absolute(max_timestamp, time_offset);
223
224        (((distance_from_left / width).0) * f64::from(view_width)) as f32
225    }
226
227    /// Return new viewport for a different file length.
228    ///
229    /// Tries to keep the current zoom level and position. If zoom is not possible it
230    /// will zoom in as much as needed to keep border margins. If the new waveform is
231    /// too short, the viewport will be moved to the left as much as needed for the zoom level.
232    #[must_use]
233    pub fn clip_to(
234        &self,
235        old_max_timestamp: &BigInt,
236        new_max_timestamp: &BigInt,
237        time_offset: &BigInt,
238    ) -> Viewport {
239        let left_timestamp = self.curr_left.absolute(old_max_timestamp, time_offset);
240        let right_timestamp = self.curr_right.absolute(old_max_timestamp, time_offset);
241        let absolute_width = right_timestamp - left_timestamp;
242
243        let new_absolute_width = new_max_timestamp
244            .to_f64()
245            .expect("Failed to convert timestamp to f64")
246            * (2.0 * self.edge_space);
247        let (left, right) = if absolute_width.0 > new_absolute_width {
248            // is the new waveform so short that we can't keep the current zoom level?
249            (Relative(-self.edge_space), Relative(1.0 + self.edge_space))
250        } else {
251            // our zoom level is achievable but we don't know the waveform is long enough
252            let new_num_ts_f64 = new_max_timestamp
253                .to_f64()
254                .expect("Failed to convert timestamp to f64");
255            let unmoved_left = Relative(left_timestamp.0 / new_num_ts_f64);
256            let unmoved_right = Relative((left_timestamp + absolute_width).0 / new_num_ts_f64);
257            if unmoved_right <= Relative(1.0 + self.edge_space) {
258                // waveform is long enough, keep current view as-is
259                (unmoved_left, unmoved_right)
260            } else {
261                // waveform is too short, clip end to the right edge (including empty space)
262                // since we checked above for zoom level, we know that there must be enough
263                // waveform to the left to keep the current zoom level
264                let relative_width = absolute_width.0 / new_num_ts_f64;
265                (
266                    Relative(1.0 + self.edge_space - relative_width),
267                    Relative(1.0 + self.edge_space),
268                )
269            }
270        };
271
272        Viewport {
273            curr_left: left,
274            curr_right: right,
275            target_left: left,
276            target_right: right,
277            move_start_left: left,
278            move_start_right: right,
279            move_duration: None,
280            move_strategy: self.move_strategy,
281            edge_space: self.edge_space,
282            min_width: self.min_width,
283        }
284    }
285
286    #[inline]
287    fn width(&self) -> Relative {
288        self.curr_right - self.curr_left
289    }
290
291    #[inline]
292    pub(crate) fn width_absolute(&self, max_timestamp: &BigInt, time_offset: &BigInt) -> Absolute {
293        self.curr_right.absolute(max_timestamp, time_offset)
294            - self.curr_left.absolute(max_timestamp, time_offset)
295    }
296
297    pub fn go_to_time(&mut self, center: &BigInt, max_timestamp: &BigInt, time_offset: &BigInt) {
298        let center_point: Absolute = center.into();
299        let half_width = self.half_width_absolute(max_timestamp, time_offset);
300
301        let target_left = (center_point - half_width).relative(max_timestamp, time_offset);
302        let target_right = (center_point + half_width).relative(max_timestamp, time_offset);
303        self.set_viewport_to_clipped(target_left, target_right, max_timestamp, time_offset);
304    }
305
306    pub fn zoom_to_fit(&mut self) {
307        self.set_target_left(Relative(0.0));
308        self.set_target_right(Relative(1.0));
309    }
310
311    pub fn go_to_start(&mut self) {
312        let old_width = self.width();
313        self.set_target_left(Relative(0.0));
314        self.set_target_right(old_width);
315    }
316
317    pub fn go_to_end(&mut self) {
318        self.set_target_left(Relative(1.0) - self.width());
319        self.set_target_right(Relative(1.0));
320    }
321
322    pub fn zoom_to_time(
323        &mut self,
324        center: &BigInt,
325        delta: f64,
326        max_timestamp: &BigInt,
327        time_offset: &BigInt,
328    ) {
329        let center = Absolute::from(center);
330        let half_width = (self.curr_right.absolute(max_timestamp, time_offset)
331            - self.curr_left.absolute(max_timestamp, time_offset))
332            * delta
333            * 0.5;
334
335        self.set_viewport_to_clipped(
336            (center - half_width).relative(max_timestamp, time_offset),
337            (center + half_width).relative(max_timestamp, time_offset),
338            max_timestamp,
339            time_offset,
340        );
341    }
342
343    pub fn handle_canvas_zoom(
344        &mut self,
345        mouse_ptr_timestamp: Option<BigInt>,
346        delta: f64,
347        max_timestamp: &BigInt,
348        time_offset: &BigInt,
349    ) {
350        // Zoom or scroll
351        let Viewport {
352            curr_left: left,
353            curr_right: right,
354            ..
355        } = &self;
356
357        let (target_left, target_right) = if let Some(mouse_location) =
358            mouse_ptr_timestamp.map(|t| Absolute::from(&t).relative(max_timestamp, time_offset))
359        {
360            (
361                (*left - mouse_location) / Relative(delta) + mouse_location,
362                (*right - mouse_location) / Relative(delta) + mouse_location,
363            )
364        } else {
365            let mid_point = self.midpoint();
366            let offset = self.half_width() * delta;
367
368            (mid_point - offset, mid_point + offset)
369        };
370
371        self.set_viewport_to_clipped(target_left, target_right, max_timestamp, time_offset);
372    }
373
374    pub fn handle_canvas_scroll(&mut self, deltay: f64) {
375        // Scroll 5% of the viewport per scroll event.
376        // One scroll event yields 50
377        let scroll_step = -self.width() / Relative(50. * 20.);
378        let scaled_deltay = scroll_step * deltay;
379        self.set_viewport_to_clipped_no_width_check(
380            self.curr_left + scaled_deltay,
381            self.curr_right + scaled_deltay,
382        );
383    }
384
385    fn set_viewport_to_clipped(
386        &mut self,
387        target_left: Relative,
388        target_right: Relative,
389        max_timestamp: &BigInt,
390        time_offset: &BigInt,
391    ) {
392        let rel_min_width = self.min_width.relative(max_timestamp, time_offset);
393
394        if (target_right - target_left) <= rel_min_width + Relative(f64::EPSILON) {
395            let center = (target_left + target_right) * 0.5;
396            self.set_viewport_to_clipped_no_width_check(
397                center - rel_min_width,
398                center + rel_min_width,
399            );
400        } else {
401            self.set_viewport_to_clipped_no_width_check(target_left, target_right);
402        }
403    }
404
405    fn set_viewport_to_clipped_no_width_check(
406        &mut self,
407        target_left: Relative,
408        target_right: Relative,
409    ) {
410        let width = target_right - target_left;
411
412        let abs_min = Relative(-self.edge_space);
413        let abs_max = Relative(1.0 + self.edge_space);
414
415        let max_right = Relative(1.0) + width * self.edge_space;
416        let min_left = -width * self.edge_space;
417        if width > (abs_max - abs_min) {
418            self.set_target_left(abs_min);
419            self.set_target_right(abs_max);
420        } else if target_left < min_left {
421            self.set_target_left(min_left);
422            self.set_target_right(min_left + width);
423        } else if target_right > max_right {
424            self.set_target_left(max_right - width);
425            self.set_target_right(max_right);
426        } else {
427            self.set_target_left(target_left);
428            self.set_target_right(target_right);
429        }
430    }
431
432    #[inline]
433    fn midpoint(&self) -> Relative {
434        (self.curr_right + self.curr_left) * 0.5
435    }
436
437    #[inline]
438    fn half_width(&self) -> Relative {
439        self.width() * 0.5
440    }
441
442    #[inline]
443    fn half_width_absolute(&self, max_timestamp: &BigInt, time_offset: &BigInt) -> Absolute {
444        (self.width() * 0.5).absolute(max_timestamp, time_offset)
445    }
446
447    pub fn zoom_to_range(
448        &mut self,
449        left: &BigInt,
450        right: &BigInt,
451        max_timestamp: &BigInt,
452        time_offset: &BigInt,
453    ) {
454        self.set_viewport_to_clipped(
455            Absolute::from(left).relative(max_timestamp, time_offset),
456            Absolute::from(right).relative(max_timestamp, time_offset),
457            max_timestamp,
458            time_offset,
459        );
460    }
461
462    pub fn go_to_cursor_if_not_in_view(
463        &mut self,
464        cursor: &BigInt,
465        max_timestamp: &BigInt,
466        time_offset: &BigInt,
467    ) -> bool {
468        let fcursor = cursor.into();
469        if fcursor <= self.curr_left.absolute(max_timestamp, time_offset)
470            || fcursor >= self.curr_right.absolute(max_timestamp, time_offset)
471        {
472            self.go_to_time_f64(fcursor, max_timestamp, time_offset);
473            true
474        } else {
475            false
476        }
477    }
478
479    pub fn go_to_time_f64(
480        &mut self,
481        center: Absolute,
482        max_timestamp: &BigInt,
483        time_offset: &BigInt,
484    ) {
485        let half_width = (self.curr_right.absolute(max_timestamp, time_offset)
486            - self.curr_left.absolute(max_timestamp, time_offset))
487            / 2.;
488
489        self.set_viewport_to_clipped(
490            (center - half_width).relative(max_timestamp, time_offset),
491            (center + half_width).relative(max_timestamp, time_offset),
492            max_timestamp,
493            time_offset,
494        );
495    }
496
497    fn set_target_left(&mut self, target_left: Relative) {
498        if let ViewportStrategy::Instant = self.move_strategy {
499            self.curr_left = target_left;
500        } else {
501            self.target_left = target_left;
502            self.move_start_left = self.curr_left;
503            self.move_duration = Some(0.);
504        }
505    }
506    fn set_target_right(&mut self, target_right: Relative) {
507        if let ViewportStrategy::Instant = self.move_strategy {
508            self.curr_right = target_right;
509        } else {
510            self.target_right = target_right;
511            self.move_start_right = self.curr_right;
512            self.move_duration = Some(0.);
513        }
514    }
515
516    pub fn move_viewport(&mut self, frame_time: f32) {
517        match &self.move_strategy {
518            ViewportStrategy::Instant => {
519                self.curr_left = self.target_left;
520                self.curr_right = self.target_right;
521                self.move_duration = None;
522            }
523            ViewportStrategy::EaseInOut { duration } => {
524                if let Some(move_duration) = &mut self.move_duration {
525                    if *move_duration + frame_time >= *duration {
526                        self.move_duration = None;
527                        self.curr_left = self.target_left;
528                        self.curr_right = self.target_right;
529                    } else {
530                        *move_duration += frame_time;
531
532                        self.curr_left = Relative(ease_in_out_size(
533                            self.move_start_left.0..=self.target_left.0,
534                            f64::from(*move_duration) / f64::from(*duration),
535                        ));
536                        self.curr_right = Relative(ease_in_out_size(
537                            self.move_start_right.0..=self.target_right.0,
538                            f64::from(*move_duration) / f64::from(*duration),
539                        ));
540                    }
541                }
542            }
543        }
544    }
545
546    #[must_use]
547    pub fn is_moving(&self) -> bool {
548        self.move_duration.is_some()
549    }
550}
551
552#[must_use]
553fn ease_in_out_size(r: RangeInclusive<f64>, t: f64) -> f64 {
554    r.start() + ((r.end() - r.start()) * -((std::f64::consts::PI * t).cos() - 1.) * 0.5)
555}
556
557#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
558pub enum ViewportStrategy {
559    Instant,
560    EaseInOut { duration: f32 },
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use num::BigInt;
567
568    fn bi(n: i64) -> BigInt {
569        BigInt::from(n)
570    }
571
572    #[test]
573    fn ease_in_out_endpoints_and_mid() {
574        let r = 0.0..=10.0;
575        let s0 = ease_in_out_size(r.clone(), 0.0);
576        let s05 = ease_in_out_size(r.clone(), 0.5);
577        let s1 = ease_in_out_size(r.clone(), 1.0);
578        assert!((s0 - 0.0).abs() < 1e-12);
579        assert!((s05 - 5.0).abs() < 1e-12);
580        assert!((s1 - 10.0).abs() < 1e-12);
581    }
582
583    #[test]
584    fn relative_absolute_roundtrip() {
585        // Test with zero offset
586        let n = bi(1000);
587        let offset = bi(0);
588        let r = Relative(0.25);
589        let abs = r.absolute(&n, &offset);
590        // 0.25 * 1000 = 250.0
591        assert!((abs.0 - 250.0).abs() < 1e-9);
592        let back = abs.relative(&n, &offset);
593        assert!((back.0 - r.0).abs() < 1e-9);
594
595        let offset = bi(500);
596        let r = Relative(0.25);
597        let abs = r.absolute(&n, &offset);
598        // 500 + 0.25 * (1000 - 500) = 500 + 125 = 625.0
599        assert!((abs.0 - 625.0).abs() < 1e-9);
600        let back = abs.relative(&n, &offset);
601        assert!((back.0 - r.0).abs() < 1e-9);
602    }
603
604    #[test]
605    fn pixel_from_time_consistency() {
606        let vp = Viewport::default();
607        let n = bi(1000);
608        let view_w = 1000.0_f32;
609
610        let time_offset = bi(0);
611        let time_abs = Absolute(250.0);
612        let x1 = vp.pixel_from_absolute_time(time_abs, view_w, &n, &time_offset);
613        let x2 = vp.pixel_from_time(&bi(250), view_w, &n, &time_offset);
614        assert!((x1 - 250.0).abs() < 1e-6);
615        assert!((x2 - 250.0).abs() < 1e-6);
616
617        let time_offset = bi(500);
618        let time_abs = Absolute(750.0); // 250 + 500 offset
619        let x1 = vp.pixel_from_absolute_time(time_abs, view_w, &n, &time_offset);
620        let x2 = vp.pixel_from_time(&bi(750), view_w, &n, &time_offset);
621        assert!((x1 - 500.0).abs() < 1e-6);
622        assert!((x2 - 500.0).abs() < 1e-6);
623    }
624
625    #[test]
626    fn set_viewport_min_width_enforced() {
627        let mut vp = Viewport::default();
628        let n = bi(1000);
629        let offset = bi(0);
630        // Try to set zero-width viewport at center
631        let center = Relative(0.5);
632        vp.set_viewport_to_clipped(center, center, &n, &offset);
633        // width must be at least min_width.relative_with_offset(n, offset)
634        let rel_min = vp.min_width.relative(&n, &offset).0;
635        let width = (vp.curr_right - vp.curr_left).0;
636        assert!(
637            width + f64::EPSILON >= rel_min,
638            "width {width} < min {rel_min}"
639        );
640    }
641
642    #[test]
643    fn go_to_start_and_end_preserve_width() {
644        let mut vp = Viewport::default();
645        let w0 = (vp.curr_right - vp.curr_left).0;
646        vp.go_to_end();
647        let w1 = (vp.curr_right - vp.curr_left).0;
648        assert!((w0 - w1).abs() < 1e-12);
649        assert!((vp.curr_right.0 - 1.0).abs() < 1e-12);
650        vp.go_to_start();
651        let w2 = (vp.curr_right - vp.curr_left).0;
652        assert!((w0 - w2).abs() < 1e-12);
653        assert!((vp.curr_left.0 - 0.0).abs() < 1e-12);
654    }
655
656    #[test]
657    fn move_viewport_ease_in_out_reaches_target() {
658        let mut vp = Viewport {
659            move_strategy: ViewportStrategy::EaseInOut { duration: 0.3 },
660            ..Default::default()
661        };
662        let n = bi(1000);
663        let offset = bi(0);
664        // request a move
665        vp.set_viewport_to_clipped(Relative(0.1), Relative(0.3), &n, &offset);
666        let mut t = 0.0;
667        // step in a few frames
668        while vp.is_moving() && t < 1.0 {
669            vp.move_viewport(0.05);
670            t += 0.05;
671        }
672        assert!(!vp.is_moving());
673        assert!((vp.curr_left.0 - 0.1).abs() < 1e-6);
674        assert!((vp.curr_right.0 - 0.3).abs() < 1e-6);
675    }
676
677    #[test]
678    fn clip_to_does_not_invert_viewport() {
679        // Regression test: when clipping viewport to a file with different max_timestamp,
680        // the viewport should never become inverted (left > right).
681        // This reproduces the exact scenario from the bug report.
682        let mut vp = Viewport::default();
683        vp.curr_left = Relative(0.9027133537478365);
684        vp.curr_right = Relative(0.9455180041784441);
685        vp.target_left = vp.curr_left;
686        vp.target_right = vp.curr_right;
687
688        let old_max_timestamp = bi(122055);
689        let new_max_timestamp = bi(131445);
690        let time_offset = bi(0);
691
692        let clipped = vp.clip_to(&old_max_timestamp, &new_max_timestamp, &time_offset);
693
694        assert!(
695            clipped.curr_left.0 < clipped.curr_right.0,
696            "Viewport inverted after clip_to: left={} >= right={}",
697            clipped.curr_left.0,
698            clipped.curr_right.0
699        );
700    }
701
702    #[test]
703    fn clip_to_preserves_valid_viewport_on_file_growth() {
704        // When file grows, viewport should still be valid and maintain approximate position
705        let mut vp = Viewport::default();
706        vp.curr_left = Relative(0.8);
707        vp.curr_right = Relative(0.9);
708        vp.target_left = vp.curr_left;
709        vp.target_right = vp.curr_right;
710
711        let old_max_timestamp = bi(1000);
712        let new_max_timestamp = bi(2000); // file doubled in size
713        let time_offset = bi(0);
714
715        let clipped = vp.clip_to(&old_max_timestamp, &new_max_timestamp, &time_offset);
716
717        // Must not be inverted
718        assert!(
719            clipped.curr_left.0 < clipped.curr_right.0,
720            "Viewport inverted: left={} >= right={}",
721            clipped.curr_left.0,
722            clipped.curr_right.0
723        );
724
725        // Width should be preserved (in absolute terms, so halved in relative terms)
726        let old_width = 0.1; // 0.9 - 0.8
727        let expected_relative_width = old_width * 1000.0 / 2000.0; // 0.05
728        let actual_width = clipped.curr_right.0 - clipped.curr_left.0;
729        assert!(
730            (actual_width - expected_relative_width).abs() < 1e-9,
731            "Width not preserved: expected={}, actual={}",
732            expected_relative_width,
733            actual_width
734        );
735    }
736
737    #[test]
738    fn clip_to_handles_file_shrink_with_viewport_overshoot() {
739        // When file shrinks and viewport would overshoot the new file boundary,
740        // clip_to must correctly reposition the viewport to stay within bounds.
741        let mut vp = Viewport::default();
742        // Viewing timestamps 950-1000 in a 1000-timestamp file (relative 0.95-1.0)
743        vp.curr_left = Relative(0.95);
744        vp.curr_right = Relative(1.0);
745        vp.target_left = vp.curr_left;
746        vp.target_right = vp.curr_right;
747
748        let old_max_timestamp = bi(1000);
749        let new_max_timestamp = bi(500); // file shrinks
750        let time_offset = bi(0);
751
752        let clipped = vp.clip_to(&old_max_timestamp, &new_max_timestamp, &time_offset);
753
754        // Must not be inverted
755        assert!(
756            clipped.curr_left.0 < clipped.curr_right.0,
757            "Viewport inverted: left={} >= right={}",
758            clipped.curr_left.0,
759            clipped.curr_right.0
760        );
761
762        // Left edge must be valid (>= -edge_space)
763        assert!(
764            clipped.curr_left.0 >= -vp.edge_space,
765            "Left edge out of bounds: {}",
766            clipped.curr_left.0
767        );
768
769        // Width should be preserved in absolute terms (50 timestamps = 0.1 relative in new file)
770        let expected_relative_width = 50.0 / 500.0; // 0.1
771        let actual_width = clipped.curr_right.0 - clipped.curr_left.0;
772        assert!(
773            (actual_width - expected_relative_width).abs() < 1e-9,
774            "Width not preserved: expected={}, actual={}",
775            expected_relative_width,
776            actual_width
777        );
778    }
779}