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
446impl MetricInvalidation {
447    /// Whether this invalidation requires recomputing glyph rasterization.
448    ///
449    /// DPR and font size changes affect pixel output; container resize does not.
450    #[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    /// Whether this invalidation requires recomputing grid dimensions.
459    #[must_use]
460    pub fn requires_refit(&self) -> bool {
461        // All invalidations may affect the fit except a pure font load
462        // where the cell size doesn't change (handled by caller).
463        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// =========================================================================
481// MetricLifecycle
482// =========================================================================
483
484/// Stateful tracker for font metric changes and cache invalidation.
485///
486/// Maintains the current cell metrics, generation counter, and pending
487/// invalidations. The lifecycle ensures a deterministic sequence:
488///
489/// 1. Invalidation event arrives (font load, DPR change, etc.)
490/// 2. Generation is bumped, pending flag is set
491/// 3. Caller calls [`MetricLifecycle::refit`] with new metrics
492/// 4. If grid dimensions changed, resize propagates through the pipeline
493///
494/// This prevents stale glyph metrics and geometry jumps by enforcing that
495/// all consumers see consistent metric state.
496#[derive(Debug, Clone)]
497pub struct MetricLifecycle {
498    /// Current cell metrics.
499    cell_metrics: CellMetrics,
500    /// Current viewport (if set).
501    viewport: Option<ContainerViewport>,
502    /// Fit policy.
503    policy: FitPolicy,
504    /// Current metric generation.
505    generation: MetricGeneration,
506    /// Whether a refit is pending.
507    pending_refit: bool,
508    /// Last invalidation reason.
509    last_invalidation: Option<MetricInvalidation>,
510    /// Last computed fit result.
511    last_fit: Option<FitResult>,
512    /// Total invalidation count for diagnostics.
513    total_invalidations: u64,
514    /// Total refit count for diagnostics.
515    total_refits: u64,
516}
517
518impl MetricLifecycle {
519    /// Create a new lifecycle with default cell metrics and no viewport.
520    #[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    /// Current cell metrics.
536    #[must_use]
537    pub fn cell_metrics(&self) -> &CellMetrics {
538        &self.cell_metrics
539    }
540
541    /// Current metric generation.
542    #[must_use]
543    pub fn generation(&self) -> MetricGeneration {
544        self.generation
545    }
546
547    /// Whether a refit is pending.
548    #[must_use]
549    pub fn is_pending(&self) -> bool {
550        self.pending_refit
551    }
552
553    /// Last computed fit result.
554    #[must_use]
555    pub fn last_fit(&self) -> Option<&FitResult> {
556        self.last_fit.as_ref()
557    }
558
559    /// Total invalidation count.
560    #[must_use]
561    pub fn total_invalidations(&self) -> u64 {
562        self.total_invalidations
563    }
564
565    /// Total refit count.
566    #[must_use]
567    pub fn total_refits(&self) -> u64 {
568        self.total_refits
569    }
570
571    /// Record an invalidation event.
572    ///
573    /// Bumps the generation and marks a refit as pending. If cell metrics
574    /// changed, the new metrics are stored immediately.
575    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    /// Update the container viewport.
587    ///
588    /// If the viewport changed, marks a refit as pending.
589    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    /// Update the fit policy.
601    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    /// Perform the pending refit computation.
609    ///
610    /// Returns `Some(FitResult)` if the grid dimensions changed, `None` if
611    /// no refit was needed or dimensions are unchanged.
612    ///
613    /// Clears the pending flag regardless of outcome.
614    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    /// Diagnostic snapshot for JSONL evidence logging.
634    #[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/// Diagnostic snapshot of metric lifecycle state.
654///
655/// All fields are `Copy` for cheap JSONL serialization.
656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
657pub struct MetricSnapshot {
658    /// Current generation counter.
659    pub generation: u64,
660    /// Whether a refit is pending.
661    pub pending_refit: bool,
662    /// Cell width in sub-pixel units.
663    pub cell_width_subpx: u32,
664    /// Cell height in sub-pixel units.
665    pub cell_height_subpx: u32,
666    /// Container width in physical pixels.
667    pub viewport_width_px: u32,
668    /// Container height in physical pixels.
669    pub viewport_height_px: u32,
670    /// DPR in sub-pixel units.
671    pub dpr_subpx: u32,
672    /// Zoom in sub-pixel units.
673    pub zoom_subpx: u32,
674    /// Last computed grid columns.
675    pub fit_cols: u16,
676    /// Last computed grid rows.
677    pub fit_rows: u16,
678    /// Total invalidation count.
679    pub total_invalidations: u64,
680    /// Total refit count.
681    pub total_refits: u64,
682}
683
684// =========================================================================
685// Tests
686// =========================================================================
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    // ── CellMetrics ──────────────────────────────────────────────────
693
694    #[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); // 8.5 * 256
712        assert_eq!(m.height_subpx, 4288); // 16.75 * 256
713        assert_eq!(m.width_px(), 8); // truncated
714        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    // ── ContainerViewport ────────────────────────────────────────────
750
751    #[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); // 1.0x
757        assert_eq!(v.zoom_subpx, 256); // 100%
758    }
759
760    #[test]
761    fn viewport_effective_1x_dpr() {
762        let v = ContainerViewport::simple(800, 600).unwrap();
763        // effective = physical * 256^3 / (256 * 256) = physical * 256
764        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        // effective = 1600 * 256^3 / (512 * 256) = 1600 * 256 / 2 = 800 * 256
772        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        // effective = 800 * 256^3 / (256 * 384) = 800 * 256^2 / 384
780        // = 800 * 65536 / 384 = 136533 (integer division)
781        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    // ── FitPolicy & fit_to_container ──────────────────────────────────
805
806    #[test]
807    fn fit_default_80x24_terminal() {
808        // 80 cols * 8px = 640px wide, 24 rows * 16px = 384px tall
809        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        // 645px / 8px = 80.625 → 80 cols, remainder 5px
821        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        // Container smaller than one cell: FitToContainer clamps to 1x1.
833        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        // Container fits 5x3 at 8x16, but minimum is 10x5
856        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); // 800/8
883        assert_eq!(r.rows, 37); // 600/16 = 37.5 → 37
884    }
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        // 2x DPR: 1600 physical px → 800 CSS px → 100 cols at 8px
911        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); // 384 CSS px / 16 = 24
916    }
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); // 800/8
924        assert_eq!(r.rows, 24); // 384/16
925    }
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    // ── MetricGeneration ──────────────────────────────────────────────
943
944    #[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    // ── MetricInvalidation ────────────────────────────────────────────
969
970    #[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    // ── MetricLifecycle ───────────────────────────────────────────────
987
988    #[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); // same viewport again
1031        assert_eq!(lc.generation(), prev_gen); // no change
1032    }
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()); // pending cleared
1040    }
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(); // first refit
1057        // Same viewport again — refit should return None (no change)
1058        lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
1059        // viewport is same, so set_viewport doesn't bump pending
1060    }
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        // Change to different size
1068        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        // Only one refit should be needed
1104        assert!(lc.is_pending());
1105        assert_eq!(lc.total_invalidations(), 4); // 1 from set_viewport + 3 explicit
1106    }
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); // 800/8
1114        assert_eq!(first.rows, 37); // 600/16 = 37
1115
1116        // Double the font size: 16x32
1117        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); // 800/16
1121        assert_eq!(second.rows, 18); // 600/32 = 18
1122    }
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        // Move to 2x DPR display (same physical pixels)
1132        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); // effective width halved
1136    }
1137
1138    // ── px_to_subpx edge cases ───────────────────────────────────────
1139
1140    #[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}