Skip to main content

rlvgl_platform/
screen.rs

1// SPDX-License-Identifier: MIT
2//! Screen abstraction: logical dimensions + physical scan rotation.
3//!
4//! A [`Screen`] is the single source of truth for a display's geometry.
5//! Its `width` and `height` are the **logical** size — the coordinate space
6//! the application draws into and the simulator window reflects. The
7//! `rotation` field is a scan-direction hint consumed **only** by the
8//! renderer, display driver, compositor, and input device: it tells them
9//! how the logical space maps onto the physical framebuffer.
10//!
11//! Applications never read `rotation`. They just ask for `width`/`height`
12//! (or use the [`Screen::logical_size`] helper) and trust the platform to
13//! put the pixels in the right place.
14//!
15//! # Example
16//!
17//! A simulator running a native 800×480 window:
18//!
19//! ```
20//! use rlvgl_platform::screen::{Rotation, Screen};
21//! let screen = Screen::landscape(800, 480);
22//! assert_eq!(screen.logical_size(), (800, 480));
23//! assert_eq!(screen.physical_size(), (800, 480));
24//! assert_eq!(screen.rotation, Rotation::Deg0);
25//! ```
26//!
27//! A 480×800 portrait LTDC framebuffer presenting an 800×480 landscape
28//! view to the application:
29//!
30//! ```
31//! use rlvgl_platform::screen::{Rotation, Screen};
32//! let screen = Screen::new(800, 480, Rotation::Deg90);
33//! assert_eq!(screen.logical_size(), (800, 480));
34//! assert_eq!(screen.physical_size(), (480, 800));
35//! ```
36
37/// Scan-direction rotation applied between logical draw coordinates and
38/// the physical framebuffer.
39#[derive(Copy, Clone, Debug, PartialEq, Eq)]
40pub enum Rotation {
41    /// No rotation: logical coordinates match physical coordinates.
42    Deg0,
43    /// Logical drawing is rotated 90° clockwise into the framebuffer.
44    ///
45    /// Used when the physical display scans in portrait but the
46    /// application draws in landscape.
47    Deg90,
48    /// Logical drawing is rotated 180° (upside-down).
49    Deg180,
50    /// Logical drawing is rotated 270° clockwise (equivalently 90°
51    /// counter-clockwise).
52    Deg270,
53}
54
55impl Rotation {
56    /// Returns `true` if this rotation swaps the framebuffer axes.
57    #[inline]
58    pub const fn swaps_axes(self) -> bool {
59        matches!(self, Rotation::Deg90 | Rotation::Deg270)
60    }
61}
62
63/// Color resolution of the physical display.
64///
65/// The CPU framebuffer used by `rlvgl` is always 32-bit ARGB internally,
66/// but the **target** display panel may have lower bit depth. This enum
67/// describes the physical panel format so the simulator can apply the
68/// same quantization to its preview window — banding, dithering, and
69/// reduced colour space artefacts that the application would actually
70/// see on the target hardware become visible on the host.
71///
72/// Hardware drivers ignore this field; their `flush` already targets
73/// the panel's native format. The field exists so simulators can
74/// **simulate** the target colour space when previewing apps that will
75/// ship to a lower-depth panel.
76#[derive(Copy, Clone, Debug, PartialEq, Eq)]
77pub enum ColorFormat {
78    /// True-colour 32-bit ARGB. No quantization.
79    Argb8888,
80    /// True-colour 24-bit RGB. Discards alpha.
81    Rgb888,
82    /// 16-bit RGB565. 5 bits red, 6 bits green, 5 bits blue.
83    Rgb565,
84    /// 12-bit RGB444. 4 bits per channel — common on small TFTs.
85    Rgb444,
86    /// 8-bit greyscale.
87    L8,
88    /// 1-bit monochrome (e.g. e-paper).
89    Mono,
90}
91
92impl ColorFormat {
93    /// Quantize a 24-bit `(r, g, b)` triple to this color format and
94    /// return it back in 24-bit `(r, g, b)` so callers can display the
95    /// reduced precision in an 8-bit framebuffer.
96    ///
97    /// For [`ColorFormat::Argb8888`] this is a pass-through.
98    pub const fn quantize(&self, r: u8, g: u8, b: u8) -> (u8, u8, u8) {
99        match self {
100            ColorFormat::Argb8888 | ColorFormat::Rgb888 => (r, g, b),
101            ColorFormat::Rgb565 => {
102                // 5/6/5: keep top 5/6/5 bits then replicate to fill the
103                // low bits so the resulting 8-bit value matches what a
104                // panel would actually display.
105                let r5 = r & 0xF8;
106                let g6 = g & 0xFC;
107                let b5 = b & 0xF8;
108                (r5 | (r5 >> 5), g6 | (g6 >> 6), b5 | (b5 >> 5))
109            }
110            ColorFormat::Rgb444 => {
111                let r4 = r & 0xF0;
112                let g4 = g & 0xF0;
113                let b4 = b & 0xF0;
114                (r4 | (r4 >> 4), g4 | (g4 >> 4), b4 | (b4 >> 4))
115            }
116            ColorFormat::L8 => {
117                // ITU-R BT.601 luma weights, scaled by 1000 to stay in
118                // integer arithmetic. Max product: 255*587 = 149,685 → u32.
119                let l = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
120                (l, l, l)
121            }
122            ColorFormat::Mono => {
123                let l = (r as u16 + g as u16 + b as u16) / 3;
124                let v = if l >= 128 { 255 } else { 0 };
125                (v, v, v)
126            }
127        }
128    }
129}
130
131/// Default display refresh rate used by [`Screen::new`] when the
132/// caller does not explicitly pick one via [`Screen::with_frame_hz`].
133///
134/// 60 Hz is the most common host refresh rate and the default the
135/// simulator used before `frame_hz` became a screen property; pinning
136/// the default here keeps every existing caller's behaviour identical.
137pub const DEFAULT_FRAME_HZ: u32 = 60;
138
139/// Logical display geometry plus the scan rotation used to reach the
140/// physical framebuffer.
141#[derive(Copy, Clone, Debug, PartialEq, Eq)]
142pub struct Screen {
143    /// Logical width in pixels (the coordinate space the app draws into).
144    pub width: u32,
145    /// Logical height in pixels.
146    pub height: u32,
147    /// Physical scan rotation from logical space to the framebuffer.
148    pub rotation: Rotation,
149    /// Native colour format of the physical display panel.
150    ///
151    /// Application rendering always happens in 32-bit ARGB internally;
152    /// this field tells the simulator how to **quantize** its preview to
153    /// match the target panel so artefacts like RGB565 banding are
154    /// visible on the host. Hardware drivers ignore the field — their
155    /// own `flush` already targets the panel's native format.
156    pub color_format: ColorFormat,
157    /// Target display refresh rate in Hz.
158    ///
159    /// Declared by the target project and consumed by every timing
160    /// loop that wants to match the hardware cadence: the simulator's
161    /// frame sleep, the gesture recognisers' tap / double-tap
162    /// timeouts, and motion engines like
163    /// [`rlvgl_widgets::motion::crawl::StarCrawl`](../../rlvgl_widgets/motion/crawl/type.StarCrawl.html)
164    /// that need `pixels_per_sec / frame_hz` to turn into the right
165    /// Q8 scroll-advance value.
166    ///
167    /// Hardware drivers may still read this to program SysTick; the
168    /// field is purely advisory for drivers whose own cadence comes
169    /// from a real display controller.
170    pub frame_hz: u32,
171}
172
173impl Screen {
174    /// Create a screen with an explicit rotation. Defaults to true-colour
175    /// [`ColorFormat::Argb8888`] and [`DEFAULT_FRAME_HZ`] (60 Hz); use
176    /// [`Self::with_color_format`] and [`Self::with_frame_hz`] to opt
177    /// into a lower-depth simulation or a different refresh rate.
178    #[inline]
179    pub const fn new(width: u32, height: u32, rotation: Rotation) -> Self {
180        Self {
181            width,
182            height,
183            rotation,
184            color_format: ColorFormat::Argb8888,
185            frame_hz: DEFAULT_FRAME_HZ,
186        }
187    }
188
189    /// Create an unrotated landscape screen (`Rotation::Deg0`,
190    /// 32-bit ARGB, 60 Hz).
191    #[inline]
192    pub const fn landscape(width: u32, height: u32) -> Self {
193        Self::new(width, height, Rotation::Deg0)
194    }
195
196    /// Return a copy of this screen with a different colour format.
197    ///
198    /// Use this to declare that the target panel is, for example,
199    /// RGB565 — the simulator will then quantize its preview window
200    /// through the same colour format so banding artefacts become
201    /// visible.
202    #[inline]
203    pub const fn with_color_format(self, color_format: ColorFormat) -> Self {
204        Self {
205            color_format,
206            ..self
207        }
208    }
209
210    /// Return a copy of this screen with a different target refresh
211    /// rate. `frame_hz = 0` is clamped to 1 to keep divide-by-zero
212    /// guards further down the stack trivial.
213    #[inline]
214    pub const fn with_frame_hz(self, frame_hz: u32) -> Self {
215        let frame_hz = if frame_hz == 0 { 1 } else { frame_hz };
216        Self { frame_hz, ..self }
217    }
218
219    /// Logical size in the application's coordinate space.
220    #[inline]
221    pub const fn logical_size(&self) -> (u32, u32) {
222        (self.width, self.height)
223    }
224
225    /// Physical framebuffer dimensions. Axes are swapped when the
226    /// rotation is 90° or 270°.
227    #[inline]
228    pub const fn physical_size(&self) -> (u32, u32) {
229        if self.rotation.swaps_axes() {
230            (self.height, self.width)
231        } else {
232            (self.width, self.height)
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn landscape_is_deg0() {
243        let s = Screen::landscape(800, 480);
244        assert_eq!(s.rotation, Rotation::Deg0);
245        assert_eq!(s.logical_size(), (800, 480));
246        assert_eq!(s.physical_size(), (800, 480));
247    }
248
249    #[test]
250    fn deg90_swaps_physical_axes() {
251        let s = Screen::new(800, 480, Rotation::Deg90);
252        assert_eq!(s.logical_size(), (800, 480));
253        assert_eq!(s.physical_size(), (480, 800));
254    }
255
256    #[test]
257    fn deg180_preserves_axes() {
258        let s = Screen::new(800, 480, Rotation::Deg180);
259        assert_eq!(s.logical_size(), s.physical_size());
260    }
261
262    #[test]
263    fn deg270_swaps_physical_axes() {
264        let s = Screen::new(320, 240, Rotation::Deg270);
265        assert_eq!(s.physical_size(), (240, 320));
266    }
267
268    #[test]
269    fn swaps_axes_matches_rotation() {
270        assert!(!Rotation::Deg0.swaps_axes());
271        assert!(Rotation::Deg90.swaps_axes());
272        assert!(!Rotation::Deg180.swaps_axes());
273        assert!(Rotation::Deg270.swaps_axes());
274    }
275
276    #[test]
277    fn default_color_format_is_argb8888() {
278        let s = Screen::landscape(800, 480);
279        assert_eq!(s.color_format, ColorFormat::Argb8888);
280    }
281
282    #[test]
283    fn with_color_format_overrides_only_color() {
284        let s = Screen::new(480, 320, Rotation::Deg90).with_color_format(ColorFormat::Rgb565);
285        assert_eq!(s.color_format, ColorFormat::Rgb565);
286        assert_eq!(s.rotation, Rotation::Deg90);
287        assert_eq!(s.logical_size(), (480, 320));
288    }
289
290    #[test]
291    fn argb8888_quantize_is_passthrough() {
292        let (r, g, b) = ColorFormat::Argb8888.quantize(0x12, 0x34, 0x56);
293        assert_eq!((r, g, b), (0x12, 0x34, 0x56));
294    }
295
296    #[test]
297    fn rgb565_quantize_drops_low_bits_then_replicates() {
298        // 0x12 = 00010010 → top 5 bits 00010 → expand to 00010_000 = 0x10
299        // → replicate top 3 into low: 0x10 | (0x10 >> 5) = 0x10 | 0 = 0x10
300        // 0x34 = 00110100 → top 6 bits 001101 → expand to 00110100 = 0x34
301        // → replicate top 2 into low: 0x34 | (0x34 >> 6) = 0x34 | 0 = 0x34
302        // 0x56 = 01010110 → top 5 bits 01010 → expand to 0x50
303        // → 0x50 | (0x50 >> 5) = 0x50 | 0x02 = 0x52
304        let (r, g, b) = ColorFormat::Rgb565.quantize(0x12, 0x34, 0x56);
305        assert_eq!(r & 0x07, r >> 5, "low 3 bits should mirror top 3");
306        assert_eq!(g & 0x03, g >> 6, "low 2 bits should mirror top 2");
307        assert_eq!(b & 0x07, b >> 5, "low 3 bits should mirror top 3");
308        // Pure white stays white.
309        assert_eq!(ColorFormat::Rgb565.quantize(255, 255, 255), (255, 255, 255));
310        // Pure black stays black.
311        assert_eq!(ColorFormat::Rgb565.quantize(0, 0, 0), (0, 0, 0));
312    }
313
314    #[test]
315    fn rgb444_quantize_uses_4_bits_per_channel() {
316        // Pure colours should round-trip cleanly.
317        assert_eq!(
318            ColorFormat::Rgb444.quantize(0xFF, 0xFF, 0xFF),
319            (0xFF, 0xFF, 0xFF)
320        );
321        assert_eq!(
322            ColorFormat::Rgb444.quantize(0x00, 0x00, 0x00),
323            (0x00, 0x00, 0x00)
324        );
325        // 0x12 = 00010010 → top 4 = 0x10 → 0x10 | 0x01 = 0x11
326        let (r, _, _) = ColorFormat::Rgb444.quantize(0x12, 0, 0);
327        assert_eq!(r, 0x11);
328    }
329
330    #[test]
331    fn l8_quantize_collapses_to_grey() {
332        let (r, g, b) = ColorFormat::L8.quantize(255, 0, 0);
333        assert_eq!(r, g);
334        assert_eq!(g, b);
335        // Red weighted 0.299 → ~76
336        assert!((75..=77).contains(&r));
337    }
338
339    #[test]
340    fn mono_quantize_thresholds_at_half() {
341        assert_eq!(ColorFormat::Mono.quantize(255, 255, 255), (255, 255, 255));
342        assert_eq!(ColorFormat::Mono.quantize(0, 0, 0), (0, 0, 0));
343        assert_eq!(ColorFormat::Mono.quantize(120, 120, 120), (0, 0, 0));
344        assert_eq!(ColorFormat::Mono.quantize(140, 140, 140), (255, 255, 255));
345    }
346}