Skip to main content

truce_gui/
backend_cpu.rs

1//! CPU rendering backend using tiny-skia.
2//!
3//! Renders to an in-memory RGBA pixel buffer (premultiplied alpha,
4//! row-major) sized at `logical × scale` physical pixels. Callers
5//! draw in **logical points**; the backend multiplies input
6//! coordinates by `scale` internally, matching the contract of
7//! [`WgpuBackend`](../../truce_gpu/struct.WgpuBackend.html) so text
8//! and primitives stay sharp on Retina displays.
9
10use tiny_skia::{Paint, PathBuilder, Pixmap, PixmapPaint, Stroke, Transform};
11use truce_core::cast::len_u32;
12
13use crate::ColorExt;
14use truce_gui_types::render::{ImageId, RenderBackend};
15use truce_gui_types::theme::Color;
16
17/// CPU-based rendering backend.
18///
19/// Wraps a tiny-skia `Pixmap` and implements `RenderBackend` using
20/// software rasterization. Zero GPU dependencies.
21pub struct CpuBackend {
22    pixmap: Pixmap,
23    /// Display scale factor: `logical × scale = physical`. Applied
24    /// inside every `RenderBackend` method so callers author in
25    /// logical points.
26    scale: f32,
27    /// Registered images. Index = ImageId.0. None = unregistered slot.
28    images: Vec<Option<Pixmap>>,
29}
30
31impl CpuBackend {
32    /// Create a new CPU backend.
33    ///
34    /// `logical_w` / `logical_h` are in logical points; `scale` is the
35    /// display scale factor (2.0 on Retina, 1.0 otherwise). The
36    /// internal pixmap is sized at `logical × scale` physical pixels.
37    #[must_use]
38    pub fn new(logical_w: u32, logical_h: u32, scale: f32) -> Option<Self> {
39        let scale = scale.max(0.0);
40        let phys_w = crate::platform::to_physical_px(logical_w, f64::from(scale));
41        let phys_h = crate::platform::to_physical_px(logical_h, f64::from(scale));
42        Pixmap::new(phys_w, phys_h).map(|pixmap| Self {
43            pixmap,
44            scale,
45            images: Vec::new(),
46        })
47    }
48
49    /// Reallocate the internal pixmap for a new logical size and/or
50    /// scale factor. Call from a host-reported resize / DPI-change
51    /// handler; a no-op if the resulting physical dimensions match
52    /// the current pixmap.
53    pub fn resize(&mut self, logical_w: u32, logical_h: u32, scale: f32) -> bool {
54        let scale = scale.max(0.0);
55        let phys_w = crate::platform::to_physical_px(logical_w, f64::from(scale));
56        let phys_h = crate::platform::to_physical_px(logical_h, f64::from(scale));
57        if phys_w == self.pixmap.width() && phys_h == self.pixmap.height() {
58            self.scale = scale;
59            return false;
60        }
61        match Pixmap::new(phys_w, phys_h) {
62            Some(pm) => {
63                self.pixmap = pm;
64                self.scale = scale;
65                true
66            }
67            None => false,
68        }
69    }
70
71    /// Raw pixel data (RGBA premultiplied, row-major, physical pixels).
72    #[must_use]
73    pub fn data(&self) -> &[u8] {
74        self.pixmap.data()
75    }
76
77    /// Pixel buffer width (physical pixels).
78    #[must_use]
79    pub fn width(&self) -> u32 {
80        self.pixmap.width()
81    }
82
83    /// Pixel buffer height (physical pixels).
84    #[must_use]
85    pub fn height(&self) -> u32 {
86        self.pixmap.height()
87    }
88
89    /// Display scale factor baked at construction.
90    #[must_use]
91    pub fn scale(&self) -> f32 {
92        self.scale
93    }
94}
95
96/// All `RenderBackend` methods accept coordinates in **logical
97/// points**. The backend multiplies by `self.scale` before handing
98/// off to tiny-skia, so the pixmap is rasterized at physical-pixel
99/// density.
100// Rasterizer math uses standard short names (`x`, `y`, `w`, `h`,
101// `s` = scale, etc.).
102#[allow(clippy::many_single_char_names)]
103impl RenderBackend for CpuBackend {
104    fn clear(&mut self, color: Color) {
105        self.pixmap.fill(color.to_skia());
106    }
107
108    fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) {
109        let s = self.scale;
110        let Some(rect) = tiny_skia::Rect::from_xywh(x * s, y * s, w * s, h * s) else {
111            return;
112        };
113        let mut paint = Paint::default();
114        paint.set_color(color.to_skia());
115        paint.anti_alias = true;
116        self.pixmap
117            .fill_rect(rect, &paint, Transform::identity(), None);
118    }
119
120    fn fill_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Color) {
121        let s = self.scale;
122        let mut pb = PathBuilder::new();
123        pb.push_circle(cx * s, cy * s, radius * s);
124        let Some(path) = pb.finish() else {
125            return;
126        };
127        let mut paint = Paint::default();
128        paint.set_color(color.to_skia());
129        paint.anti_alias = true;
130        self.pixmap.fill_path(
131            &path,
132            &paint,
133            tiny_skia::FillRule::Winding,
134            Transform::identity(),
135            None,
136        );
137    }
138
139    fn stroke_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Color, width: f32) {
140        let s = self.scale;
141        let mut pb = PathBuilder::new();
142        pb.push_circle(cx * s, cy * s, radius * s);
143        let Some(path) = pb.finish() else {
144            return;
145        };
146        let mut paint = Paint::default();
147        paint.set_color(color.to_skia());
148        paint.anti_alias = true;
149        let stroke = Stroke {
150            width: width * s,
151            ..Stroke::default()
152        };
153        self.pixmap
154            .stroke_path(&path, &paint, &stroke, Transform::identity(), None);
155    }
156
157    #[allow(clippy::cast_precision_loss)]
158    fn stroke_arc(
159        &mut self,
160        cx: f32,
161        cy: f32,
162        radius: f32,
163        start_angle: f32,
164        end_angle: f32,
165        color: Color,
166        width: f32,
167    ) {
168        let s = self.scale;
169        let segments = 64;
170        let mut pb = PathBuilder::new();
171        let angle_range = end_angle - start_angle;
172        let step = angle_range / segments as f32;
173
174        for i in 0..=segments {
175            let angle = start_angle + step * i as f32;
176            let x = cx * s + radius * s * angle.cos();
177            let y = cy * s + radius * s * angle.sin();
178            if i == 0 {
179                pb.move_to(x, y);
180            } else {
181                pb.line_to(x, y);
182            }
183        }
184
185        let Some(path) = pb.finish() else {
186            return;
187        };
188        let mut paint = Paint::default();
189        paint.set_color(color.to_skia());
190        paint.anti_alias = true;
191        let stroke = Stroke {
192            width: width * s,
193            line_cap: tiny_skia::LineCap::Round,
194            ..Stroke::default()
195        };
196        self.pixmap
197            .stroke_path(&path, &paint, &stroke, Transform::identity(), None);
198    }
199
200    fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, color: Color, width: f32) {
201        let s = self.scale;
202        let mut pb = PathBuilder::new();
203        pb.move_to(x1 * s, y1 * s);
204        pb.line_to(x2 * s, y2 * s);
205        let Some(path) = pb.finish() else {
206            return;
207        };
208        let mut paint = Paint::default();
209        paint.set_color(color.to_skia());
210        paint.anti_alias = true;
211        let stroke = Stroke {
212            width: width * s,
213            line_cap: tiny_skia::LineCap::Round,
214            ..Stroke::default()
215        };
216        self.pixmap
217            .stroke_path(&path, &paint, &stroke, Transform::identity(), None);
218    }
219
220    fn draw_text(&mut self, text: &str, x: f32, y: f32, size: f32, color: Color) {
221        let s = self.scale;
222        let w = self.pixmap.width();
223        let h = self.pixmap.height();
224        crate::font::draw_text_fontdue(
225            self.pixmap.data_mut(),
226            w,
227            h,
228            text,
229            x * s,
230            y * s,
231            size * s,
232            color.r,
233            color.g,
234            color.b,
235            color.a,
236        );
237    }
238
239    fn text_width(&self, text: &str, size: f32) -> f32 {
240        let s = self.scale;
241        crate::font::text_width_fontdue(text, size * s) / s
242    }
243
244    fn register_image(&mut self, rgba: &[u8], width: u32, height: u32) -> ImageId {
245        let Some(mut pm) = Pixmap::new(width, height) else {
246            return ImageId::INVALID;
247        };
248        let expected = (width as usize) * (height as usize) * 4;
249        if rgba.len() < expected {
250            return ImageId::INVALID;
251        }
252        pm.data_mut()[..expected].copy_from_slice(&rgba[..expected]);
253
254        if let Some(slot) = self
255            .images
256            .iter_mut()
257            .enumerate()
258            .find(|(_, s)| s.is_none())
259        {
260            *slot.1 = Some(pm);
261            return ImageId(len_u32(slot.0));
262        }
263        let id = len_u32(self.images.len());
264        self.images.push(Some(pm));
265        ImageId(id)
266    }
267
268    fn unregister_image(&mut self, id: ImageId) {
269        if let Some(slot) = self.images.get_mut(id.0 as usize) {
270            *slot = None;
271        }
272    }
273
274    // `u32 as f32` for image dimensions; image sizes are bounded by
275    // editor pixel dimensions, well below 2^23.
276    #[allow(clippy::cast_precision_loss)]
277    fn draw_image(&mut self, id: ImageId, x: f32, y: f32, w: f32, h: f32) {
278        let s = self.scale;
279        let Some(pm) = self.images.get(id.0 as usize).and_then(|s| s.as_ref()) else {
280            return;
281        };
282        let sx = (w * s) / pm.width() as f32;
283        let sy = (h * s) / pm.height() as f32;
284        let transform = Transform::from_scale(sx, sy).post_translate(x * s, y * s);
285        let paint = PixmapPaint::default();
286        self.pixmap
287            .draw_pixmap(0, 0, pm.as_ref(), &paint, transform, None);
288    }
289}