use std::fmt;
const SUBPX_SCALE: u32 = 256;
fn px_to_subpx(px: f64) -> Option<u32> {
if !px.is_finite() || px < 0.0 {
return None;
}
let val = (px * SUBPX_SCALE as f64).round();
if val > u32::MAX as f64 {
return None;
}
Some(val as u32)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CellMetrics {
pub width_subpx: u32,
pub height_subpx: u32,
}
impl CellMetrics {
#[must_use]
pub fn new(width_subpx: u32, height_subpx: u32) -> Option<Self> {
if width_subpx == 0 || height_subpx == 0 {
return None;
}
Some(Self {
width_subpx,
height_subpx,
})
}
#[must_use]
pub fn from_px(width_px: f64, height_px: f64) -> Option<Self> {
let w = px_to_subpx(width_px)?;
let h = px_to_subpx(height_px)?;
Self::new(w, h)
}
#[must_use]
pub const fn width_px(&self) -> u32 {
self.width_subpx / SUBPX_SCALE
}
#[must_use]
pub const fn height_px(&self) -> u32 {
self.height_subpx / SUBPX_SCALE
}
pub const MONOSPACE_DEFAULT: Self = Self {
width_subpx: 8 * SUBPX_SCALE,
height_subpx: 16 * SUBPX_SCALE,
};
pub const LARGE: Self = Self {
width_subpx: 10 * SUBPX_SCALE,
height_subpx: 20 * SUBPX_SCALE,
};
}
impl Default for CellMetrics {
fn default() -> Self {
Self::MONOSPACE_DEFAULT
}
}
impl fmt::Display for CellMetrics {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}x{}px ({:.2}x{:.2} sub-px)",
self.width_px(),
self.height_px(),
self.width_subpx as f64 / SUBPX_SCALE as f64,
self.height_subpx as f64 / SUBPX_SCALE as f64,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ContainerViewport {
pub width_px: u32,
pub height_px: u32,
pub dpr_subpx: u32,
pub zoom_subpx: u32,
}
impl ContainerViewport {
#[must_use]
pub fn new(width_px: u32, height_px: u32, dpr: f64, zoom: f64) -> Option<Self> {
let dpr_subpx = px_to_subpx(dpr)?;
let zoom_subpx = px_to_subpx(zoom)?;
if width_px == 0 || height_px == 0 || dpr_subpx == 0 || zoom_subpx == 0 {
return None;
}
Some(Self {
width_px,
height_px,
dpr_subpx,
zoom_subpx,
})
}
#[must_use]
pub fn simple(width_px: u32, height_px: u32) -> Option<Self> {
Self::new(width_px, height_px, 1.0, 1.0)
}
#[must_use]
pub fn effective_width_subpx(&self) -> u32 {
let scale3 = (SUBPX_SCALE as u64) * (SUBPX_SCALE as u64) * (SUBPX_SCALE as u64);
let numer = (self.width_px as u64) * scale3;
let denom = (self.dpr_subpx as u64) * (self.zoom_subpx as u64);
if denom == 0 {
return 0;
}
(numer / denom) as u32
}
#[must_use]
pub fn effective_height_subpx(&self) -> u32 {
let scale3 = (SUBPX_SCALE as u64) * (SUBPX_SCALE as u64) * (SUBPX_SCALE as u64);
let numer = (self.height_px as u64) * scale3;
let denom = (self.dpr_subpx as u64) * (self.zoom_subpx as u64);
if denom == 0 {
return 0;
}
(numer / denom) as u32
}
}
impl fmt::Display for ContainerViewport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}x{}px @{:.2}x DPR, {:.0}% zoom",
self.width_px,
self.height_px,
self.dpr_subpx as f64 / SUBPX_SCALE as f64,
self.zoom_subpx as f64 / SUBPX_SCALE as f64 * 100.0,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum FitPolicy {
#[default]
FitToContainer,
Fixed {
cols: u16,
rows: u16,
},
FitWithMinimum {
min_cols: u16,
min_rows: u16,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FitResult {
pub cols: u16,
pub rows: u16,
pub padding_right_subpx: u32,
pub padding_bottom_subpx: u32,
}
impl FitResult {
#[must_use]
pub fn is_valid(&self) -> bool {
self.cols > 0 && self.rows > 0
}
}
impl fmt::Display for FitResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}x{} cells", self.cols, self.rows)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FitError {
ContainerTooSmall,
DimensionOverflow,
}
impl fmt::Display for FitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ContainerTooSmall => write!(f, "container too small to fit any cells"),
Self::DimensionOverflow => write!(f, "computed grid dimensions overflow u16"),
}
}
}
pub fn fit_to_container(
viewport: &ContainerViewport,
cell: &CellMetrics,
policy: FitPolicy,
) -> Result<FitResult, FitError> {
match policy {
FitPolicy::Fixed { cols, rows } => Ok(FitResult {
cols,
rows,
padding_right_subpx: 0,
padding_bottom_subpx: 0,
}),
FitPolicy::FitToContainer => fit_internal(viewport, cell, 1, 1),
FitPolicy::FitWithMinimum { min_cols, min_rows } => {
fit_internal(viewport, cell, min_cols.max(1), min_rows.max(1))
}
}
}
fn fit_internal(
viewport: &ContainerViewport,
cell: &CellMetrics,
min_cols: u16,
min_rows: u16,
) -> Result<FitResult, FitError> {
let eff_w = viewport.effective_width_subpx();
let eff_h = viewport.effective_height_subpx();
let raw_cols = eff_w / cell.width_subpx;
let raw_rows = eff_h / cell.height_subpx;
let cols = raw_cols.max(min_cols as u32);
let rows = raw_rows.max(min_rows as u32);
if cols == 0 || rows == 0 {
return Err(FitError::ContainerTooSmall);
}
if cols > u16::MAX as u32 || rows > u16::MAX as u32 {
return Err(FitError::DimensionOverflow);
}
let cols = cols as u16;
let rows = rows as u16;
let used_w = cols as u32 * cell.width_subpx;
let used_h = rows as u32 * cell.height_subpx;
let pad_r = eff_w.saturating_sub(used_w);
let pad_b = eff_h.saturating_sub(used_h);
Ok(FitResult {
cols,
rows,
padding_right_subpx: pad_r,
padding_bottom_subpx: pad_b,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct MetricGeneration(u64);
impl MetricGeneration {
pub const ZERO: Self = Self(0);
#[must_use]
pub fn next(self) -> Self {
Self(self.0.saturating_add(1))
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
impl fmt::Display for MetricGeneration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "gen:{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MetricInvalidation {
FontLoaded,
DprChanged,
ZoomChanged,
ContainerResized,
FontSizeChanged,
FullReset,
}
const METRIC_INVALIDATION_CANONICAL_ORDER: [MetricInvalidation; 6] = [
MetricInvalidation::FullReset,
MetricInvalidation::FontSizeChanged,
MetricInvalidation::DprChanged,
MetricInvalidation::ZoomChanged,
MetricInvalidation::FontLoaded,
MetricInvalidation::ContainerResized,
];
impl MetricInvalidation {
#[must_use]
const fn bit(self) -> u8 {
match self {
Self::FontLoaded => 1 << 0,
Self::DprChanged => 1 << 1,
Self::ZoomChanged => 1 << 2,
Self::ContainerResized => 1 << 3,
Self::FontSizeChanged => 1 << 4,
Self::FullReset => 1 << 5,
}
}
#[must_use]
pub fn ordered_pending_from_mask(mask: u8) -> Vec<Self> {
let mut ordered = Vec::with_capacity(METRIC_INVALIDATION_CANONICAL_ORDER.len());
for reason in METRIC_INVALIDATION_CANONICAL_ORDER {
if mask & reason.bit() != 0 {
ordered.push(reason);
}
}
ordered
}
#[must_use]
pub fn requires_rasterization(&self) -> bool {
matches!(
self,
Self::FontLoaded | Self::DprChanged | Self::FontSizeChanged | Self::FullReset
)
}
#[must_use]
pub fn requires_refit(&self) -> bool {
true
}
}
impl fmt::Display for MetricInvalidation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::FontLoaded => write!(f, "font_loaded"),
Self::DprChanged => write!(f, "dpr_changed"),
Self::ZoomChanged => write!(f, "zoom_changed"),
Self::ContainerResized => write!(f, "container_resized"),
Self::FontSizeChanged => write!(f, "font_size_changed"),
Self::FullReset => write!(f, "full_reset"),
}
}
}
#[derive(Debug, Clone)]
pub struct MetricLifecycle {
cell_metrics: CellMetrics,
viewport: Option<ContainerViewport>,
policy: FitPolicy,
generation: MetricGeneration,
pending_refit: bool,
last_invalidation: Option<MetricInvalidation>,
pending_invalidation_mask: u8,
last_fit: Option<FitResult>,
total_invalidations: u64,
total_refits: u64,
}
impl MetricLifecycle {
#[must_use]
pub fn new(cell_metrics: CellMetrics, policy: FitPolicy) -> Self {
Self {
cell_metrics,
viewport: None,
policy,
generation: MetricGeneration::ZERO,
pending_refit: false,
last_invalidation: None,
pending_invalidation_mask: 0,
last_fit: None,
total_invalidations: 0,
total_refits: 0,
}
}
#[must_use]
pub fn cell_metrics(&self) -> &CellMetrics {
&self.cell_metrics
}
#[must_use]
pub fn generation(&self) -> MetricGeneration {
self.generation
}
#[must_use]
pub fn is_pending(&self) -> bool {
self.pending_refit
}
#[must_use]
pub fn last_invalidation(&self) -> Option<MetricInvalidation> {
self.last_invalidation
}
#[must_use]
pub fn pending_invalidations(&self) -> Vec<MetricInvalidation> {
MetricInvalidation::ordered_pending_from_mask(self.pending_invalidation_mask)
}
#[must_use]
pub fn last_fit(&self) -> Option<&FitResult> {
self.last_fit.as_ref()
}
#[must_use]
pub fn total_invalidations(&self) -> u64 {
self.total_invalidations
}
#[must_use]
pub fn total_refits(&self) -> u64 {
self.total_refits
}
pub fn invalidate(&mut self, reason: MetricInvalidation, new_metrics: Option<CellMetrics>) {
self.generation = self.generation.next();
self.pending_refit = true;
self.last_invalidation = Some(reason);
self.pending_invalidation_mask |= reason.bit();
self.total_invalidations += 1;
if let Some(metrics) = new_metrics {
self.cell_metrics = metrics;
}
}
pub fn set_viewport(&mut self, viewport: ContainerViewport) {
let changed = self.viewport.is_none_or(|v| v != viewport);
if changed {
let mut primary_reason = MetricInvalidation::ContainerResized;
if let Some(previous) = self.viewport {
if previous.dpr_subpx != viewport.dpr_subpx {
self.pending_invalidation_mask |= MetricInvalidation::DprChanged.bit();
primary_reason = MetricInvalidation::DprChanged;
}
if previous.zoom_subpx != viewport.zoom_subpx {
self.pending_invalidation_mask |= MetricInvalidation::ZoomChanged.bit();
if primary_reason == MetricInvalidation::ContainerResized {
primary_reason = MetricInvalidation::ZoomChanged;
}
}
if previous.width_px != viewport.width_px
|| previous.height_px != viewport.height_px
{
self.pending_invalidation_mask |= MetricInvalidation::ContainerResized.bit();
}
} else {
self.pending_invalidation_mask |= MetricInvalidation::ContainerResized.bit();
}
self.generation = self.generation.next();
self.pending_refit = true;
self.last_invalidation = Some(primary_reason);
self.total_invalidations += 1;
}
self.viewport = Some(viewport);
}
pub fn set_policy(&mut self, policy: FitPolicy) {
if self.policy != policy {
self.policy = policy;
self.pending_refit = true;
}
}
pub fn refit(&mut self) -> Option<FitResult> {
if !self.pending_refit {
return None;
}
self.pending_refit = false;
self.pending_invalidation_mask = 0;
self.total_refits += 1;
let viewport = self.viewport?;
let result = fit_to_container(&viewport, &self.cell_metrics, self.policy).ok()?;
let changed = self
.last_fit
.is_none_or(|prev| prev.cols != result.cols || prev.rows != result.rows);
self.last_fit = Some(result);
if changed { Some(result) } else { None }
}
#[must_use]
pub fn snapshot(&self) -> MetricSnapshot {
MetricSnapshot {
generation: self.generation.get(),
pending_refit: self.pending_refit,
cell_width_subpx: self.cell_metrics.width_subpx,
cell_height_subpx: self.cell_metrics.height_subpx,
viewport_width_px: self.viewport.map(|v| v.width_px).unwrap_or(0),
viewport_height_px: self.viewport.map(|v| v.height_px).unwrap_or(0),
dpr_subpx: self.viewport.map(|v| v.dpr_subpx).unwrap_or(0),
zoom_subpx: self.viewport.map(|v| v.zoom_subpx).unwrap_or(0),
fit_cols: self.last_fit.map(|f| f.cols).unwrap_or(0),
fit_rows: self.last_fit.map(|f| f.rows).unwrap_or(0),
pending_invalidation_mask: self.pending_invalidation_mask,
pending_invalidation_count: self.pending_invalidation_mask.count_ones() as u8,
total_invalidations: self.total_invalidations,
total_refits: self.total_refits,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MetricSnapshot {
pub generation: u64,
pub pending_refit: bool,
pub cell_width_subpx: u32,
pub cell_height_subpx: u32,
pub viewport_width_px: u32,
pub viewport_height_px: u32,
pub dpr_subpx: u32,
pub zoom_subpx: u32,
pub fit_cols: u16,
pub fit_rows: u16,
pub pending_invalidation_mask: u8,
pub pending_invalidation_count: u8,
pub total_invalidations: u64,
pub total_refits: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cell_metrics_default_is_monospace() {
let m = CellMetrics::default();
assert_eq!(m.width_px(), 8);
assert_eq!(m.height_px(), 16);
}
#[test]
fn cell_metrics_from_px() {
let m = CellMetrics::from_px(9.0, 18.0).unwrap();
assert_eq!(m.width_px(), 9);
assert_eq!(m.height_px(), 18);
}
#[test]
fn cell_metrics_from_px_fractional() {
let m = CellMetrics::from_px(8.5, 16.75).unwrap();
assert_eq!(m.width_subpx, 2176); assert_eq!(m.height_subpx, 4288); assert_eq!(m.width_px(), 8); assert_eq!(m.height_px(), 16);
}
#[test]
fn cell_metrics_rejects_zero() {
assert!(CellMetrics::new(0, 256).is_none());
assert!(CellMetrics::new(256, 0).is_none());
assert!(CellMetrics::new(0, 0).is_none());
}
#[test]
fn cell_metrics_rejects_negative_px() {
assert!(CellMetrics::from_px(-1.0, 16.0).is_none());
assert!(CellMetrics::from_px(8.0, -1.0).is_none());
}
#[test]
fn cell_metrics_rejects_nan() {
assert!(CellMetrics::from_px(f64::NAN, 16.0).is_none());
assert!(CellMetrics::from_px(8.0, f64::INFINITY).is_none());
}
#[test]
fn cell_metrics_display() {
let m = CellMetrics::MONOSPACE_DEFAULT;
let s = format!("{m}");
assert!(s.contains("8x16px"));
}
#[test]
fn cell_metrics_large_preset() {
assert_eq!(CellMetrics::LARGE.width_px(), 10);
assert_eq!(CellMetrics::LARGE.height_px(), 20);
}
#[test]
fn viewport_simple() {
let v = ContainerViewport::simple(800, 600).unwrap();
assert_eq!(v.width_px, 800);
assert_eq!(v.height_px, 600);
assert_eq!(v.dpr_subpx, 256); assert_eq!(v.zoom_subpx, 256); }
#[test]
fn viewport_effective_1x_dpr() {
let v = ContainerViewport::simple(800, 600).unwrap();
assert_eq!(v.effective_width_subpx(), 800 * SUBPX_SCALE);
assert_eq!(v.effective_height_subpx(), 600 * SUBPX_SCALE);
}
#[test]
fn viewport_effective_2x_dpr() {
let v = ContainerViewport::new(1600, 1200, 2.0, 1.0).unwrap();
assert_eq!(v.effective_width_subpx(), 800 * SUBPX_SCALE);
assert_eq!(v.effective_height_subpx(), 600 * SUBPX_SCALE);
}
#[test]
fn viewport_effective_zoom_150() {
let v = ContainerViewport::new(800, 600, 1.0, 1.5).unwrap();
let eff = v.effective_width_subpx();
assert_eq!(eff, 136533);
}
#[test]
fn viewport_rejects_zero_dims() {
assert!(ContainerViewport::simple(0, 600).is_none());
assert!(ContainerViewport::simple(800, 0).is_none());
}
#[test]
fn viewport_rejects_zero_dpr() {
assert!(ContainerViewport::new(800, 600, 0.0, 1.0).is_none());
}
#[test]
fn viewport_display() {
let v = ContainerViewport::simple(800, 600).unwrap();
let s = format!("{v}");
assert!(s.contains("800x600px"));
assert!(s.contains("1.00x DPR"));
}
#[test]
fn fit_default_80x24_terminal() {
let v = ContainerViewport::simple(640, 384).unwrap();
let r =
fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
assert_eq!(r.cols, 80);
assert_eq!(r.rows, 24);
assert_eq!(r.padding_right_subpx, 0);
assert_eq!(r.padding_bottom_subpx, 0);
}
#[test]
fn fit_with_remainder() {
let v = ContainerViewport::simple(645, 390).unwrap();
let r =
fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
assert_eq!(r.cols, 80);
assert_eq!(r.rows, 24);
assert_eq!(r.padding_right_subpx, 5 * 256);
assert_eq!(r.padding_bottom_subpx, 6 * 256);
}
#[test]
fn fit_small_container_clamps_to_1x1() {
let v = ContainerViewport::simple(4, 8).unwrap();
let r =
fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
assert_eq!(r.cols, 1);
assert_eq!(r.rows, 1);
}
#[test]
fn fit_fixed_ignores_container() {
let v = ContainerViewport::simple(100, 100).unwrap();
let r = fit_to_container(
&v,
&CellMetrics::MONOSPACE_DEFAULT,
FitPolicy::Fixed { cols: 80, rows: 24 },
)
.unwrap();
assert_eq!(r.cols, 80);
assert_eq!(r.rows, 24);
}
#[test]
fn fit_with_minimum_guarantees_min_size() {
let v = ContainerViewport::simple(40, 48).unwrap();
let r = fit_to_container(
&v,
&CellMetrics::MONOSPACE_DEFAULT,
FitPolicy::FitWithMinimum {
min_cols: 10,
min_rows: 5,
},
)
.unwrap();
assert_eq!(r.cols, 10);
assert_eq!(r.rows, 5);
}
#[test]
fn fit_with_minimum_uses_actual_when_larger() {
let v = ContainerViewport::simple(800, 600).unwrap();
let r = fit_to_container(
&v,
&CellMetrics::MONOSPACE_DEFAULT,
FitPolicy::FitWithMinimum {
min_cols: 10,
min_rows: 5,
},
)
.unwrap();
assert_eq!(r.cols, 100); assert_eq!(r.rows, 37); }
#[test]
fn fit_result_is_valid() {
let r = FitResult {
cols: 80,
rows: 24,
padding_right_subpx: 0,
padding_bottom_subpx: 0,
};
assert!(r.is_valid());
}
#[test]
fn fit_result_display() {
let r = FitResult {
cols: 120,
rows: 40,
padding_right_subpx: 0,
padding_bottom_subpx: 0,
};
assert_eq!(format!("{r}"), "120x40 cells");
}
#[test]
fn fit_at_2x_dpr() {
let v = ContainerViewport::new(1600, 768, 2.0, 1.0).unwrap();
let r =
fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
assert_eq!(r.cols, 100);
assert_eq!(r.rows, 24); }
#[test]
fn fit_at_3x_dpr() {
let v = ContainerViewport::new(2400, 1152, 3.0, 1.0).unwrap();
let r =
fit_to_container(&v, &CellMetrics::MONOSPACE_DEFAULT, FitPolicy::default()).unwrap();
assert_eq!(r.cols, 100); assert_eq!(r.rows, 24); }
#[test]
fn fit_deterministic_across_calls() {
let v = ContainerViewport::simple(800, 600).unwrap();
let m = CellMetrics::MONOSPACE_DEFAULT;
let r1 = fit_to_container(&v, &m, FitPolicy::default()).unwrap();
let r2 = fit_to_container(&v, &m, FitPolicy::default()).unwrap();
assert_eq!(r1, r2);
}
#[test]
fn fit_error_display() {
assert!(!format!("{}", FitError::ContainerTooSmall).is_empty());
assert!(!format!("{}", FitError::DimensionOverflow).is_empty());
}
#[test]
fn generation_starts_at_zero() {
assert_eq!(MetricGeneration::ZERO.get(), 0);
}
#[test]
fn generation_increments() {
let g = MetricGeneration::ZERO.next().next();
assert_eq!(g.get(), 2);
}
#[test]
fn generation_display() {
let s = format!("{}", MetricGeneration::ZERO.next());
assert_eq!(s, "gen:1");
}
#[test]
fn generation_ordering() {
let g0 = MetricGeneration::ZERO;
let g1 = g0.next();
assert!(g1 > g0);
}
#[test]
fn invalidation_requires_rasterization() {
assert!(MetricInvalidation::FontLoaded.requires_rasterization());
assert!(MetricInvalidation::DprChanged.requires_rasterization());
assert!(MetricInvalidation::FontSizeChanged.requires_rasterization());
assert!(MetricInvalidation::FullReset.requires_rasterization());
assert!(!MetricInvalidation::ZoomChanged.requires_rasterization());
assert!(!MetricInvalidation::ContainerResized.requires_rasterization());
}
#[test]
fn invalidation_display() {
assert_eq!(format!("{}", MetricInvalidation::FontLoaded), "font_loaded");
assert_eq!(format!("{}", MetricInvalidation::DprChanged), "dpr_changed");
}
#[test]
fn lifecycle_initial_state() {
let lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
assert_eq!(lc.generation(), MetricGeneration::ZERO);
assert!(!lc.is_pending());
assert!(lc.last_fit().is_none());
assert_eq!(lc.total_invalidations(), 0);
assert_eq!(lc.total_refits(), 0);
}
#[test]
fn lifecycle_invalidate_bumps_generation() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.invalidate(MetricInvalidation::FontLoaded, None);
assert_eq!(lc.generation().get(), 1);
assert!(lc.is_pending());
assert_eq!(lc.total_invalidations(), 1);
}
#[test]
fn lifecycle_invalidate_with_new_metrics() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
let new = CellMetrics::LARGE;
lc.invalidate(MetricInvalidation::FontSizeChanged, Some(new));
assert_eq!(*lc.cell_metrics(), new);
}
#[test]
fn lifecycle_set_viewport_marks_pending() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
let vp = ContainerViewport::simple(800, 600).unwrap();
lc.set_viewport(vp);
assert!(lc.is_pending());
assert_eq!(lc.generation().get(), 1);
}
#[test]
fn lifecycle_set_viewport_same_no_change() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
let vp = ContainerViewport::simple(800, 600).unwrap();
lc.set_viewport(vp);
let prev_gen = lc.generation();
lc.set_viewport(vp); assert_eq!(lc.generation(), prev_gen); }
#[test]
fn lifecycle_refit_without_viewport_returns_none() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.invalidate(MetricInvalidation::FontLoaded, None);
assert!(lc.refit().is_none());
assert!(!lc.is_pending()); }
#[test]
fn lifecycle_refit_computes_grid() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
let result = lc.refit().unwrap();
assert_eq!(result.cols, 80);
assert_eq!(result.rows, 24);
assert_eq!(lc.total_refits(), 1);
}
#[test]
fn lifecycle_refit_no_change_returns_none() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
let _ = lc.refit(); lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
}
#[test]
fn lifecycle_refit_detects_dimension_change() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
let _ = lc.refit();
lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
let result = lc.refit().unwrap();
assert_eq!(result.cols, 100);
assert_eq!(result.rows, 37);
}
#[test]
fn lifecycle_set_policy_marks_pending() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_policy(FitPolicy::Fixed { cols: 80, rows: 24 });
assert!(lc.is_pending());
}
#[test]
fn lifecycle_snapshot() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
let _ = lc.refit();
let snap = lc.snapshot();
assert_eq!(snap.fit_cols, 80);
assert_eq!(snap.fit_rows, 24);
assert_eq!(snap.viewport_width_px, 640);
assert_eq!(snap.viewport_height_px, 384);
assert_eq!(snap.dpr_subpx, 256);
assert_eq!(snap.zoom_subpx, 256);
assert!(!snap.pending_refit);
}
#[test]
fn lifecycle_multiple_invalidations() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
lc.invalidate(MetricInvalidation::FontLoaded, None);
lc.invalidate(MetricInvalidation::DprChanged, None);
lc.invalidate(MetricInvalidation::ZoomChanged, None);
assert!(lc.is_pending());
assert_eq!(lc.total_invalidations(), 4); }
#[test]
fn lifecycle_pending_invalidations_are_canonical() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(640, 384).unwrap());
let _ = lc.refit();
lc.invalidate(MetricInvalidation::FontLoaded, None);
lc.invalidate(MetricInvalidation::ZoomChanged, None);
lc.invalidate(MetricInvalidation::FontLoaded, None); lc.invalidate(MetricInvalidation::FullReset, None);
lc.invalidate(MetricInvalidation::ContainerResized, None);
assert_eq!(
lc.pending_invalidations(),
vec![
MetricInvalidation::FullReset,
MetricInvalidation::ZoomChanged,
MetricInvalidation::FontLoaded,
MetricInvalidation::ContainerResized
]
);
}
#[test]
fn lifecycle_set_viewport_tracks_dpr_and_zoom_invalidations() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
let _ = lc.refit();
lc.set_viewport(ContainerViewport::new(1600, 1200, 2.0, 1.25).unwrap());
assert_eq!(lc.last_invalidation(), Some(MetricInvalidation::DprChanged));
assert_eq!(
lc.pending_invalidations(),
vec![
MetricInvalidation::DprChanged,
MetricInvalidation::ZoomChanged,
MetricInvalidation::ContainerResized
]
);
}
#[test]
fn lifecycle_delayed_font_load_uses_latest_metrics() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
let baseline = lc.refit().unwrap();
assert_eq!(baseline.cols, 100);
assert_eq!(baseline.rows, 37);
let fallback = CellMetrics::from_px(9.0, 18.0).unwrap();
lc.invalidate(MetricInvalidation::FontSizeChanged, Some(fallback));
let fallback_fit = lc.refit().unwrap();
assert_eq!(fallback_fit.cols, 88);
assert_eq!(fallback_fit.rows, 33);
lc.invalidate(MetricInvalidation::FontLoaded, Some(CellMetrics::LARGE));
let final_fit = lc.refit().unwrap();
assert_eq!(final_fit.cols, 80);
assert_eq!(final_fit.rows, 30);
assert_eq!(*lc.cell_metrics(), CellMetrics::LARGE);
}
#[test]
fn lifecycle_font_swap_race_orders_invalidations_deterministically() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
let _ = lc.refit();
let fallback = CellMetrics::from_px(9.0, 18.0).unwrap();
let swapped = CellMetrics::from_px(11.0, 22.0).unwrap();
lc.invalidate(MetricInvalidation::FontLoaded, Some(fallback));
lc.invalidate(MetricInvalidation::FontLoaded, Some(swapped));
lc.set_viewport(ContainerViewport::new(1600, 1200, 2.0, 1.25).unwrap());
assert_eq!(
lc.pending_invalidations(),
vec![
MetricInvalidation::DprChanged,
MetricInvalidation::ZoomChanged,
MetricInvalidation::FontLoaded,
MetricInvalidation::ContainerResized
]
);
let fit = lc.refit().unwrap();
assert_eq!(fit.cols, 58); assert_eq!(fit.rows, 21); assert_eq!(*lc.cell_metrics(), swapped);
}
#[test]
fn lifecycle_dynamic_font_event_stream_keeps_fit_in_sync() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
let _ = lc.refit();
let fallback = CellMetrics::from_px(9.0, 18.0).unwrap();
lc.invalidate(MetricInvalidation::FontLoaded, Some(fallback));
lc.set_viewport(ContainerViewport::new(1600, 1200, 2.0, 1.25).unwrap());
lc.invalidate(MetricInvalidation::FontLoaded, Some(CellMetrics::LARGE));
let fit = lc.refit().unwrap();
assert_eq!(fit.cols, 64); assert_eq!(fit.rows, 24);
let snap = lc.snapshot();
assert_eq!(snap.fit_cols, fit.cols);
assert_eq!(snap.fit_rows, fit.rows);
assert_eq!(snap.pending_invalidation_mask, 0);
assert_eq!(snap.pending_invalidation_count, 0);
}
#[test]
fn lifecycle_font_size_change_affects_fit() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
let first = lc.refit().unwrap();
assert_eq!(first.cols, 100); assert_eq!(first.rows, 37);
let big = CellMetrics::new(16 * 256, 32 * 256).unwrap();
lc.invalidate(MetricInvalidation::FontSizeChanged, Some(big));
let second = lc.refit().unwrap();
assert_eq!(second.cols, 50); assert_eq!(second.rows, 18); }
#[test]
fn lifecycle_dpr_change_affects_fit() {
let mut lc = MetricLifecycle::new(CellMetrics::default(), FitPolicy::default());
lc.set_viewport(ContainerViewport::simple(800, 600).unwrap());
let first = lc.refit().unwrap();
assert_eq!(first.cols, 100);
let vp2 = ContainerViewport::new(800, 600, 2.0, 1.0).unwrap();
lc.set_viewport(vp2);
let second = lc.refit().unwrap();
assert_eq!(second.cols, 50); }
#[test]
fn subpx_conversion_zero() {
assert_eq!(px_to_subpx(0.0), Some(0));
}
#[test]
fn subpx_conversion_negative() {
assert_eq!(px_to_subpx(-1.0), None);
}
#[test]
fn subpx_conversion_nan() {
assert_eq!(px_to_subpx(f64::NAN), None);
}
#[test]
fn subpx_conversion_infinity() {
assert_eq!(px_to_subpx(f64::INFINITY), None);
}
#[test]
fn subpx_conversion_precise() {
assert_eq!(px_to_subpx(1.0), Some(256));
assert_eq!(px_to_subpx(0.5), Some(128));
assert_eq!(px_to_subpx(2.0), Some(512));
}
}