Skip to main content

blockworx_paint/
zoom.rs

1//! The canvas world→screen scale, constrained to the range the view ever
2//! legitimately reaches: framing clamps to `[0.1, 10]` and wheel zoom to a
3//! narrower band inside it. The type guarantees the scale is positive, so
4//! screen↔world division is always sound.
5
6use blockworx_geom::{Bounded, Bounds};
7
8pub struct ZoomBounds;
9
10impl Bounds for ZoomBounds {
11    const MIN: f32 = 0.1;
12    const MAX: f32 = 10.0;
13}
14
15/// A newtype rather than a `Bounded<ZoomBounds>` alias so [`Zoom::unity`] can
16/// be inherent; the clamping and the total order are [`Bounded`]'s.
17#[derive(
18    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
19)]
20#[serde(transparent)]
21pub struct Zoom(Bounded<ZoomBounds>);
22
23impl Zoom {
24    pub const fn new(scale: f32) -> Self {
25        Self(Bounded::new(scale))
26    }
27
28    pub const fn get(self) -> f32 {
29        self.0.get()
30    }
31
32    pub const fn unity() -> Self {
33        Self::new(1.0)
34    }
35
36    pub const fn min_value() -> Self {
37        Self(Bounded::min_value())
38    }
39
40    pub const fn max_value() -> Self {
41        Self(Bounded::max_value())
42    }
43
44    /// The band the user's own zoom — the wheel, a pinch, a keyboard step —
45    /// lands in. Deliberately narrower than the type's own bounds, which the
46    /// framing helpers are allowed to use in full.
47    pub const WORKED_MIN: Self = Self::new(0.25);
48    pub const WORKED_MAX: Self = Self::new(4.0);
49
50    /// The zoom that fits a `content`-sized box within a `viewport`-sized one,
51    /// capped at `max_zoom`. Padding is the caller's: it pads `content` before
52    /// framing it.
53    pub fn framing(
54        viewport: blockworx_geom::Vec2,
55        content: blockworx_geom::Vec2,
56        max_zoom: Self,
57    ) -> Self {
58        let zoom = (viewport.x / content.x).min(viewport.y / content.y);
59        Self::new(zoom.min(max_zoom.get()))
60    }
61}
62
63impl From<Zoom> for f32 {
64    fn from(zoom: Zoom) -> f32 {
65        zoom.get()
66    }
67}
68
69/// One keyboard zoom step's direction — the parameter form of "which way",
70/// so a call site reads `ZoomStep::In` rather than a bare sign or factor.
71#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
72pub enum ZoomStep {
73    In,
74    Out,
75}
76
77impl ZoomStep {
78    /// The factor this step multiplies the zoom by. A quarter step: small
79    /// enough to land where you meant, large enough to feel like a press.
80    pub fn factor(self) -> Factor {
81        const STEP: f32 = 1.25;
82        Factor::new(match self {
83            ZoomStep::In => STEP,
84            ZoomStep::Out => 1.0 / STEP,
85        })
86    }
87}
88
89/// What a zoom is multiplied by: a wheel notch, a pinch, a keyboard step.
90/// Above one magnifies.
91#[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
92pub struct Factor(f32);
93
94/// Zoom factor exponent per pixel of scroll. One rate, so a wheel notch
95/// means the same thing whichever host read it.
96const SCROLL_ZOOM_RATE: f32 = 0.002;
97
98/// A wheel notch as a host reports it: the screen pixels the diagram was
99/// asked to scroll **up** by. Hosts disagree on the sign of a wheel, so each
100/// states its own in this type's terms and none of them states the rate.
101#[derive(Clone, Copy, PartialEq, Debug)]
102pub struct ScrollPx(f32);
103
104impl ScrollPx {
105    pub const fn up(pixels: f32) -> Self {
106        Self(pixels)
107    }
108}
109
110impl Factor {
111    /// Leaves the zoom where it is.
112    pub const IDENTITY: Self = Self(1.0);
113
114    pub const fn new(factor: f32) -> Self {
115        Self(factor)
116    }
117
118    /// The zoom a wheel notch of `scroll` asks for.
119    #[must_use]
120    pub fn of_scroll(scroll: ScrollPx) -> Self {
121        Self((scroll.0 * SCROLL_ZOOM_RATE).exp())
122    }
123
124    pub const fn get(self) -> f32 {
125        self.0
126    }
127}
128
129impl From<Factor> for f32 {
130    fn from(factor: Factor) -> Self {
131        factor.0
132    }
133}
134
135#[cfg(test)]
136mod scroll_tests {
137    use super::*;
138
139    #[test]
140    fn scrolling_up_magnifies_and_scrolling_down_shrinks() {
141        assert_eq!(Factor::of_scroll(ScrollPx::up(0.0)), Factor::IDENTITY);
142        assert!(Factor::of_scroll(ScrollPx::up(50.0)).get() > 1.0);
143        assert!(Factor::of_scroll(ScrollPx::up(-50.0)).get() < 1.0);
144    }
145
146    /// Equal and opposite notches undo one another, so a wheel rolled back
147    /// and forth lands where it started.
148    #[test]
149    fn opposite_notches_cancel() {
150        let there = Factor::of_scroll(ScrollPx::up(37.0)).get();
151        let back = Factor::of_scroll(ScrollPx::up(-37.0)).get();
152        assert!((there * back - 1.0).abs() < 1e-5, "{there} {back}");
153    }
154}