1use std::fmt;
25
26const SUBPX_SCALE: u32 = 256;
35
36fn px_to_subpx(px: f64) -> Option<u32> {
40 if !px.is_finite() || px < 0.0 {
41 return None;
42 }
43 let val = (px * SUBPX_SCALE as f64).round();
44 if val > u32::MAX as f64 {
45 return None;
46 }
47 Some(val as u32)
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub struct CellMetrics {
60 pub width_subpx: u32,
62 pub height_subpx: u32,
64}
65
66impl CellMetrics {
67 #[must_use]
71 pub fn new(width_subpx: u32, height_subpx: u32) -> Option<Self> {
72 if width_subpx == 0 || height_subpx == 0 {
73 return None;
74 }
75 Some(Self {
76 width_subpx,
77 height_subpx,
78 })
79 }
80
81 #[must_use]
85 pub fn from_px(width_px: f64, height_px: f64) -> Option<Self> {
86 let w = px_to_subpx(width_px)?;
87 let h = px_to_subpx(height_px)?;
88 Self::new(w, h)
89 }
90
91 #[must_use]
93 pub const fn width_px(&self) -> u32 {
94 self.width_subpx / SUBPX_SCALE
95 }
96
97 #[must_use]
99 pub const fn height_px(&self) -> u32 {
100 self.height_subpx / SUBPX_SCALE
101 }
102
103 pub const MONOSPACE_DEFAULT: Self = Self {
105 width_subpx: 8 * SUBPX_SCALE,
106 height_subpx: 16 * SUBPX_SCALE,
107 };
108
109 pub const LARGE: Self = Self {
111 width_subpx: 10 * SUBPX_SCALE,
112 height_subpx: 20 * SUBPX_SCALE,
113 };
114}
115
116impl Default for CellMetrics {
117 fn default() -> Self {
118 Self::MONOSPACE_DEFAULT
119 }
120}
121
122impl fmt::Display for CellMetrics {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 write!(
125 f,
126 "{}x{}px ({:.2}x{:.2} sub-px)",
127 self.width_px(),
128 self.height_px(),
129 self.width_subpx as f64 / SUBPX_SCALE as f64,
130 self.height_subpx as f64 / SUBPX_SCALE as f64,
131 )
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
144pub struct ContainerViewport {
145 pub width_px: u32,
147 pub height_px: u32,
149 pub dpr_subpx: u32,
156 pub zoom_subpx: u32,
164}
165
166impl ContainerViewport {
167 #[must_use]
171 pub fn new(width_px: u32, height_px: u32, dpr: f64, zoom: f64) -> Option<Self> {
172 let dpr_subpx = px_to_subpx(dpr)?;
173 let zoom_subpx = px_to_subpx(zoom)?;
174 if width_px == 0 || height_px == 0 || dpr_subpx == 0 || zoom_subpx == 0 {
175 return None;
176 }
177 Some(Self {
178 width_px,
179 height_px,
180 dpr_subpx,
181 zoom_subpx,
182 })
183 }
184
185 #[must_use]
187 pub fn simple(width_px: u32, height_px: u32) -> Option<Self> {
188 Self::new(width_px, height_px, 1.0, 1.0)
189 }
190
191 #[must_use]
197 pub fn effective_width_subpx(&self) -> u32 {
198 let scale3 = (SUBPX_SCALE as u64) * (SUBPX_SCALE as u64) * (SUBPX_SCALE as u64);
200 let numer = (self.width_px as u64) * scale3;
201 let denom = (self.dpr_subpx as u64) * (self.zoom_subpx as u64);
202 if denom == 0 {
203 return 0;
204 }
205 (numer / denom) as u32
206 }
207
208 #[must_use]
210 pub fn effective_height_subpx(&self) -> u32 {
211 let scale3 = (SUBPX_SCALE as u64) * (SUBPX_SCALE as u64) * (SUBPX_SCALE as u64);
212 let numer = (self.height_px as u64) * scale3;
213 let denom = (self.dpr_subpx as u64) * (self.zoom_subpx as u64);
214 if denom == 0 {
215 return 0;
216 }
217 (numer / denom) as u32
218 }
219}
220
221impl fmt::Display for ContainerViewport {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 write!(
224 f,
225 "{}x{}px @{:.2}x DPR, {:.0}% zoom",
226 self.width_px,
227 self.height_px,
228 self.dpr_subpx as f64 / SUBPX_SCALE as f64,
229 self.zoom_subpx as f64 / SUBPX_SCALE as f64 * 100.0,
230 )
231 }
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
240pub enum FitPolicy {
241 #[default]
245 FitToContainer,
246 Fixed {
250 cols: u16,
252 rows: u16,
254 },
255 FitWithMinimum {
259 min_cols: u16,
261 min_rows: u16,
263 },
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
272pub struct FitResult {
273 pub cols: u16,
275 pub rows: u16,
277 pub padding_right_subpx: u32,
282 pub padding_bottom_subpx: u32,
284}
285
286impl FitResult {
287 #[must_use]
289 pub fn is_valid(&self) -> bool {
290 self.cols > 0 && self.rows > 0
291 }
292}
293
294impl fmt::Display for FitResult {
295 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296 write!(f, "{}x{} cells", self.cols, self.rows)
297 }
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum FitError {
307 ContainerTooSmall,
309 DimensionOverflow,
311}
312
313impl fmt::Display for FitError {
314 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315 match self {
316 Self::ContainerTooSmall => write!(f, "container too small to fit any cells"),
317 Self::DimensionOverflow => write!(f, "computed grid dimensions overflow u16"),
318 }
319 }
320}
321
322pub fn fit_to_container(
332 viewport: &ContainerViewport,
333 cell: &CellMetrics,
334 policy: FitPolicy,
335) -> Result<FitResult, FitError> {
336 match policy {
337 FitPolicy::Fixed { cols, rows } => Ok(FitResult {
338 cols,
339 rows,
340 padding_right_subpx: 0,
341 padding_bottom_subpx: 0,
342 }),
343 FitPolicy::FitToContainer => fit_internal(viewport, cell, 1, 1),
344 FitPolicy::FitWithMinimum { min_cols, min_rows } => {
345 fit_internal(viewport, cell, min_cols.max(1), min_rows.max(1))
346 }
347 }
348}
349
350fn fit_internal(
351 viewport: &ContainerViewport,
352 cell: &CellMetrics,
353 min_cols: u16,
354 min_rows: u16,
355) -> Result<FitResult, FitError> {
356 let eff_w = viewport.effective_width_subpx();
357 let eff_h = viewport.effective_height_subpx();
358
359 let raw_cols = eff_w / cell.width_subpx;
361 let raw_rows = eff_h / cell.height_subpx;
362
363 let cols = raw_cols.max(min_cols as u32);
364 let rows = raw_rows.max(min_rows as u32);
365
366 if cols == 0 || rows == 0 {
367 return Err(FitError::ContainerTooSmall);
368 }
369 if cols > u16::MAX as u32 || rows > u16::MAX as u32 {
370 return Err(FitError::DimensionOverflow);
371 }
372
373 let cols = cols as u16;
374 let rows = rows as u16;
375
376 let used_w = cols as u32 * cell.width_subpx;
377 let used_h = rows as u32 * cell.height_subpx;
378 let pad_r = eff_w.saturating_sub(used_w);
379 let pad_b = eff_h.saturating_sub(used_h);
380
381 Ok(FitResult {
382 cols,
383 rows,
384 padding_right_subpx: pad_r,
385 padding_bottom_subpx: pad_b,
386 })
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
398pub struct MetricGeneration(u64);
399
400impl MetricGeneration {
401 pub const ZERO: Self = Self(0);
403
404 #[must_use]
406 pub fn next(self) -> Self {
407 Self(self.0.saturating_add(1))
408 }
409
410 #[must_use]
412 pub const fn get(self) -> u64 {
413 self.0
414 }
415}
416
417impl fmt::Display for MetricGeneration {
418 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
419 write!(f, "gen:{}", self.0)
420 }
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
431pub enum MetricInvalidation {
432 FontLoaded,
434 DprChanged,
436 ZoomChanged,
438 ContainerResized,
440 FontSizeChanged,
442 FullReset,
444}
445
446impl MetricInvalidation {
447 #[must_use]
451 pub fn requires_rasterization(&self) -> bool {
452 matches!(
453 self,
454 Self::FontLoaded | Self::DprChanged | Self::FontSizeChanged | Self::FullReset
455 )
456 }
457
458 #[must_use]
460 pub fn requires_refit(&self) -> bool {
461 true
464 }
465}
466
467impl fmt::Display for MetricInvalidation {
468 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469 match self {
470 Self::FontLoaded => write!(f, "font_loaded"),
471 Self::DprChanged => write!(f, "dpr_changed"),
472 Self::ZoomChanged => write!(f, "zoom_changed"),
473 Self::ContainerResized => write!(f, "container_resized"),
474 Self::FontSizeChanged => write!(f, "font_size_changed"),
475 Self::FullReset => write!(f, "full_reset"),
476 }
477 }
478}
479
480#[derive(Debug, Clone)]
497pub struct MetricLifecycle {
498 cell_metrics: CellMetrics,
500 viewport: Option<ContainerViewport>,
502 policy: FitPolicy,
504 generation: MetricGeneration,
506 pending_refit: bool,
508 last_invalidation: Option<MetricInvalidation>,
510 last_fit: Option<FitResult>,
512 total_invalidations: u64,
514 total_refits: u64,
516}
517
518impl MetricLifecycle {
519 #[must_use]
521 pub fn new(cell_metrics: CellMetrics, policy: FitPolicy) -> Self {
522 Self {
523 cell_metrics,
524 viewport: None,
525 policy,
526 generation: MetricGeneration::ZERO,
527 pending_refit: false,
528 last_invalidation: None,
529 last_fit: None,
530 total_invalidations: 0,
531 total_refits: 0,
532 }
533 }
534
535 #[must_use]
537 pub fn cell_metrics(&self) -> &CellMetrics {
538 &self.cell_metrics
539 }
540
541 #[must_use]
543 pub fn generation(&self) -> MetricGeneration {
544 self.generation
545 }
546
547 #[must_use]
549 pub fn is_pending(&self) -> bool {
550 self.pending_refit
551 }
552
553 #[must_use]
555 pub fn last_fit(&self) -> Option<&FitResult> {
556 self.last_fit.as_ref()
557 }
558
559 #[must_use]
561 pub fn total_invalidations(&self) -> u64 {
562 self.total_invalidations
563 }
564
565 #[must_use]
567 pub fn total_refits(&self) -> u64 {
568 self.total_refits
569 }
570
571 pub fn invalidate(&mut self, reason: MetricInvalidation, new_metrics: Option<CellMetrics>) {
576 self.generation = self.generation.next();
577 self.pending_refit = true;
578 self.last_invalidation = Some(reason);
579 self.total_invalidations += 1;
580
581 if let Some(metrics) = new_metrics {
582 self.cell_metrics = metrics;
583 }
584 }
585
586 pub fn set_viewport(&mut self, viewport: ContainerViewport) {
590 let changed = self.viewport.is_none_or(|v| v != viewport);
591 self.viewport = Some(viewport);
592 if changed {
593 self.generation = self.generation.next();
594 self.pending_refit = true;
595 self.last_invalidation = Some(MetricInvalidation::ContainerResized);
596 self.total_invalidations += 1;
597 }
598 }
599
600 pub fn set_policy(&mut self, policy: FitPolicy) {
602 if self.policy != policy {
603 self.policy = policy;
604 self.pending_refit = true;
605 }
606 }
607
608 pub fn refit(&mut self) -> Option<FitResult> {
615 if !self.pending_refit {
616 return None;
617 }
618 self.pending_refit = false;
619 self.total_refits += 1;
620
621 let viewport = self.viewport?;
622 let result = fit_to_container(&viewport, &self.cell_metrics, self.policy).ok()?;
623
624 let changed = self
625 .last_fit
626 .is_none_or(|prev| prev.cols != result.cols || prev.rows != result.rows);
627
628 self.last_fit = Some(result);
629
630 if changed { Some(result) } else { None }
631 }
632
633 #[must_use]
635 pub fn snapshot(&self) -> MetricSnapshot {
636 MetricSnapshot {
637 generation: self.generation.get(),
638 pending_refit: self.pending_refit,
639 cell_width_subpx: self.cell_metrics.width_subpx,
640 cell_height_subpx: self.cell_metrics.height_subpx,
641 viewport_width_px: self.viewport.map(|v| v.width_px).unwrap_or(0),
642 viewport_height_px: self.viewport.map(|v| v.height_px).unwrap_or(0),
643 dpr_subpx: self.viewport.map(|v| v.dpr_subpx).unwrap_or(0),
644 zoom_subpx: self.viewport.map(|v| v.zoom_subpx).unwrap_or(0),
645 fit_cols: self.last_fit.map(|f| f.cols).unwrap_or(0),
646 fit_rows: self.last_fit.map(|f| f.rows).unwrap_or(0),
647 total_invalidations: self.total_invalidations,
648 total_refits: self.total_refits,
649 }
650 }
651}
652
653#[derive(Debug, Clone, Copy, PartialEq, Eq)]
657pub struct MetricSnapshot {
658 pub generation: u64,
660 pub pending_refit: bool,
662 pub cell_width_subpx: u32,
664 pub cell_height_subpx: u32,
666 pub viewport_width_px: u32,
668 pub viewport_height_px: u32,
670 pub dpr_subpx: u32,
672 pub zoom_subpx: u32,
674 pub fit_cols: u16,
676 pub fit_rows: u16,
678 pub total_invalidations: u64,
680 pub total_refits: u64,
682}
683
684#[cfg(test)]
689mod tests {
690 use super::*;
691
692 #[test]
695 fn cell_metrics_default_is_monospace() {
696 let m = CellMetrics::default();
697 assert_eq!(m.width_px(), 8);
698 assert_eq!(m.height_px(), 16);
699 }
700
701 #[test]
702 fn cell_metrics_from_px() {
703 let m = CellMetrics::from_px(9.0, 18.0).unwrap();
704 assert_eq!(m.width_px(), 9);
705 assert_eq!(m.height_px(), 18);
706 }
707
708 #[test]
709 fn cell_metrics_from_px_fractional() {
710 let m = CellMetrics::from_px(8.5, 16.75).unwrap();
711 assert_eq!(m.width_subpx, 2176); assert_eq!(m.height_subpx, 4288); assert_eq!(m.width_px(), 8); assert_eq!(m.height_px(), 16);
715 }
716
717 #[test]
718 fn cell_metrics_rejects_zero() {
719 assert!(CellMetrics::new(0, 256).is_none());
720 assert!(CellMetrics::new(256, 0).is_none());
721 assert!(CellMetrics::new(0, 0).is_none());
722 }
723
724 #[test]
725 fn cell_metrics_rejects_negative_px() {
726 assert!(CellMetrics::from_px(-1.0, 16.0).is_none());
727 assert!(CellMetrics::from_px(8.0, -1.0).is_none());
728 }
729
730 #[test]
731 fn cell_metrics_rejects_nan() {
732 assert!(CellMetrics::from_px(f64::NAN, 16.0).is_none());
733 assert!(CellMetrics::from_px(8.0, f64::INFINITY).is_none());
734 }
735
736 #[test]
737 fn cell_metrics_display() {
738 let m = CellMetrics::MONOSPACE_DEFAULT;
739 let s = format!("{m}");
740 assert!(s.contains("8x16px"));
741 }
742
743 #[test]
744 fn cell_metrics_large_preset() {
745 assert_eq!(CellMetrics::LARGE.width_px(), 10);
746 assert_eq!(CellMetrics::LARGE.height_px(), 20);
747 }
748
749 #[test]
752 fn viewport_simple() {
753 let v = ContainerViewport::simple(800, 600).unwrap();
754 assert_eq!(v.width_px, 800);
755 assert_eq!(v.height_px, 600);
756 assert_eq!(v.dpr_subpx, 256); assert_eq!(v.zoom_subpx, 256); }
759
760 #[test]
761 fn viewport_effective_1x_dpr() {
762 let v = ContainerViewport::simple(800, 600).unwrap();
763 assert_eq!(v.effective_width_subpx(), 800 * SUBPX_SCALE);
765 assert_eq!(v.effective_height_subpx(), 600 * SUBPX_SCALE);
766 }
767
768 #[test]
769 fn viewport_effective_2x_dpr() {
770 let v = ContainerViewport::new(1600, 1200, 2.0, 1.0).unwrap();
771 assert_eq!(v.effective_width_subpx(), 800 * SUBPX_SCALE);
773 assert_eq!(v.effective_height_subpx(), 600 * SUBPX_SCALE);
774 }
775
776 #[test]
777 fn viewport_effective_zoom_150() {
778 let v = ContainerViewport::new(800, 600, 1.0, 1.5).unwrap();
779 let eff = v.effective_width_subpx();
782 assert_eq!(eff, 136533);
783 }
784
785 #[test]
786 fn viewport_rejects_zero_dims() {
787 assert!(ContainerViewport::simple(0, 600).is_none());
788 assert!(ContainerViewport::simple(800, 0).is_none());
789 }
790
791 #[test]
792 fn viewport_rejects_zero_dpr() {
793 assert!(ContainerViewport::new(800, 600, 0.0, 1.0).is_none());
794 }
795
796 #[test]
797 fn viewport_display() {
798 let v = ContainerViewport::simple(800, 600).unwrap();
799 let s = format!("{v}");
800 assert!(s.contains("800x600px"));
801 assert!(s.contains("1.00x DPR"));
802 }
803
804 #[test]
807 fn fit_default_80x24_terminal() {
808 let v = ContainerViewport::simple(640, 384).unwrap();
810 let r =
811 fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
812 assert_eq!(r.cols, 80);
813 assert_eq!(r.rows, 24);
814 assert_eq!(r.padding_right_subpx, 0);
815 assert_eq!(r.padding_bottom_subpx, 0);
816 }
817
818 #[test]
819 fn fit_with_remainder() {
820 let v = ContainerViewport::simple(645, 390).unwrap();
822 let r =
823 fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
824 assert_eq!(r.cols, 80);
825 assert_eq!(r.rows, 24);
826 assert_eq!(r.padding_right_subpx, 5 * 256);
827 assert_eq!(r.padding_bottom_subpx, 6 * 256);
828 }
829
830 #[test]
831 fn fit_small_container_clamps_to_1x1() {
832 let v = ContainerViewport::simple(4, 8).unwrap();
834 let r =
835 fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
836 assert_eq!(r.cols, 1);
837 assert_eq!(r.rows, 1);
838 }
839
840 #[test]
841 fn fit_fixed_ignores_container() {
842 let v = ContainerViewport::simple(100, 100).unwrap();
843 let r = fit_to_container(
844 &v,
845 &CellMetrics::MONOSPACE_DEFAULT,
846 FitPolicy::Fixed { cols: 80, rows: 24 },
847 )
848 .unwrap();
849 assert_eq!(r.cols, 80);
850 assert_eq!(r.rows, 24);
851 }
852
853 #[test]
854 fn fit_with_minimum_guarantees_min_size() {
855 let v = ContainerViewport::simple(40, 48).unwrap();
857 let r = fit_to_container(
858 &v,
859 &CellMetrics::MONOSPACE_DEFAULT,
860 FitPolicy::FitWithMinimum {
861 min_cols: 10,
862 min_rows: 5,
863 },
864 )
865 .unwrap();
866 assert_eq!(r.cols, 10);
867 assert_eq!(r.rows, 5);
868 }
869
870 #[test]
871 fn fit_with_minimum_uses_actual_when_larger() {
872 let v = ContainerViewport::simple(800, 600).unwrap();
873 let r = fit_to_container(
874 &v,
875 &CellMetrics::MONOSPACE_DEFAULT,
876 FitPolicy::FitWithMinimum {
877 min_cols: 10,
878 min_rows: 5,
879 },
880 )
881 .unwrap();
882 assert_eq!(r.cols, 100); assert_eq!(r.rows, 37); }
885
886 #[test]
887 fn fit_result_is_valid() {
888 let r = FitResult {
889 cols: 80,
890 rows: 24,
891 padding_right_subpx: 0,
892 padding_bottom_subpx: 0,
893 };
894 assert!(r.is_valid());
895 }
896
897 #[test]
898 fn fit_result_display() {
899 let r = FitResult {
900 cols: 120,
901 rows: 40,
902 padding_right_subpx: 0,
903 padding_bottom_subpx: 0,
904 };
905 assert_eq!(format!("{r}"), "120x40 cells");
906 }
907
908 #[test]
909 fn fit_at_2x_dpr() {
910 let v = ContainerViewport::new(1600, 768, 2.0, 1.0).unwrap();
912 let r =
913 fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
914 assert_eq!(r.cols, 100);
915 assert_eq!(r.rows, 24); }
917
918 #[test]
919 fn fit_at_3x_dpr() {
920 let v = ContainerViewport::new(2400, 1152, 3.0, 1.0).unwrap();
921 let r =
922 fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
923 assert_eq!(r.cols, 100); assert_eq!(r.rows, 24); }
926
927 #[test]
928 fn fit_deterministic_across_calls() {
929 let v = ContainerViewport::simple(800, 600).unwrap();
930 let m = CellMetrics::MONOSPACE_DEFAULT;
931 let r1 = fit_to_container(&v, &m, FitPolicy::default()).unwrap();
932 let r2 = fit_to_container(&v, &m, FitPolicy::default()).unwrap();
933 assert_eq!(r1, r2);
934 }
935
936 #[test]
937 fn fit_error_display() {
938 assert!(!format!("{}", FitError::ContainerTooSmall).is_empty());
939 assert!(!format!("{}", FitError::DimensionOverflow).is_empty());
940 }
941
942 #[test]
945 fn generation_starts_at_zero() {
946 assert_eq!(MetricGeneration::ZERO.get(), 0);
947 }
948
949 #[test]
950 fn generation_increments() {
951 let g = MetricGeneration::ZERO.next().next();
952 assert_eq!(g.get(), 2);
953 }
954
955 #[test]
956 fn generation_display() {
957 let s = format!("{}", MetricGeneration::ZERO.next());
958 assert_eq!(s, "gen:1");
959 }
960
961 #[test]
962 fn generation_ordering() {
963 let g0 = MetricGeneration::ZERO;
964 let g1 = g0.next();
965 assert!(g1 > g0);
966 }
967
968 #[test]
971 fn invalidation_requires_rasterization() {
972 assert!(MetricInvalidation::FontLoaded.requires_rasterization());
973 assert!(MetricInvalidation::DprChanged.requires_rasterization());
974 assert!(MetricInvalidation::FontSizeChanged.requires_rasterization());
975 assert!(MetricInvalidation::FullReset.requires_rasterization());
976 assert!(!MetricInvalidation::ZoomChanged.requires_rasterization());
977 assert!(!MetricInvalidation::ContainerResized.requires_rasterization());
978 }
979
980 #[test]
981 fn invalidation_display() {
982 assert_eq!(format!("{}", MetricInvalidation::FontLoaded), "font_loaded");
983 assert_eq!(format!("{}", MetricInvalidation::DprChanged), "dpr_changed");
984 }
985
986 #[test]
989 fn lifecycle_initial_state() {
990 let lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
991 assert_eq!(lc.generation(), MetricGeneration::ZERO);
992 assert!(!lc.is_pending());
993 assert!(lc.last_fit().is_none());
994 assert_eq!(lc.total_invalidations(), 0);
995 assert_eq!(lc.total_refits(), 0);
996 }
997
998 #[test]
999 fn lifecycle_invalidate_bumps_generation() {
1000 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1001 lc.invalidate(MetricInvalidation::FontLoaded, None);
1002 assert_eq!(lc.generation().get(), 1);
1003 assert!(lc.is_pending());
1004 assert_eq!(lc.total_invalidations(), 1);
1005 }
1006
1007 #[test]
1008 fn lifecycle_invalidate_with_new_metrics() {
1009 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1010 let new = CellMetrics::LARGE;
1011 lc.invalidate(MetricInvalidation::FontSizeChanged, Some(new));
1012 assert_eq!(*lc.cell_metrics(), new);
1013 }
1014
1015 #[test]
1016 fn lifecycle_set_viewport_marks_pending() {
1017 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1018 let vp = ContainerViewport::simple(800, 600).unwrap();
1019 lc.set_viewport(vp);
1020 assert!(lc.is_pending());
1021 assert_eq!(lc.generation().get(), 1);
1022 }
1023
1024 #[test]
1025 fn lifecycle_set_viewport_same_no_change() {
1026 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1027 let vp = ContainerViewport::simple(800, 600).unwrap();
1028 lc.set_viewport(vp);
1029 let prev_gen = lc.generation();
1030 lc.set_viewport(vp); assert_eq!(lc.generation(), prev_gen); }
1033
1034 #[test]
1035 fn lifecycle_refit_without_viewport_returns_none() {
1036 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1037 lc.invalidate(MetricInvalidation::FontLoaded, None);
1038 assert!(lc.refit().is_none());
1039 assert!(!lc.is_pending()); }
1041
1042 #[test]
1043 fn lifecycle_refit_computes_grid() {
1044 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1045 lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1046 let result = lc.refit().unwrap();
1047 assert_eq!(result.cols, 80);
1048 assert_eq!(result.rows, 24);
1049 assert_eq!(lc.total_refits(), 1);
1050 }
1051
1052 #[test]
1053 fn lifecycle_refit_no_change_returns_none() {
1054 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1055 lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1056 let _ = lc.refit(); lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1059 }
1061
1062 #[test]
1063 fn lifecycle_refit_detects_dimension_change() {
1064 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1065 lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1066 let _ = lc.refit();
1067 lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
1069 let result = lc.refit().unwrap();
1070 assert_eq!(result.cols, 100);
1071 assert_eq!(result.rows, 37);
1072 }
1073
1074 #[test]
1075 fn lifecycle_set_policy_marks_pending() {
1076 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1077 lc.set_policy(FitPolicy::Fixed { cols: 80, rows: 24 });
1078 assert!(lc.is_pending());
1079 }
1080
1081 #[test]
1082 fn lifecycle_snapshot() {
1083 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1084 lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1085 let _ = lc.refit();
1086 let snap = lc.snapshot();
1087 assert_eq!(snap.fit_cols, 80);
1088 assert_eq!(snap.fit_rows, 24);
1089 assert_eq!(snap.viewport_width_px, 640);
1090 assert_eq!(snap.viewport_height_px, 384);
1091 assert_eq!(snap.dpr_subpx, 256);
1092 assert_eq!(snap.zoom_subpx, 256);
1093 assert!(!snap.pending_refit);
1094 }
1095
1096 #[test]
1097 fn lifecycle_multiple_invalidations() {
1098 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1099 lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1100 lc.invalidate(MetricInvalidation::FontLoaded, None);
1101 lc.invalidate(MetricInvalidation::DprChanged, None);
1102 lc.invalidate(MetricInvalidation::ZoomChanged, None);
1103 assert!(lc.is_pending());
1105 assert_eq!(lc.total_invalidations(), 4); }
1107
1108 #[test]
1109 fn lifecycle_font_size_change_affects_fit() {
1110 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1111 lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
1112 let first = lc.refit().unwrap();
1113 assert_eq!(first.cols, 100); assert_eq!(first.rows, 37); let big = CellMetrics::new(16 * 256, 32 * 256).unwrap();
1118 lc.invalidate(MetricInvalidation::FontSizeChanged, Some(big));
1119 let second = lc.refit().unwrap();
1120 assert_eq!(second.cols, 50); assert_eq!(second.rows, 18); }
1123
1124 #[test]
1125 fn lifecycle_dpr_change_affects_fit() {
1126 let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1127 lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
1128 let first = lc.refit().unwrap();
1129 assert_eq!(first.cols, 100);
1130
1131 let vp2 = ContainerViewport::new(800, 600, 2.0, 1.0).unwrap();
1133 lc.set_viewport(vp2);
1134 let second = lc.refit().unwrap();
1135 assert_eq!(second.cols, 50); }
1137
1138 #[test]
1141 fn subpx_conversion_zero() {
1142 assert_eq!(px_to_subpx(0.0), Some(0));
1143 }
1144
1145 #[test]
1146 fn subpx_conversion_negative() {
1147 assert_eq!(px_to_subpx(-1.0), None);
1148 }
1149
1150 #[test]
1151 fn subpx_conversion_nan() {
1152 assert_eq!(px_to_subpx(f64::NAN), None);
1153 }
1154
1155 #[test]
1156 fn subpx_conversion_infinity() {
1157 assert_eq!(px_to_subpx(f64::INFINITY), None);
1158 }
1159
1160 #[test]
1161 fn subpx_conversion_precise() {
1162 assert_eq!(px_to_subpx(1.0), Some(256));
1163 assert_eq!(px_to_subpx(0.5), Some(128));
1164 assert_eq!(px_to_subpx(2.0), Some(512));
1165 }
1166}