#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DpiScale {
scale_factor: f64,
}
impl DpiScale {
#[must_use]
pub fn new(scale_factor: f64) -> Self {
assert!(
scale_factor.is_finite() && scale_factor > 0.0,
"DPI scale factor must be finite and strictly positive, got {scale_factor}",
);
Self { scale_factor }
}
#[must_use]
pub fn to_logical(&self, physical: f64) -> f64 {
physical / self.scale_factor
}
#[must_use]
pub fn to_physical(&self, logical: f64) -> f64 {
logical * self.scale_factor
}
pub fn update_scale(&mut self, new_scale: f64) {
assert!(
new_scale.is_finite() && new_scale > 0.0,
"DPI scale factor must be finite and strictly positive, got {new_scale}",
);
self.scale_factor = new_scale;
}
#[must_use]
pub fn scale_factor(&self) -> f64 {
self.scale_factor
}
#[must_use]
pub fn is_valid(scale_factor: f64) -> bool {
scale_factor.is_finite() && scale_factor > 0.0
}
}
impl Default for DpiScale {
fn default() -> Self {
Self { scale_factor: 1.0 }
}
}
#[cfg(test)]
mod tests {
use super::DpiScale;
#[test]
fn default_is_identity_scale() {
let scale = DpiScale::default();
assert_eq!(scale.scale_factor(), 1.0);
assert_eq!(scale.to_logical(800.0), 800.0);
assert_eq!(scale.to_physical(800.0), 800.0);
}
#[test]
fn new_stores_scale_factor() {
let scale = DpiScale::new(2.0);
assert_eq!(scale.scale_factor(), 2.0);
}
#[test]
fn to_logical_divides_by_scale() {
let scale = DpiScale::new(2.0);
assert_eq!(scale.to_logical(1920.0), 960.0);
assert_eq!(scale.to_logical(1080.0), 540.0);
}
#[test]
fn to_physical_multiplies_by_scale() {
let scale = DpiScale::new(2.0);
assert_eq!(scale.to_physical(960.0), 1920.0);
assert_eq!(scale.to_physical(540.0), 1080.0);
}
#[test]
fn conversions_are_inverse() {
let scale = DpiScale::new(2.5);
for value in [0.0, 1.0, 100.0, 1234.5678, 4096.0] {
let roundtrip = scale.to_logical(scale.to_physical(value));
assert!(
(roundtrip - value).abs() < f64::EPSILON,
"logical(physical({value})) = {roundtrip} should equal {value}",
);
let roundtrip = scale.to_physical(scale.to_logical(value));
assert!(
(roundtrip - value).abs() < f64::EPSILON,
"physical(logical({value})) = {roundtrip} should equal {value}",
);
}
}
#[test]
fn fractional_scale_1_25() {
let scale = DpiScale::new(1.25);
assert_eq!(scale.to_physical(1000.0), 1250.0);
assert_eq!(scale.to_logical(1250.0), 1000.0);
}
#[test]
fn fractional_scale_1_5() {
let scale = DpiScale::new(1.5);
assert_eq!(scale.to_physical(100.0), 150.0);
assert_eq!(scale.to_logical(150.0), 100.0);
}
#[test]
fn fractional_scale_1_75() {
let scale = DpiScale::new(1.75);
assert_eq!(scale.to_physical(100.0), 175.0);
assert_eq!(scale.to_logical(175.0), 100.0);
}
#[test]
fn fractional_scale_3x_retina() {
let scale = DpiScale::new(3.0);
assert_eq!(scale.to_physical(640.0), 1920.0);
assert_eq!(scale.to_logical(1920.0), 640.0);
}
#[test]
fn update_scale_changes_conversions() {
let mut scale = DpiScale::new(1.0);
assert_eq!(scale.to_physical(100.0), 100.0);
scale.update_scale(2.0);
assert_eq!(scale.scale_factor(), 2.0);
assert_eq!(scale.to_physical(100.0), 200.0);
assert_eq!(scale.to_logical(200.0), 100.0);
}
#[test]
fn update_scale_to_fractional() {
let mut scale = DpiScale::new(1.0);
scale.update_scale(1.5);
assert_eq!(scale.scale_factor(), 1.5);
assert_eq!(scale.to_physical(200.0), 300.0);
}
#[test]
fn zero_physical_maps_to_zero_logical() {
let scale = DpiScale::new(2.0);
assert_eq!(scale.to_logical(0.0), 0.0);
assert_eq!(scale.to_physical(0.0), 0.0);
}
#[test]
fn negative_coordinates_are_preserved() {
let scale = DpiScale::new(2.0);
assert_eq!(scale.to_logical(-100.0), -50.0);
assert_eq!(scale.to_physical(-50.0), -100.0);
}
#[test]
fn subpixel_precision_is_retained() {
let scale = DpiScale::new(1.5);
assert_eq!(scale.to_physical(1.0), 1.5);
assert_eq!(scale.to_logical(1.5), 1.0);
}
#[test]
#[should_panic(expected = "strictly positive")]
fn new_rejects_zero() {
let _ = DpiScale::new(0.0);
}
#[test]
#[should_panic(expected = "strictly positive")]
fn new_rejects_negative() {
let _ = DpiScale::new(-1.0);
}
#[test]
#[should_panic(expected = "finite")]
fn new_rejects_nan() {
let _ = DpiScale::new(f64::NAN);
}
#[test]
#[should_panic(expected = "finite")]
fn new_rejects_infinity() {
let _ = DpiScale::new(f64::INFINITY);
}
#[test]
#[should_panic(expected = "strictly positive")]
fn update_scale_rejects_zero() {
let mut scale = DpiScale::new(1.0);
scale.update_scale(0.0);
}
#[test]
#[should_panic(expected = "finite")]
fn update_scale_rejects_nan() {
let mut scale = DpiScale::new(1.0);
scale.update_scale(f64::NAN);
}
#[test]
fn copy_and_eq_semantics() {
let a = DpiScale::new(1.5);
let b = a;
assert_eq!(a, b);
let c = DpiScale::new(2.0);
assert_ne!(a, c);
}
#[test]
fn is_valid_accepts_positive_finite() {
assert!(DpiScale::is_valid(1.0));
assert!(DpiScale::is_valid(0.5));
assert!(DpiScale::is_valid(1.25));
assert!(DpiScale::is_valid(3.0));
assert!(DpiScale::is_valid(0.01));
}
#[test]
fn is_valid_rejects_zero_and_negative() {
assert!(!DpiScale::is_valid(0.0));
assert!(!DpiScale::is_valid(-1.0));
assert!(!DpiScale::is_valid(-0.01));
}
#[test]
fn is_valid_rejects_non_finite() {
assert!(!DpiScale::is_valid(f64::NAN));
assert!(!DpiScale::is_valid(f64::INFINITY));
assert!(!DpiScale::is_valid(f64::NEG_INFINITY));
}
}