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}