Skip to main content

ftui_render/
fit_metrics.rs

1//! Deterministic fit-to-container and font metric lifecycle.
2//!
3//! This module provides the infrastructure for mapping pixel-space container
4//! dimensions to cell-grid dimensions, accounting for DPR, zoom, and font
5//! metrics. It ensures that equivalent resize/font-load event streams yield
6//! identical viewport and cursor geometry outcomes.
7//!
8//! # Key types
9//!
10//! - [`CellMetrics`]: cell size in sub-pixel units (1/256 px), deterministic.
11//! - [`ContainerViewport`]: container dimensions with DPR and zoom tracking.
12//! - [`FitPolicy`]: strategy for computing grid dimensions from container.
13//! - [`FitResult`]: computed grid dimensions from a fit operation.
14//! - [`MetricGeneration`]: monotonic counter for cache invalidation.
15//! - [`MetricInvalidation`]: reason for metric recomputation.
16//! - [`MetricLifecycle`]: stateful tracker for font metric changes.
17//!
18//! # Determinism
19//!
20//! All pixel-to-cell conversions use fixed-point arithmetic (256 sub-pixel
21//! units per pixel) to avoid floating-point rounding ambiguity across
22//! platforms. The same inputs always produce the same grid dimensions.
23
24use std::fmt;
25
26// =========================================================================
27// Fixed-point helpers
28// =========================================================================
29
30/// Sub-pixel units per pixel (fixed-point denominator).
31///
32/// All metric calculations use this scale factor to avoid floating-point
33/// rounding ambiguity. 256 gives 8 fractional bits of sub-pixel precision.
34const SUBPX_SCALE: u32 = 256;
35
36/// Convert a floating-point pixel value to sub-pixel units.
37///
38/// Rounds to nearest sub-pixel unit. Returns `None` on overflow.
39fn 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// =========================================================================
51// CellMetrics
52// =========================================================================
53
54/// Cell dimensions in sub-pixel units (1/256 px) for deterministic layout.
55///
56/// Both `width_subpx` and `height_subpx` must be > 0. Use [`CellMetrics::new`]
57/// to validate.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub struct CellMetrics {
60    /// Cell width in sub-pixel units (1/256 px).
61    pub width_subpx: u32,
62    /// Cell height in sub-pixel units (1/256 px).
63    pub height_subpx: u32,
64}
65
66impl CellMetrics {
67    /// Create cell metrics from sub-pixel values.
68    ///
69    /// Returns `None` if either dimension is zero.
70    #[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    /// Create cell metrics from floating-point pixel values.
82    ///
83    /// Converts to sub-pixel units internally. Returns `None` on invalid input.
84    #[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    /// Cell width in whole pixels (truncated).
92    #[must_use]
93    pub const fn width_px(&self) -> u32 {
94        self.width_subpx / SUBPX_SCALE
95    }
96
97    /// Cell height in whole pixels (truncated).
98    #[must_use]
99    pub const fn height_px(&self) -> u32 {
100        self.height_subpx / SUBPX_SCALE
101    }
102
103    /// Monospace terminal default: 8x16 px.
104    pub const MONOSPACE_DEFAULT: Self = Self {
105        width_subpx: 8 * SUBPX_SCALE,
106        height_subpx: 16 * SUBPX_SCALE,
107    };
108
109    /// Common 10x20 px cell size.
110    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// =========================================================================
136// ContainerViewport
137// =========================================================================
138
139/// Container dimensions and display parameters for fit computation.
140///
141/// Represents the available rendering area in physical pixels, plus the
142/// DPR and zoom factor needed for correct pixel-to-cell mapping.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
144pub struct ContainerViewport {
145    /// Available width in physical pixels.
146    pub width_px: u32,
147    /// Available height in physical pixels.
148    pub height_px: u32,
149    /// Device pixel ratio in sub-pixel units (256 = 1.0x DPR).
150    ///
151    /// Must be > 0. Common values:
152    /// - 256 = 1.0x (standard density)
153    /// - 512 = 2.0x (Retina)
154    /// - 768 = 3.0x (high-DPI mobile)
155    pub dpr_subpx: u32,
156    /// Zoom factor in sub-pixel units (256 = 100% zoom).
157    ///
158    /// Must be > 0. Common values:
159    /// - 256 = 100%
160    /// - 320 = 125%
161    /// - 384 = 150%
162    /// - 512 = 200%
163    pub zoom_subpx: u32,
164}
165
166impl ContainerViewport {
167    /// Create a viewport with explicit parameters.
168    ///
169    /// Returns `None` if dimensions are zero or DPR/zoom are zero.
170    #[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    /// Create a simple viewport at 1x DPR, 100% zoom.
186    #[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    /// Effective pixel width adjusted for DPR and zoom, in sub-pixel units.
192    ///
193    /// Computes `physical_px / (dpr * zoom)` expressed in the same sub-pixel
194    /// units as [`CellMetrics`] (1/256 px), so the caller can divide by
195    /// `cell.width_subpx` to get column count.
196    #[must_use]
197    pub fn effective_width_subpx(&self) -> u32 {
198        // effective_subpx = physical_px * SUBPX^3 / (dpr_subpx * zoom_subpx)
199        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    /// Effective pixel height adjusted for DPR and zoom, in sub-pixel units.
209    #[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// =========================================================================
235// FitPolicy
236// =========================================================================
237
238/// Strategy for computing grid dimensions from container and font metrics.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
240pub enum FitPolicy {
241    /// Automatically fit: fill the container, rounding down to whole cells.
242    ///
243    /// This is the xterm.js `fit` addon behavior: cols = floor(container_width / cell_width).
244    #[default]
245    FitToContainer,
246    /// Fixed grid size, ignoring container dimensions.
247    ///
248    /// Useful for testing or when the host manages sizing.
249    Fixed {
250        /// Fixed column count.
251        cols: u16,
252        /// Fixed row count.
253        rows: u16,
254    },
255    /// Clamp to container but with minimum dimensions.
256    ///
257    /// Like `FitToContainer` but guarantees at least `min_cols` x `min_rows`.
258    FitWithMinimum {
259        /// Minimum column count.
260        min_cols: u16,
261        /// Minimum row count.
262        min_rows: u16,
263    },
264}
265
266// =========================================================================
267// FitResult
268// =========================================================================
269
270/// Computed grid dimensions from a fit operation.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
272pub struct FitResult {
273    /// Grid columns.
274    pub cols: u16,
275    /// Grid rows.
276    pub rows: u16,
277    /// Horizontal padding remainder in sub-pixel units.
278    ///
279    /// The leftover space after fitting whole columns:
280    /// `container_width - (cols * cell_width)`.
281    pub padding_right_subpx: u32,
282    /// Vertical padding remainder in sub-pixel units.
283    pub padding_bottom_subpx: u32,
284}
285
286impl FitResult {
287    /// Whether the fit result represents a valid (non-empty) grid.
288    #[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// =========================================================================
301// fit_to_container
302// =========================================================================
303
304/// Errors from fit computation.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum FitError {
307    /// Container is too small to fit even one cell.
308    ContainerTooSmall,
309    /// Grid dimensions would overflow u16.
310    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
322/// Compute grid dimensions by fitting cells into a container viewport.
323///
324/// This is the core deterministic computation. Given a container size (adjusted
325/// for DPR/zoom) and cell metrics, it returns the number of cols/rows that fit.
326///
327/// # Determinism
328///
329/// Uses integer-only arithmetic on sub-pixel units. The same inputs always
330/// produce the same output, regardless of platform or FPU mode.
331pub 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    // Integer division: cols = floor(effective_width / cell_width)
360    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// =========================================================================
390// MetricGeneration
391// =========================================================================
392
393/// Monotonic generation counter for metric cache invalidation.
394///
395/// Each font metric change increments the generation. Caches compare their
396/// stored generation against the current one to detect staleness.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
398pub struct MetricGeneration(u64);
399
400impl MetricGeneration {
401    /// Initial generation.
402    pub const ZERO: Self = Self(0);
403
404    /// Advance to the next generation.
405    #[must_use]
406    pub fn next(self) -> Self {
407        Self(self.0.saturating_add(1))
408    }
409
410    /// Raw generation value.
411    #[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// =========================================================================
424// MetricInvalidation
425// =========================================================================
426
427/// Reason for a font metric recomputation.
428///
429/// Each variant triggers a specific set of cache invalidations.
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
431pub enum MetricInvalidation {
432    /// Web font finished loading, metrics may have changed.
433    FontLoaded,
434    /// Device pixel ratio changed (e.g., window moved between monitors).
435    DprChanged,
436    /// User zoom level changed.
437    ZoomChanged,
438    /// Container was resized (may affect fit, not metrics themselves).
439    ContainerResized,
440    /// Font size explicitly changed by user or configuration.
441    FontSizeChanged,
442    /// Full metric reset requested (e.g., after error recovery).
443    FullReset,
444}
445
446const METRIC_INVALIDATION_CANONICAL_ORDER: [MetricInvalidation; 6] = [
447    MetricInvalidation::FullReset,
448    MetricInvalidation::FontSizeChanged,
449    MetricInvalidation::DprChanged,
450    MetricInvalidation::ZoomChanged,
451    MetricInvalidation::FontLoaded,
452    MetricInvalidation::ContainerResized,
453];
454
455impl MetricInvalidation {
456    /// Bit mask used to track pending invalidation reasons.
457    #[must_use]
458    const fn bit(self) -> u8 {
459        match self {
460            Self::FontLoaded => 1 << 0,
461            Self::DprChanged => 1 << 1,
462            Self::ZoomChanged => 1 << 2,
463            Self::ContainerResized => 1 << 3,
464            Self::FontSizeChanged => 1 << 4,
465            Self::FullReset => 1 << 5,
466        }
467    }
468
469    /// Return pending invalidations in deterministic, canonical order.
470    #[must_use]
471    pub fn ordered_pending_from_mask(mask: u8) -> Vec<Self> {
472        let mut ordered = Vec::with_capacity(METRIC_INVALIDATION_CANONICAL_ORDER.len());
473        for reason in METRIC_INVALIDATION_CANONICAL_ORDER {
474            if mask & reason.bit() != 0 {
475                ordered.push(reason);
476            }
477        }
478        ordered
479    }
480
481    /// Whether this invalidation requires recomputing glyph rasterization.
482    ///
483    /// DPR and font size changes affect pixel output; container resize does not.
484    #[must_use]
485    pub fn requires_rasterization(&self) -> bool {
486        matches!(
487            self,
488            Self::FontLoaded | Self::DprChanged | Self::FontSizeChanged | Self::FullReset
489        )
490    }
491
492    /// Whether this invalidation requires recomputing grid dimensions.
493    #[must_use]
494    pub fn requires_refit(&self) -> bool {
495        // All invalidations may affect the fit except a pure font load
496        // where the cell size doesn't change (handled by caller).
497        true
498    }
499}
500
501impl fmt::Display for MetricInvalidation {
502    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
503        match self {
504            Self::FontLoaded => write!(f, "font_loaded"),
505            Self::DprChanged => write!(f, "dpr_changed"),
506            Self::ZoomChanged => write!(f, "zoom_changed"),
507            Self::ContainerResized => write!(f, "container_resized"),
508            Self::FontSizeChanged => write!(f, "font_size_changed"),
509            Self::FullReset => write!(f, "full_reset"),
510        }
511    }
512}
513
514// =========================================================================
515// MetricLifecycle
516// =========================================================================
517
518/// Stateful tracker for font metric changes and cache invalidation.
519///
520/// Maintains the current cell metrics, generation counter, and pending
521/// invalidations. The lifecycle ensures a deterministic sequence:
522///
523/// 1. Invalidation event arrives (font load, DPR change, etc.)
524/// 2. Generation is bumped, pending flag is set
525/// 3. Caller calls [`MetricLifecycle::refit`] with new metrics
526/// 4. If grid dimensions changed, resize propagates through the pipeline
527///
528/// This prevents stale glyph metrics and geometry jumps by enforcing that
529/// all consumers see consistent metric state.
530#[derive(Debug, Clone)]
531pub struct MetricLifecycle {
532    /// Current cell metrics.
533    cell_metrics: CellMetrics,
534    /// Current viewport (if set).
535    viewport: Option<ContainerViewport>,
536    /// Fit policy.
537    policy: FitPolicy,
538    /// Current metric generation.
539    generation: MetricGeneration,
540    /// Whether a refit is pending.
541    pending_refit: bool,
542    /// Last invalidation reason.
543    last_invalidation: Option<MetricInvalidation>,
544    /// Pending invalidation reasons (bit mask).
545    pending_invalidation_mask: u8,
546    /// Last computed fit result.
547    last_fit: Option<FitResult>,
548    /// Total invalidation count for diagnostics.
549    total_invalidations: u64,
550    /// Total refit count for diagnostics.
551    total_refits: u64,
552}
553
554impl MetricLifecycle {
555    /// Create a new lifecycle with default cell metrics and no viewport.
556    #[must_use]
557    pub fn new(cell_metrics: CellMetrics, policy: FitPolicy) -> Self {
558        Self {
559            cell_metrics,
560            viewport: None,
561            policy,
562            generation: MetricGeneration::ZERO,
563            pending_refit: false,
564            last_invalidation: None,
565            pending_invalidation_mask: 0,
566            last_fit: None,
567            total_invalidations: 0,
568            total_refits: 0,
569        }
570    }
571
572    /// Current cell metrics.
573    #[must_use]
574    pub fn cell_metrics(&self) -> &CellMetrics {
575        &self.cell_metrics
576    }
577
578    /// Current metric generation.
579    #[must_use]
580    pub fn generation(&self) -> MetricGeneration {
581        self.generation
582    }
583
584    /// Whether a refit is pending.
585    #[must_use]
586    pub fn is_pending(&self) -> bool {
587        self.pending_refit
588    }
589
590    /// Last invalidation reason observed by the lifecycle.
591    #[must_use]
592    pub fn last_invalidation(&self) -> Option<MetricInvalidation> {
593        self.last_invalidation
594    }
595
596    /// Pending invalidation reasons in deterministic processing order.
597    #[must_use]
598    pub fn pending_invalidations(&self) -> Vec<MetricInvalidation> {
599        MetricInvalidation::ordered_pending_from_mask(self.pending_invalidation_mask)
600    }
601
602    /// Last computed fit result.
603    #[must_use]
604    pub fn last_fit(&self) -> Option<&FitResult> {
605        self.last_fit.as_ref()
606    }
607
608    /// Total invalidation count.
609    #[must_use]
610    pub fn total_invalidations(&self) -> u64 {
611        self.total_invalidations
612    }
613
614    /// Total refit count.
615    #[must_use]
616    pub fn total_refits(&self) -> u64 {
617        self.total_refits
618    }
619
620    /// Record an invalidation event.
621    ///
622    /// Bumps the generation and marks a refit as pending. If cell metrics
623    /// changed, the new metrics are stored immediately.
624    pub fn invalidate(&mut self, reason: MetricInvalidation, new_metrics: Option<CellMetrics>) {
625        self.generation = self.generation.next();
626        self.pending_refit = true;
627        self.last_invalidation = Some(reason);
628        self.pending_invalidation_mask |= reason.bit();
629        self.total_invalidations += 1;
630
631        if let Some(metrics) = new_metrics {
632            self.cell_metrics = metrics;
633        }
634    }
635
636    /// Update the container viewport.
637    ///
638    /// If the viewport changed, marks a refit as pending.
639    pub fn set_viewport(&mut self, viewport: ContainerViewport) {
640        let changed = self.viewport.is_none_or(|v| v != viewport);
641        if changed {
642            let mut primary_reason = MetricInvalidation::ContainerResized;
643            if let Some(previous) = self.viewport {
644                if previous.dpr_subpx != viewport.dpr_subpx {
645                    self.pending_invalidation_mask |= MetricInvalidation::DprChanged.bit();
646                    primary_reason = MetricInvalidation::DprChanged;
647                }
648                if previous.zoom_subpx != viewport.zoom_subpx {
649                    self.pending_invalidation_mask |= MetricInvalidation::ZoomChanged.bit();
650                    if primary_reason == MetricInvalidation::ContainerResized {
651                        primary_reason = MetricInvalidation::ZoomChanged;
652                    }
653                }
654                if previous.width_px != viewport.width_px
655                    || previous.height_px != viewport.height_px
656                {
657                    self.pending_invalidation_mask |= MetricInvalidation::ContainerResized.bit();
658                }
659            } else {
660                self.pending_invalidation_mask |= MetricInvalidation::ContainerResized.bit();
661            }
662
663            self.generation = self.generation.next();
664            self.pending_refit = true;
665            self.last_invalidation = Some(primary_reason);
666            self.total_invalidations += 1;
667        }
668        self.viewport = Some(viewport);
669    }
670
671    /// Update the fit policy.
672    pub fn set_policy(&mut self, policy: FitPolicy) {
673        if self.policy != policy {
674            self.policy = policy;
675            self.pending_refit = true;
676        }
677    }
678
679    /// Perform the pending refit computation.
680    ///
681    /// Returns `Some(FitResult)` if the grid dimensions changed, `None` if
682    /// no refit was needed or dimensions are unchanged.
683    ///
684    /// Clears the pending flag regardless of outcome.
685    pub fn refit(&mut self) -> Option<FitResult> {
686        if !self.pending_refit {
687            return None;
688        }
689        self.pending_refit = false;
690        self.pending_invalidation_mask = 0;
691        self.total_refits += 1;
692
693        let viewport = self.viewport?;
694        let result = fit_to_container(&viewport, &self.cell_metrics, self.policy).ok()?;
695
696        let changed = self
697            .last_fit
698            .is_none_or(|prev| prev.cols != result.cols || prev.rows != result.rows);
699
700        self.last_fit = Some(result);
701
702        if changed { Some(result) } else { None }
703    }
704
705    /// Diagnostic snapshot for JSONL evidence logging.
706    #[must_use]
707    pub fn snapshot(&self) -> MetricSnapshot {
708        MetricSnapshot {
709            generation: self.generation.get(),
710            pending_refit: self.pending_refit,
711            cell_width_subpx: self.cell_metrics.width_subpx,
712            cell_height_subpx: self.cell_metrics.height_subpx,
713            viewport_width_px: self.viewport.map(|v| v.width_px).unwrap_or(0),
714            viewport_height_px: self.viewport.map(|v| v.height_px).unwrap_or(0),
715            dpr_subpx: self.viewport.map(|v| v.dpr_subpx).unwrap_or(0),
716            zoom_subpx: self.viewport.map(|v| v.zoom_subpx).unwrap_or(0),
717            fit_cols: self.last_fit.map(|f| f.cols).unwrap_or(0),
718            fit_rows: self.last_fit.map(|f| f.rows).unwrap_or(0),
719            pending_invalidation_mask: self.pending_invalidation_mask,
720            pending_invalidation_count: self.pending_invalidation_mask.count_ones() as u8,
721            total_invalidations: self.total_invalidations,
722            total_refits: self.total_refits,
723        }
724    }
725}
726
727/// Diagnostic snapshot of metric lifecycle state.
728///
729/// All fields are `Copy` for cheap JSONL serialization.
730#[derive(Debug, Clone, Copy, PartialEq, Eq)]
731pub struct MetricSnapshot {
732    /// Current generation counter.
733    pub generation: u64,
734    /// Whether a refit is pending.
735    pub pending_refit: bool,
736    /// Cell width in sub-pixel units.
737    pub cell_width_subpx: u32,
738    /// Cell height in sub-pixel units.
739    pub cell_height_subpx: u32,
740    /// Container width in physical pixels.
741    pub viewport_width_px: u32,
742    /// Container height in physical pixels.
743    pub viewport_height_px: u32,
744    /// DPR in sub-pixel units.
745    pub dpr_subpx: u32,
746    /// Zoom in sub-pixel units.
747    pub zoom_subpx: u32,
748    /// Last computed grid columns.
749    pub fit_cols: u16,
750    /// Last computed grid rows.
751    pub fit_rows: u16,
752    /// Pending invalidation reasons (bit mask).
753    pub pending_invalidation_mask: u8,
754    /// Number of pending invalidation reasons.
755    pub pending_invalidation_count: u8,
756    /// Total invalidation count.
757    pub total_invalidations: u64,
758    /// Total refit count.
759    pub total_refits: u64,
760}
761
762// =========================================================================
763// Tests
764// =========================================================================
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769
770    // ── CellMetrics ──────────────────────────────────────────────────
771
772    #[test]
773    fn cell_metrics_default_is_monospace() {
774        let m = CellMetrics::default();
775        assert_eq!(m.width_px(), 8);
776        assert_eq!(m.height_px(), 16);
777    }
778
779    #[test]
780    fn cell_metrics_from_px() {
781        let m = CellMetrics::from_px(9.0, 18.0).unwrap();
782        assert_eq!(m.width_px(), 9);
783        assert_eq!(m.height_px(), 18);
784    }
785
786    #[test]
787    fn cell_metrics_from_px_fractional() {
788        let m = CellMetrics::from_px(8.5, 16.75).unwrap();
789        assert_eq!(m.width_subpx, 2176); // 8.5 * 256
790        assert_eq!(m.height_subpx, 4288); // 16.75 * 256
791        assert_eq!(m.width_px(), 8); // truncated
792        assert_eq!(m.height_px(), 16);
793    }
794
795    #[test]
796    fn cell_metrics_rejects_zero() {
797        assert!(CellMetrics::new(0, 256).is_none());
798        assert!(CellMetrics::new(256, 0).is_none());
799        assert!(CellMetrics::new(0, 0).is_none());
800    }
801
802    #[test]
803    fn cell_metrics_rejects_negative_px() {
804        assert!(CellMetrics::from_px(-1.0, 16.0).is_none());
805        assert!(CellMetrics::from_px(8.0, -1.0).is_none());
806    }
807
808    #[test]
809    fn cell_metrics_rejects_nan() {
810        assert!(CellMetrics::from_px(f64::NAN, 16.0).is_none());
811        assert!(CellMetrics::from_px(8.0, f64::INFINITY).is_none());
812    }
813
814    #[test]
815    fn cell_metrics_display() {
816        let m = CellMetrics::MONOSPACE_DEFAULT;
817        let s = format!("{m}");
818        assert!(s.contains("8x16px"));
819    }
820
821    #[test]
822    fn cell_metrics_large_preset() {
823        assert_eq!(CellMetrics::LARGE.width_px(), 10);
824        assert_eq!(CellMetrics::LARGE.height_px(), 20);
825    }
826
827    // ── ContainerViewport ────────────────────────────────────────────
828
829    #[test]
830    fn viewport_simple() {
831        let v = ContainerViewport::simple(800, 600).unwrap();
832        assert_eq!(v.width_px, 800);
833        assert_eq!(v.height_px, 600);
834        assert_eq!(v.dpr_subpx, 256); // 1.0x
835        assert_eq!(v.zoom_subpx, 256); // 100%
836    }
837
838    #[test]
839    fn viewport_effective_1x_dpr() {
840        let v = ContainerViewport::simple(800, 600).unwrap();
841        // effective = physical * 256^3 / (256 * 256) = physical * 256
842        assert_eq!(v.effective_width_subpx(), 800 * SUBPX_SCALE);
843        assert_eq!(v.effective_height_subpx(), 600 * SUBPX_SCALE);
844    }
845
846    #[test]
847    fn viewport_effective_2x_dpr() {
848        let v = ContainerViewport::new(1600, 1200, 2.0, 1.0).unwrap();
849        // effective = 1600 * 256^3 / (512 * 256) = 1600 * 256 / 2 = 800 * 256
850        assert_eq!(v.effective_width_subpx(), 800 * SUBPX_SCALE);
851        assert_eq!(v.effective_height_subpx(), 600 * SUBPX_SCALE);
852    }
853
854    #[test]
855    fn viewport_effective_zoom_150() {
856        let v = ContainerViewport::new(800, 600, 1.0, 1.5).unwrap();
857        // effective = 800 * 256^3 / (256 * 384) = 800 * 256^2 / 384
858        // = 800 * 65536 / 384 = 136533 (integer division)
859        let eff = v.effective_width_subpx();
860        assert_eq!(eff, 136533);
861    }
862
863    #[test]
864    fn viewport_rejects_zero_dims() {
865        assert!(ContainerViewport::simple(0, 600).is_none());
866        assert!(ContainerViewport::simple(800, 0).is_none());
867    }
868
869    #[test]
870    fn viewport_rejects_zero_dpr() {
871        assert!(ContainerViewport::new(800, 600, 0.0, 1.0).is_none());
872    }
873
874    #[test]
875    fn viewport_display() {
876        let v = ContainerViewport::simple(800, 600).unwrap();
877        let s = format!("{v}");
878        assert!(s.contains("800x600px"));
879        assert!(s.contains("1.00x DPR"));
880    }
881
882    // ── FitPolicy & fit_to_container ──────────────────────────────────
883
884    #[test]
885    fn fit_default_80x24_terminal() {
886        // 80 cols * 8px = 640px wide, 24 rows * 16px = 384px tall
887        let v = ContainerViewport::simple(640, 384).unwrap();
888        let r =
889            fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
890        assert_eq!(r.cols, 80);
891        assert_eq!(r.rows, 24);
892        assert_eq!(r.padding_right_subpx, 0);
893        assert_eq!(r.padding_bottom_subpx, 0);
894    }
895
896    #[test]
897    fn fit_with_remainder() {
898        // 645px / 8px = 80.625 → 80 cols, remainder 5px
899        let v = ContainerViewport::simple(645, 390).unwrap();
900        let r =
901            fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
902        assert_eq!(r.cols, 80);
903        assert_eq!(r.rows, 24);
904        assert_eq!(r.padding_right_subpx, 5 * 256);
905        assert_eq!(r.padding_bottom_subpx, 6 * 256);
906    }
907
908    #[test]
909    fn fit_small_container_clamps_to_1x1() {
910        // Container smaller than one cell: FitToContainer clamps to 1x1.
911        let v = ContainerViewport::simple(4, 8).unwrap();
912        let r =
913            fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
914        assert_eq!(r.cols, 1);
915        assert_eq!(r.rows, 1);
916    }
917
918    #[test]
919    fn fit_fixed_ignores_container() {
920        let v = ContainerViewport::simple(100, 100).unwrap();
921        let r = fit_to_container(
922            &v,
923            &CellMetrics::MONOSPACE_DEFAULT,
924            FitPolicy::Fixed { cols: 80, rows: 24 },
925        )
926        .unwrap();
927        assert_eq!(r.cols, 80);
928        assert_eq!(r.rows, 24);
929    }
930
931    #[test]
932    fn fit_with_minimum_guarantees_min_size() {
933        // Container fits 5x3 at 8x16, but minimum is 10x5
934        let v = ContainerViewport::simple(40, 48).unwrap();
935        let r = fit_to_container(
936            &v,
937            &CellMetrics::MONOSPACE_DEFAULT,
938            FitPolicy::FitWithMinimum {
939                min_cols: 10,
940                min_rows: 5,
941            },
942        )
943        .unwrap();
944        assert_eq!(r.cols, 10);
945        assert_eq!(r.rows, 5);
946    }
947
948    #[test]
949    fn fit_with_minimum_uses_actual_when_larger() {
950        let v = ContainerViewport::simple(800, 600).unwrap();
951        let r = fit_to_container(
952            &v,
953            &CellMetrics::MONOSPACE_DEFAULT,
954            FitPolicy::FitWithMinimum {
955                min_cols: 10,
956                min_rows: 5,
957            },
958        )
959        .unwrap();
960        assert_eq!(r.cols, 100); // 800/8
961        assert_eq!(r.rows, 37); // 600/16 = 37.5 → 37
962    }
963
964    #[test]
965    fn fit_result_is_valid() {
966        let r = FitResult {
967            cols: 80,
968            rows: 24,
969            padding_right_subpx: 0,
970            padding_bottom_subpx: 0,
971        };
972        assert!(r.is_valid());
973    }
974
975    #[test]
976    fn fit_result_display() {
977        let r = FitResult {
978            cols: 120,
979            rows: 40,
980            padding_right_subpx: 0,
981            padding_bottom_subpx: 0,
982        };
983        assert_eq!(format!("{r}"), "120x40 cells");
984    }
985
986    #[test]
987    fn fit_at_2x_dpr() {
988        // 2x DPR: 1600 physical px → 800 CSS px → 100 cols at 8px
989        let v = ContainerViewport::new(1600, 768, 2.0, 1.0).unwrap();
990        let r =
991            fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
992        assert_eq!(r.cols, 100);
993        assert_eq!(r.rows, 24); // 384 CSS px / 16 = 24
994    }
995
996    #[test]
997    fn fit_at_3x_dpr() {
998        let v = ContainerViewport::new(2400, 1152, 3.0, 1.0).unwrap();
999        let r =
1000            fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
1001        assert_eq!(r.cols, 100); // 800/8
1002        assert_eq!(r.rows, 24); // 384/16
1003    }
1004
1005    #[test]
1006    fn fit_deterministic_across_calls() {
1007        let v = ContainerViewport::simple(800, 600).unwrap();
1008        let m = CellMetrics::MONOSPACE_DEFAULT;
1009        let r1 = fit_to_container(&v, &m, FitPolicy::default()).unwrap();
1010        let r2 = fit_to_container(&v, &m, FitPolicy::default()).unwrap();
1011        assert_eq!(r1, r2);
1012    }
1013
1014    #[test]
1015    fn fit_error_display() {
1016        assert!(!format!("{}", FitError::ContainerTooSmall).is_empty());
1017        assert!(!format!("{}", FitError::DimensionOverflow).is_empty());
1018    }
1019
1020    // ── MetricGeneration ──────────────────────────────────────────────
1021
1022    #[test]
1023    fn generation_starts_at_zero() {
1024        assert_eq!(MetricGeneration::ZERO.get(), 0);
1025    }
1026
1027    #[test]
1028    fn generation_increments() {
1029        let g = MetricGeneration::ZERO.next().next();
1030        assert_eq!(g.get(), 2);
1031    }
1032
1033    #[test]
1034    fn generation_display() {
1035        let s = format!("{}", MetricGeneration::ZERO.next());
1036        assert_eq!(s, "gen:1");
1037    }
1038
1039    #[test]
1040    fn generation_ordering() {
1041        let g0 = MetricGeneration::ZERO;
1042        let g1 = g0.next();
1043        assert!(g1 > g0);
1044    }
1045
1046    // ── MetricInvalidation ────────────────────────────────────────────
1047
1048    #[test]
1049    fn invalidation_requires_rasterization() {
1050        assert!(MetricInvalidation::FontLoaded.requires_rasterization());
1051        assert!(MetricInvalidation::DprChanged.requires_rasterization());
1052        assert!(MetricInvalidation::FontSizeChanged.requires_rasterization());
1053        assert!(MetricInvalidation::FullReset.requires_rasterization());
1054        assert!(!MetricInvalidation::ZoomChanged.requires_rasterization());
1055        assert!(!MetricInvalidation::ContainerResized.requires_rasterization());
1056    }
1057
1058    #[test]
1059    fn invalidation_display() {
1060        assert_eq!(format!("{}", MetricInvalidation::FontLoaded), "font_loaded");
1061        assert_eq!(format!("{}", MetricInvalidation::DprChanged), "dpr_changed");
1062    }
1063
1064    // ── MetricLifecycle ───────────────────────────────────────────────
1065
1066    #[test]
1067    fn lifecycle_initial_state() {
1068        let lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1069        assert_eq!(lc.generation(), MetricGeneration::ZERO);
1070        assert!(!lc.is_pending());
1071        assert!(lc.last_fit().is_none());
1072        assert_eq!(lc.total_invalidations(), 0);
1073        assert_eq!(lc.total_refits(), 0);
1074    }
1075
1076    #[test]
1077    fn lifecycle_invalidate_bumps_generation() {
1078        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1079        lc.invalidate(MetricInvalidation::FontLoaded, None);
1080        assert_eq!(lc.generation().get(), 1);
1081        assert!(lc.is_pending());
1082        assert_eq!(lc.total_invalidations(), 1);
1083    }
1084
1085    #[test]
1086    fn lifecycle_invalidate_with_new_metrics() {
1087        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1088        let new = CellMetrics::LARGE;
1089        lc.invalidate(MetricInvalidation::FontSizeChanged, Some(new));
1090        assert_eq!(*lc.cell_metrics(), new);
1091    }
1092
1093    #[test]
1094    fn lifecycle_set_viewport_marks_pending() {
1095        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1096        let vp = ContainerViewport::simple(800, 600).unwrap();
1097        lc.set_viewport(vp);
1098        assert!(lc.is_pending());
1099        assert_eq!(lc.generation().get(), 1);
1100    }
1101
1102    #[test]
1103    fn lifecycle_set_viewport_same_no_change() {
1104        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1105        let vp = ContainerViewport::simple(800, 600).unwrap();
1106        lc.set_viewport(vp);
1107        let prev_gen = lc.generation();
1108        lc.set_viewport(vp); // same viewport again
1109        assert_eq!(lc.generation(), prev_gen); // no change
1110    }
1111
1112    #[test]
1113    fn lifecycle_refit_without_viewport_returns_none() {
1114        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1115        lc.invalidate(MetricInvalidation::FontLoaded, None);
1116        assert!(lc.refit().is_none());
1117        assert!(!lc.is_pending()); // pending cleared
1118    }
1119
1120    #[test]
1121    fn lifecycle_refit_computes_grid() {
1122        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1123        lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1124        let result = lc.refit().unwrap();
1125        assert_eq!(result.cols, 80);
1126        assert_eq!(result.rows, 24);
1127        assert_eq!(lc.total_refits(), 1);
1128    }
1129
1130    #[test]
1131    fn lifecycle_refit_no_change_returns_none() {
1132        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1133        lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1134        let _ = lc.refit(); // first refit
1135        // Same viewport again — refit should return None (no change)
1136        lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1137        // viewport is same, so set_viewport doesn't bump pending
1138    }
1139
1140    #[test]
1141    fn lifecycle_refit_detects_dimension_change() {
1142        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1143        lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1144        let _ = lc.refit();
1145        // Change to different size
1146        lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
1147        let result = lc.refit().unwrap();
1148        assert_eq!(result.cols, 100);
1149        assert_eq!(result.rows, 37);
1150    }
1151
1152    #[test]
1153    fn lifecycle_set_policy_marks_pending() {
1154        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1155        lc.set_policy(FitPolicy::Fixed { cols: 80, rows: 24 });
1156        assert!(lc.is_pending());
1157    }
1158
1159    #[test]
1160    fn lifecycle_snapshot() {
1161        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1162        lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1163        let _ = lc.refit();
1164        let snap = lc.snapshot();
1165        assert_eq!(snap.fit_cols, 80);
1166        assert_eq!(snap.fit_rows, 24);
1167        assert_eq!(snap.viewport_width_px, 640);
1168        assert_eq!(snap.viewport_height_px, 384);
1169        assert_eq!(snap.dpr_subpx, 256);
1170        assert_eq!(snap.zoom_subpx, 256);
1171        assert!(!snap.pending_refit);
1172    }
1173
1174    #[test]
1175    fn lifecycle_multiple_invalidations() {
1176        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1177        lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1178        lc.invalidate(MetricInvalidation::FontLoaded, None);
1179        lc.invalidate(MetricInvalidation::DprChanged, None);
1180        lc.invalidate(MetricInvalidation::ZoomChanged, None);
1181        // Only one refit should be needed
1182        assert!(lc.is_pending());
1183        assert_eq!(lc.total_invalidations(), 4); // 1 from set_viewport + 3 explicit
1184    }
1185
1186    #[test]
1187    fn lifecycle_pending_invalidations_are_canonical() {
1188        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1189        lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1190        let _ = lc.refit();
1191
1192        lc.invalidate(MetricInvalidation::FontLoaded, None);
1193        lc.invalidate(MetricInvalidation::ZoomChanged, None);
1194        lc.invalidate(MetricInvalidation::FontLoaded, None); // duplicate should be de-duped
1195        lc.invalidate(MetricInvalidation::FullReset, None);
1196        lc.invalidate(MetricInvalidation::ContainerResized, None);
1197
1198        assert_eq!(
1199            lc.pending_invalidations(),
1200            vec![
1201                MetricInvalidation::FullReset,
1202                MetricInvalidation::ZoomChanged,
1203                MetricInvalidation::FontLoaded,
1204                MetricInvalidation::ContainerResized
1205            ]
1206        );
1207    }
1208
1209    #[test]
1210    fn lifecycle_set_viewport_tracks_dpr_and_zoom_invalidations() {
1211        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1212        lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
1213        let _ = lc.refit();
1214
1215        // Change physical size + DPR + zoom in one event.
1216        lc.set_viewport(ContainerViewport::new(1600, 1200, 2.0, 1.25).unwrap());
1217        assert_eq!(lc.last_invalidation(), Some(MetricInvalidation::DprChanged));
1218        assert_eq!(
1219            lc.pending_invalidations(),
1220            vec![
1221                MetricInvalidation::DprChanged,
1222                MetricInvalidation::ZoomChanged,
1223                MetricInvalidation::ContainerResized
1224            ]
1225        );
1226    }
1227
1228    #[test]
1229    fn lifecycle_delayed_font_load_uses_latest_metrics() {
1230        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1231        lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
1232        let baseline = lc.refit().unwrap();
1233        assert_eq!(baseline.cols, 100);
1234        assert_eq!(baseline.rows, 37);
1235
1236        // Temporary fallback metrics while font is still loading.
1237        let fallback = CellMetrics::from_px(9.0, 18.0).unwrap();
1238        lc.invalidate(MetricInvalidation::FontSizeChanged, Some(fallback));
1239        let fallback_fit = lc.refit().unwrap();
1240        assert_eq!(fallback_fit.cols, 88);
1241        assert_eq!(fallback_fit.rows, 33);
1242
1243        // Delayed final font load must overwrite fallback metrics deterministically.
1244        lc.invalidate(MetricInvalidation::FontLoaded, Some(CellMetrics::LARGE));
1245        let final_fit = lc.refit().unwrap();
1246        assert_eq!(final_fit.cols, 80);
1247        assert_eq!(final_fit.rows, 30);
1248        assert_eq!(*lc.cell_metrics(), CellMetrics::LARGE);
1249    }
1250
1251    #[test]
1252    fn lifecycle_font_swap_race_orders_invalidations_deterministically() {
1253        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1254        lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
1255        let _ = lc.refit();
1256
1257        let fallback = CellMetrics::from_px(9.0, 18.0).unwrap();
1258        let swapped = CellMetrics::from_px(11.0, 22.0).unwrap();
1259        lc.invalidate(MetricInvalidation::FontLoaded, Some(fallback));
1260        lc.invalidate(MetricInvalidation::FontLoaded, Some(swapped));
1261        lc.set_viewport(ContainerViewport::new(1600, 1200, 2.0, 1.25).unwrap());
1262
1263        assert_eq!(
1264            lc.pending_invalidations(),
1265            vec![
1266                MetricInvalidation::DprChanged,
1267                MetricInvalidation::ZoomChanged,
1268                MetricInvalidation::FontLoaded,
1269                MetricInvalidation::ContainerResized
1270            ]
1271        );
1272
1273        // Single coalesced refit uses latest swapped metrics (no stale fallback).
1274        let fit = lc.refit().unwrap();
1275        assert_eq!(fit.cols, 58); // floor(640 / 11)
1276        assert_eq!(fit.rows, 21); // floor(480 / 22)
1277        assert_eq!(*lc.cell_metrics(), swapped);
1278    }
1279
1280    #[test]
1281    fn lifecycle_dynamic_font_event_stream_keeps_fit_in_sync() {
1282        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1283        lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
1284        let _ = lc.refit();
1285
1286        // E2E-style dynamic sequence: fallback font metrics, viewport regime change,
1287        // then delayed final font load before the next coalesced refit.
1288        let fallback = CellMetrics::from_px(9.0, 18.0).unwrap();
1289        lc.invalidate(MetricInvalidation::FontLoaded, Some(fallback));
1290        lc.set_viewport(ContainerViewport::new(1600, 1200, 2.0, 1.25).unwrap());
1291        lc.invalidate(MetricInvalidation::FontLoaded, Some(CellMetrics::LARGE));
1292
1293        let fit = lc.refit().unwrap();
1294        assert_eq!(fit.cols, 64); // floor(640 / 10)
1295        assert_eq!(fit.rows, 24); // floor(480 / 20)
1296
1297        let snap = lc.snapshot();
1298        assert_eq!(snap.fit_cols, fit.cols);
1299        assert_eq!(snap.fit_rows, fit.rows);
1300        assert_eq!(snap.pending_invalidation_mask, 0);
1301        assert_eq!(snap.pending_invalidation_count, 0);
1302    }
1303
1304    #[test]
1305    fn lifecycle_font_size_change_affects_fit() {
1306        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1307        lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
1308        let first = lc.refit().unwrap();
1309        assert_eq!(first.cols, 100); // 800/8
1310        assert_eq!(first.rows, 37); // 600/16 = 37
1311
1312        // Double the font size: 16x32
1313        let big = CellMetrics::new(16 * 256, 32 * 256).unwrap();
1314        lc.invalidate(MetricInvalidation::FontSizeChanged, Some(big));
1315        let second = lc.refit().unwrap();
1316        assert_eq!(second.cols, 50); // 800/16
1317        assert_eq!(second.rows, 18); // 600/32 = 18
1318    }
1319
1320    #[test]
1321    fn lifecycle_dpr_change_affects_fit() {
1322        let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
1323        lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
1324        let first = lc.refit().unwrap();
1325        assert_eq!(first.cols, 100);
1326
1327        // Move to 2x DPR display (same physical pixels)
1328        let vp2 = ContainerViewport::new(800, 600, 2.0, 1.0).unwrap();
1329        lc.set_viewport(vp2);
1330        let second = lc.refit().unwrap();
1331        assert_eq!(second.cols, 50); // effective width halved
1332    }
1333
1334    // ── px_to_subpx edge cases ───────────────────────────────────────
1335
1336    #[test]
1337    fn subpx_conversion_zero() {
1338        assert_eq!(px_to_subpx(0.0), Some(0));
1339    }
1340
1341    #[test]
1342    fn subpx_conversion_negative() {
1343        assert_eq!(px_to_subpx(-1.0), None);
1344    }
1345
1346    #[test]
1347    fn subpx_conversion_nan() {
1348        assert_eq!(px_to_subpx(f64::NAN), None);
1349    }
1350
1351    #[test]
1352    fn subpx_conversion_infinity() {
1353        assert_eq!(px_to_subpx(f64::INFINITY), None);
1354    }
1355
1356    #[test]
1357    fn subpx_conversion_precise() {
1358        assert_eq!(px_to_subpx(1.0), Some(256));
1359        assert_eq!(px_to_subpx(0.5), Some(128));
1360        assert_eq!(px_to_subpx(2.0), Some(512));
1361    }
1362}