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 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 #[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 #[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 #[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 #[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 (Relative(-self.edge_space), Relative(1.0 + self.edge_space))
250 } else {
251 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 (unmoved_left, unmoved_right)
260 } else {
261 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 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 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 let n = bi(1000);
587 let offset = bi(0);
588 let r = Relative(0.25);
589 let abs = r.absolute(&n, &offset);
590 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 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); 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 let center = Relative(0.5);
632 vp.set_viewport_to_clipped(center, center, &n, &offset);
633 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 vp.set_viewport_to_clipped(Relative(0.1), Relative(0.3), &n, &offset);
666 let mut t = 0.0;
667 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 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 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); let time_offset = bi(0);
714
715 let clipped = vp.clip_to(&old_max_timestamp, &new_max_timestamp, &time_offset);
716
717 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 let old_width = 0.1; let expected_relative_width = old_width * 1000.0 / 2000.0; 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 let mut vp = Viewport::default();
742 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); let time_offset = bi(0);
751
752 let clipped = vp.clip_to(&old_max_timestamp, &new_max_timestamp, &time_offset);
753
754 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 assert!(
764 clipped.curr_left.0 >= -vp.edge_space,
765 "Left edge out of bounds: {}",
766 clipped.curr_left.0
767 );
768
769 let expected_relative_width = 50.0 / 500.0; 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}