Skip to main content

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