Skip to main content

compose_rust/
zoom.rs

1//! How large the Renderer is drawing, and the application's way to change it.
2//!
3//! Two things make a screen larger. The reader's operating system has a text size (`os`),
4//! and the application has a zoom level (`level`), stepped with Command or Control and
5//! plus, minus or zero, the way an editor zooms. The level is a whole number from
6//! [`MIN_ZOOM_LEVEL`] to [`MAX_ZOOM_LEVEL`], and each step is a factor of 1.2. What the
7//! screen is drawn at is their product, `k = os × 1.2^level`.
8//!
9//! The Renderer owns both: it reads the system's setting, answers the keys and remembers
10//! the level between runs. It tells the Host the result once at start and again whenever
11//! it changes, the way a size class is reported, and this module is where that report is
12//! kept for the components that read it. An application that lays out text of its own
13//! (an HTML island, a measured paragraph) uses `k`; one that only uses native widgets needs
14//! nothing here, because those are already drawn at the right size.
15//!
16//! The application can also set the level, from a menu's "Zoom In" for example. That rides
17//! out on the window record, in the batch the call produces.
18
19use std::cell::{Cell, RefCell};
20use std::sync::Arc;
21
22/// The smallest application zoom level. Eight steps of 1.2 down is about a quarter size.
23pub const MIN_ZOOM_LEVEL: i8 = -8;
24/// The largest application zoom level. Eight steps of 1.2 up is about four times the size.
25pub const MAX_ZOOM_LEVEL: i8 = 8;
26
27/// The factor one application zoom step multiplies by.
28pub const ZOOM_STEP: f32 = 1.2;
29
30/// What the Renderer is drawing at, as it last reported.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct Zoom {
33    /// The whole factor, `os × app`.
34    pub k: f32,
35    /// The operating system's text size, where 1 is the default.
36    pub os: f32,
37    /// The application's zoom level, from [`MIN_ZOOM_LEVEL`] to [`MAX_ZOOM_LEVEL`].
38    pub level: i8,
39}
40
41impl Zoom {
42    /// Nothing enlarged: the size before the Renderer has said anything.
43    pub const DEFAULT: Zoom = Zoom {
44        k: 1.0,
45        os: 1.0,
46        level: 0,
47    };
48
49    /// The application's share of the factor, `1.2^level`.
50    pub fn app(&self) -> f32 {
51        app_scale(self.level)
52    }
53}
54
55impl Default for Zoom {
56    fn default() -> Self {
57        Self::DEFAULT
58    }
59}
60
61/// `1.2^level`, the application's share of the factor at a level.
62pub fn app_scale(level: i8) -> f32 {
63    ZOOM_STEP.powi(i32::from(clamp_level(level)))
64}
65
66/// A level kept inside the range the Renderer accepts.
67pub fn clamp_level(level: i8) -> i8 {
68    level.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
69}
70
71struct Subscriber {
72    id: u64,
73    notify: Arc<dyn Fn() + Send + Sync>,
74}
75
76thread_local! {
77    static CURRENT: Cell<Zoom> = const { Cell::new(Zoom::DEFAULT) };
78    /// A level the application asked for since the last batch, not yet written into one.
79    static PENDING_LEVEL: Cell<Option<i8>> = const { Cell::new(None) };
80    static SUBSCRIBERS: RefCell<Vec<Subscriber>> = const { RefCell::new(Vec::new()) };
81    static NEXT_SUBSCRIBER_ID: Cell<u64> = const { Cell::new(1) };
82}
83
84/// What the Renderer last reported. Available outside a component too.
85pub fn zoom() -> Zoom {
86    CURRENT.with(Cell::get)
87}
88
89/// Records the Renderer's report and wakes the components that read it.
90///
91/// Returns whether any component was woken. A report nobody reads is still recorded, so a
92/// component that mounts later reads it, but nothing is redrawn for it.
93pub(crate) fn publish(zoom: Zoom) -> bool {
94    if CURRENT.with(Cell::get) == zoom {
95        return false;
96    }
97    CURRENT.with(|current| current.set(zoom));
98    SUBSCRIBERS.with_borrow(|subscribers| {
99        for subscriber in subscribers {
100            (subscriber.notify)();
101        }
102        !subscribers.is_empty()
103    })
104}
105
106/// Asks the Renderer to draw at `level`. It goes out on the window record in the batch the
107/// current call produces, and the Renderer answers with a new report.
108pub fn set_zoom_level(level: i8) {
109    PENDING_LEVEL.with(|pending| pending.set(Some(clamp_level(level))));
110}
111
112/// The level asked for since the last batch, if any.
113pub(crate) fn take_pending_level() -> Option<i8> {
114    PENDING_LEVEL.with(Cell::take)
115}
116
117/// Clears the report, anything asked for, and every subscription. Used when a Host is made
118/// and between tests.
119#[doc(hidden)]
120pub fn reset_zoom() {
121    CURRENT.with(|current| current.set(Zoom::DEFAULT));
122    PENDING_LEVEL.with(|pending| pending.set(None));
123    SUBSCRIBERS.with_borrow_mut(Vec::clear);
124}
125
126/// A registration for changes to the report. Dropping it ends the registration.
127pub struct ZoomSubscription {
128    id: u64,
129}
130
131/// Registers `notify` to be called each time the Renderer reports a different zoom.
132pub fn subscribe(notify: Arc<dyn Fn() + Send + Sync>) -> ZoomSubscription {
133    let id = NEXT_SUBSCRIBER_ID.with(|next| {
134        let id = next.get();
135        next.set(id + 1);
136        id
137    });
138    SUBSCRIBERS.with_borrow_mut(|subscribers| subscribers.push(Subscriber { id, notify }));
139    ZoomSubscription { id }
140}
141
142impl Drop for ZoomSubscription {
143    fn drop(&mut self) {
144        SUBSCRIBERS.with_borrow_mut(|subscribers| {
145            subscribers.retain(|subscriber| subscriber.id != self.id);
146        });
147    }
148}
149
150/// A handle on the zoom. `Copy`, so it can move into any number of handlers.
151#[derive(Clone, Copy, Debug, Default)]
152pub struct ZoomHandle {
153    _private: (),
154}
155
156impl ZoomHandle {
157    /// What the Renderer last reported.
158    pub fn get(&self) -> Zoom {
159        zoom()
160    }
161
162    /// Sets the application zoom level, kept inside its range.
163    pub fn set_level(&self, level: i8) {
164        set_zoom_level(level);
165    }
166
167    /// One step larger, as a "Zoom In" menu item does.
168    pub fn zoom_in(&self) {
169        set_zoom_level(self.current_level().saturating_add(1));
170    }
171
172    /// One step smaller.
173    pub fn zoom_out(&self) {
174        set_zoom_level(self.current_level().saturating_sub(1));
175    }
176
177    /// Back to the system's own size.
178    pub fn reset(&self) {
179        set_zoom_level(0);
180    }
181
182    /// The level asked for in this call, or else the one last reported, so two steps in
183    /// one handler are two steps.
184    fn current_level(&self) -> i8 {
185        PENDING_LEVEL
186            .with(Cell::get)
187            .unwrap_or_else(|| zoom().level)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn fr43_app_scale_is_one_point_two_to_the_level() {
197        assert_eq!(app_scale(0), 1.0);
198        assert!((app_scale(2) - 1.44).abs() < 1e-6);
199        assert!((app_scale(-1) - 1.0 / 1.2).abs() < 1e-6);
200        assert_eq!(app_scale(20), app_scale(MAX_ZOOM_LEVEL));
201    }
202
203    #[test]
204    fn fr43_two_steps_in_one_handler_are_two_steps() {
205        reset_zoom();
206        let handle = ZoomHandle::default();
207        handle.zoom_in();
208        handle.zoom_in();
209        assert_eq!(take_pending_level(), Some(2));
210        assert_eq!(take_pending_level(), None);
211        handle.set_level(40);
212        assert_eq!(take_pending_level(), Some(MAX_ZOOM_LEVEL));
213        reset_zoom();
214    }
215
216    #[test]
217    fn fr43_a_report_wakes_only_on_change() {
218        reset_zoom();
219        let woken = Arc::new(std::sync::atomic::AtomicUsize::new(0));
220        let counter = woken.clone();
221        let _subscription = subscribe(Arc::new(move || {
222            counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
223        }));
224        let report = Zoom {
225            k: 1.8,
226            os: 1.25,
227            level: 2,
228        };
229        assert!(publish(report));
230        assert!(!publish(report), "the same report twice is one change");
231        assert_eq!(woken.load(std::sync::atomic::Ordering::SeqCst), 1);
232        assert_eq!(zoom(), report);
233        reset_zoom();
234    }
235}