Skip to main content

oxiui_render_soft/
backend.rs

1//! [`SoftBackend`]: a [`oxiui_core::paint::RenderBackend`] implementation that replays a
2//! [`oxiui_core::paint::DrawList`] onto a CPU [`Framebuffer`] through a single [`Canvas`].
3//!
4//! Because all commands share one [`Canvas`] for the entire
5//! [`oxiui_core::paint::RenderBackend::execute`] call,
6//! `PushClip`/`PopClip` commands are correctly applied to every subsequent
7//! draw command — there is no clip-leak between commands.
8
9use oxiui_core::geometry::Size;
10use oxiui_core::paint::{DrawCommand, DrawList, ImageFilter, RenderBackend};
11use oxiui_core::{Color, UiError};
12
13use crate::clip::{ClipRect, ClipStack};
14use crate::draw::{Canvas, DashPattern, SrcImage};
15use crate::framebuffer::Framebuffer;
16use crate::shadow::GaussianCache;
17use crate::{AaMode, ShadowQuality, SoftRenderQuality};
18
19// ---------------------------------------------------------------------------
20// SoftBackend
21// ---------------------------------------------------------------------------
22
23/// CPU framebuffer backend implementing [`oxiui_core::paint::RenderBackend`].
24///
25/// Replays a [`oxiui_core::paint::DrawList`] onto a [`Framebuffer`] via a [`Canvas`]
26/// (clip-correct). All commands share one clip stack for the duration of
27/// the [`oxiui_core::paint::RenderBackend::execute`] call.
28///
29/// When the `text` feature is enabled, a cached `oxiui_text::TextPipeline`
30/// is held on the backend so that `DrawText` commands are fully shaped and
31/// rasterised rather than silently ignored.
32pub struct SoftBackend {
33    fb: Framebuffer,
34    shadow_cache: GaussianCache,
35    /// Quality preset controlling AA mode and shadow rendering strategy.
36    quality: SoftRenderQuality,
37    /// Optional cached text pipeline.  `None` when the `text` feature is
38    /// disabled or when the embedded font fails to parse (graceful degradation).
39    #[cfg(feature = "text")]
40    text_pipeline: Option<oxiui_text::TextPipeline>,
41}
42
43impl SoftBackend {
44    /// Create a backend with a transparent framebuffer.
45    pub fn new(width: u32, height: u32) -> Self {
46        Self {
47            fb: Framebuffer::new(width, height),
48            shadow_cache: GaussianCache::default(),
49            quality: SoftRenderQuality::balanced(),
50            #[cfg(feature = "text")]
51            text_pipeline: Self::init_text_pipeline(),
52        }
53    }
54
55    /// Create a backend pre-filled with a solid background colour.
56    pub fn with_background(width: u32, height: u32, bg: Color) -> Self {
57        Self {
58            fb: Framebuffer::with_fill(width, height, bg),
59            shadow_cache: GaussianCache::default(),
60            quality: SoftRenderQuality::balanced(),
61            #[cfg(feature = "text")]
62            text_pipeline: Self::init_text_pipeline(),
63        }
64    }
65
66    /// Initialise the embedded-font text pipeline.
67    ///
68    /// Returns `None` (graceful degradation) if the font fails to parse,
69    /// rather than panicking.
70    #[cfg(feature = "text")]
71    fn init_text_pipeline() -> Option<oxiui_text::TextPipeline> {
72        // Fallback font bundled with this crate (assets/fallback-font.ttf).
73        // Kept inside the crate directory so `cargo publish` packaging works.
74        const FONT_BYTES: &[u8] = include_bytes!("../assets/fallback-font.ttf");
75        oxiui_text::TextPipeline::from_bytes(FONT_BYTES).ok()
76    }
77
78    /// Set the quality preset, influencing AA mode and shadow strategy.
79    pub fn set_quality(&mut self, quality: SoftRenderQuality) {
80        self.quality = quality;
81    }
82
83    /// Return the current quality preset.
84    pub fn quality(&self) -> &SoftRenderQuality {
85        &self.quality
86    }
87
88    /// Return `true` if the current quality preset enables anti-aliasing.
89    fn aa_enabled(&self) -> bool {
90        !matches!(self.quality.aa_mode, AaMode::None)
91    }
92
93    /// Return `true` if shadow rendering is enabled at any quality level.
94    fn shadow_enabled(&self) -> bool {
95        !matches!(self.quality.shadow_quality, ShadowQuality::Off)
96    }
97
98    /// Borrow the underlying framebuffer.
99    pub fn frame(&self) -> &Framebuffer {
100        &self.fb
101    }
102
103    /// Consume the backend, returning the underlying framebuffer.
104    pub fn into_framebuffer(self) -> Framebuffer {
105        self.fb
106    }
107
108    /// Fill the entire framebuffer with `color`.
109    pub fn clear(&mut self, color: Color) {
110        self.fb.clear(color);
111    }
112
113    /// Apply a [`oxiui_theme::ShadowSpec`] to the framebuffer at the given rect.
114    ///
115    /// Bridges the theme-level shadow description into the existing Gaussian-blur
116    /// shadow infrastructure in [`crate::shadow`].  The rect is inflated by
117    /// `spec.spread` before blurring, matching CSS box-shadow behaviour.
118    ///
119    /// `inset` shadows are currently rendered the same as drop shadows
120    /// (the inset flag is noted but not yet composited differently — a follow-up
121    /// can apply source-atop compositing for true inset support).
122    ///
123    /// # Parameters
124    /// * `rect` — bounding rectangle `(x, y, width, height)` of the widget.
125    /// * `spec` — the shadow specification from the theme.
126    #[cfg(feature = "theme")]
127    pub fn apply_shadow_spec(
128        &mut self,
129        rect: (f32, f32, f32, f32),
130        spec: &oxiui_theme::ShadowSpec,
131    ) {
132        use crate::shadow::box_shadow;
133
134        // Inflate rect by spread radius.
135        let (rx, ry, rw, rh) = rect;
136        let spread = spec.spread;
137        let inflated = (
138            rx - spread,
139            ry - spread,
140            rw + spread * 2.0,
141            rh + spread * 2.0,
142        );
143
144        box_shadow(
145            &mut self.fb,
146            inflated,
147            spec.offset_x,
148            spec.offset_y,
149            spec.blur,
150            spec.color,
151            &mut self.shadow_cache,
152        );
153    }
154
155    /// Return the framebuffer width in pixels.
156    pub fn width(&self) -> u32 {
157        self.fb.width()
158    }
159
160    /// Return the framebuffer height in pixels.
161    pub fn height(&self) -> u32 {
162        self.fb.height()
163    }
164
165    /// Export the framebuffer as a flat byte vector in the requested pixel format.
166    ///
167    /// The native internal format is `0xAARRGGBB` (one `u32` per pixel).
168    /// This method reinterprets and reorders channels for the target format.
169    ///
170    /// | Format | Bytes/pixel | Channel order |
171    /// |--------|-------------|---------------|
172    /// | `Argb32` | 4 | A, R, G, B |
173    /// | `Bgra8`  | 4 | B, G, R, A |
174    /// | `Rgb565` | 2 | 5R·6G·5B, big-endian |
175    pub fn to_bytes(&self, format: crate::headless::PixelFormat) -> Vec<u8> {
176        use crate::framebuffer::unpack;
177        use crate::headless::PixelFormat;
178        match format {
179            PixelFormat::Argb32 => {
180                // 4 bytes per pixel: A, R, G, B (unpack 0xAARRGGBB)
181                self.fb
182                    .pixels()
183                    .iter()
184                    .flat_map(|&p| {
185                        let (r, g, b, a) = unpack(p);
186                        [a, r, g, b]
187                    })
188                    .collect()
189            }
190            PixelFormat::Bgra8 => {
191                // 4 bytes per pixel: B, G, R, A
192                self.fb
193                    .pixels()
194                    .iter()
195                    .flat_map(|&p| {
196                        let (r, g, b, a) = unpack(p);
197                        [b, g, r, a]
198                    })
199                    .collect()
200            }
201            PixelFormat::Rgb565 => {
202                // 2 bytes per pixel: 5 red + 6 green + 5 blue, big-endian
203                self.fb
204                    .pixels()
205                    .iter()
206                    .flat_map(|&p| {
207                        let (r, g, b, _a) = unpack(p);
208                        let r5 = (r as u16) >> 3;
209                        let g6 = (g as u16) >> 2;
210                        let b5 = (b as u16) >> 3;
211                        let rgb565: u16 = (r5 << 11) | (g6 << 5) | b5;
212                        [(rgb565 >> 8) as u8, (rgb565 & 0xFF) as u8]
213                    })
214                    .collect()
215            }
216        }
217    }
218}
219
220impl RenderBackend for SoftBackend {
221    fn surface_size(&self) -> Size {
222        Size::new(self.fb.width() as f32, self.fb.height() as f32)
223    }
224
225    fn supports_blur(&self) -> bool {
226        true
227    }
228    fn supports_gradients(&self) -> bool {
229        true
230    }
231    fn supports_paths(&self) -> bool {
232        true
233    }
234    fn supports_images(&self) -> bool {
235        true
236    }
237
238    #[cfg(feature = "text")]
239    fn supports_text(&self) -> bool {
240        self.text_pipeline.is_some()
241    }
242
243    #[cfg(not(feature = "text"))]
244    fn supports_text(&self) -> bool {
245        false
246    }
247
248    fn execute(&mut self, list: &DrawList) -> Result<(), UiError> {
249        let aa = self.aa_enabled();
250        let shadow = self.shadow_enabled();
251        let fb_w = self.fb.width();
252        let fb_h = self.fb.height();
253
254        // We maintain a shadow clip stack so that when we break out of a Canvas
255        // scope to blit text directly onto self.fb, we know the effective clip
256        // rect.  The stack holds individual (unmerged) push rects so Canvas
257        // clip state can be replayed when we re-enter after a DrawText.
258        //
259        // `pending_clips` mirrors the Canvas clip stack: each push appends one
260        // entry and each pop removes the last entry.
261        let mut pending_clips: Vec<(f32, f32, f32, f32)> = Vec::new();
262
263        // Shadow clip stack for direct-fb text blitting: intersected region.
264        let mut shadow_clip = ClipStack::new(fb_w, fb_h);
265
266        // Iterator over the command list.
267        let mut iter = list.iter().peekable();
268
269        while iter.peek().is_some() {
270            // ── Phase A: drain non-DrawText commands through a Canvas ──────
271            {
272                let mut canvas = Canvas::new(&mut self.fb);
273                canvas.set_aa(aa);
274
275                // Re-apply accumulated clips to the fresh canvas.
276                for &(x, y, w, h) in &pending_clips {
277                    canvas.push_clip(x, y, w, h);
278                }
279
280                // Process commands until we hit a DrawText (or exhaust the list).
281                loop {
282                    let peek = iter.peek();
283                    let is_text = matches!(peek, Some(DrawCommand::DrawText { .. }));
284                    if peek.is_none() || is_text {
285                        break;
286                    }
287                    let cmd = iter.next().expect("peeked Some above");
288
289                    // Track clip stack for shadow_clip mirror.
290                    match cmd {
291                        DrawCommand::PushClip { rect } => {
292                            let r = (rect.left(), rect.top(), rect.width(), rect.height());
293                            pending_clips.push(r);
294                            shadow_clip.push(ClipRect::from_rect(
295                                rect.left().floor() as i64,
296                                rect.top().floor() as i64,
297                                rect.width().ceil() as i64,
298                                rect.height().ceil() as i64,
299                            ));
300                        }
301                        DrawCommand::PopClip => {
302                            pending_clips.pop();
303                            shadow_clip.pop();
304                        }
305                        _ => {}
306                    }
307
308                    dispatch_command(&mut canvas, &mut self.shadow_cache, cmd, shadow);
309                }
310                // Canvas is dropped here, releasing the &mut self.fb borrow.
311            }
312
313            // ── Phase B: process one DrawText with direct fb access ────────
314            #[cfg(feature = "text")]
315            if matches!(iter.peek(), Some(DrawCommand::DrawText { .. })) {
316                if let Some(DrawCommand::DrawText {
317                    text, font, color, ..
318                }) = iter.next()
319                {
320                    draw_text_to_fb(
321                        &mut self.fb,
322                        &mut self.text_pipeline,
323                        text,
324                        font,
325                        *color,
326                        shadow_clip.current(),
327                    );
328                }
329                continue;
330            }
331
332            // Without the text feature, consume DrawText silently.
333            #[cfg(not(feature = "text"))]
334            if matches!(iter.peek(), Some(DrawCommand::DrawText { .. })) {
335                iter.next();
336                continue;
337            }
338        }
339
340        Ok(())
341    }
342}
343
344// ---------------------------------------------------------------------------
345// Text rendering helper (feature-gated)
346// ---------------------------------------------------------------------------
347
348/// Shape and rasterise `text` into `fb` at the glyph positions reported by the
349/// pipeline, clipped to `clip`.
350///
351/// On pipeline error (e.g. missing glyph data) the error is silently ignored so
352/// that a single broken glyph does not abort the entire frame — consistent with
353/// `supports_text() == false` graceful degradation.
354#[cfg(feature = "text")]
355fn draw_text_to_fb(
356    fb: &mut Framebuffer,
357    pipeline: &mut Option<oxiui_text::TextPipeline>,
358    text: &str,
359    font: &oxiui_core::FontSpec,
360    color: Color,
361    clip: ClipRect,
362) {
363    let pipeline = match pipeline {
364        Some(p) => p,
365        None => return, // no font loaded; graceful no-op
366    };
367
368    if text.is_empty() {
369        return;
370    }
371
372    let style = oxiui_text::TextStyle::new(font.size);
373    let result = match pipeline.render(text, &style) {
374        Ok(r) => r,
375        Err(_) => return, // shaping error → silent skip
376    };
377
378    for (pg, bm) in result.glyphs.iter().zip(result.bitmaps.iter()) {
379        if bm.is_empty() {
380            continue;
381        }
382        let blit_x = pg.pos.0.round() as i32;
383        let blit_y = pg.pos.1.round() as i32;
384        blit_glyph_clipped(
385            fb, blit_x, blit_y, bm.width, bm.height, &bm.pixels, color, clip,
386        );
387    }
388}
389
390// ---------------------------------------------------------------------------
391// Glyph blitting primitives
392// ---------------------------------------------------------------------------
393
394/// Blit a greyscale alpha-coverage bitmap into `fb` at `(origin_x, origin_y)`,
395/// tinting each pixel with `color`, clipped to `clip`.
396///
397/// `pixels` is row-major greyscale: one byte per pixel, 0 = transparent,
398/// 255 = fully opaque.  Out-of-bounds pixels and pixels outside `clip` are
399/// silently skipped.  The framebuffer stays in straight-alpha `0xAARRGGBB`.
400#[cfg(feature = "text")]
401#[allow(clippy::too_many_arguments)]
402fn blit_glyph_clipped(
403    fb: &mut Framebuffer,
404    origin_x: i32,
405    origin_y: i32,
406    bm_width: u32,
407    bm_height: u32,
408    pixels: &[u8],
409    color: Color,
410    clip: ClipRect,
411) {
412    let fb_w = fb.width() as i32;
413    let fb_h = fb.height() as i32;
414    for row in 0..bm_height {
415        for col in 0..bm_width {
416            let coverage = pixels[(row * bm_width + col) as usize];
417            if coverage == 0 {
418                continue;
419            }
420            let px = origin_x + col as i32;
421            let py = origin_y + row as i32;
422            // Bounds check.
423            if px < 0 || py < 0 || px >= fb_w || py >= fb_h {
424                continue;
425            }
426            // Clip rect check.
427            if !clip.contains(px as i64, py as i64) {
428                continue;
429            }
430            // Scale the text colour's alpha by the glyph coverage, then blend.
431            let effective_alpha = ((color.3 as u32) * (coverage as u32) / 255) as u8;
432            let tinted = Color(color.0, color.1, color.2, effective_alpha);
433            fb.blend_coverage(px as u32, py as u32, &tinted, 1.0);
434        }
435    }
436}
437
438/// Blit a greyscale alpha-coverage bitmap into the canvas at `(origin_x, origin_y)`,
439/// tinting each pixel with `color`.
440///
441/// `pixels` is a row-major greyscale slice: one byte per pixel, 0 = transparent,
442/// 255 = fully opaque.  Out-of-bounds pixels are silently skipped; the clip
443/// stack is NOT applied (blending goes directly to the framebuffer).  Callers
444/// that need clip-aware blitting should call through the [`Canvas`] draw API.
445///
446/// This is the primitive used for glyph blitting: feed in a rasterised glyph
447/// bitmap and the text colour; each coverage byte is multiplied with `color.a`
448/// to produce the final alpha before source-over compositing.
449pub fn blit_glyph_bitmap(
450    fb: &mut crate::framebuffer::Framebuffer,
451    origin_x: i32,
452    origin_y: i32,
453    bm_width: u32,
454    bm_height: u32,
455    pixels: &[u8],
456    color: oxiui_core::Color,
457) {
458    let fb_w = fb.width() as i32;
459    let fb_h = fb.height() as i32;
460    for row in 0..bm_height {
461        for col in 0..bm_width {
462            let alpha = pixels[(row * bm_width + col) as usize];
463            if alpha == 0 {
464                continue;
465            }
466            let px = origin_x + col as i32;
467            let py = origin_y + row as i32;
468            if px < 0 || py < 0 || px >= fb_w || py >= fb_h {
469                continue;
470            }
471            // Scale the text colour's alpha by the glyph coverage.
472            let effective_alpha = ((color.3 as u32) * (alpha as u32) / 255) as u8;
473            let tinted = oxiui_core::Color(color.0, color.1, color.2, effective_alpha);
474            fb.blend_coverage(px as u32, py as u32, &tinted, 1.0);
475        }
476    }
477}
478
479// ---------------------------------------------------------------------------
480// Command dispatch
481// ---------------------------------------------------------------------------
482
483/// Dispatch a single [`DrawCommand`] to the appropriate [`Canvas`] method.
484///
485/// Anti-aliasing is carried by `canvas.aa` (set once per `execute` call).
486/// `shadow` enables Gaussian blur for box-shadow commands.
487fn dispatch_command(
488    canvas: &mut Canvas<'_>,
489    cache: &mut GaussianCache,
490    cmd: &DrawCommand,
491    shadow: bool,
492) {
493    match cmd {
494        // ── Clipping ──────────────────────────────────────────────────────
495        DrawCommand::PushClip { rect } => {
496            canvas.push_clip(rect.left(), rect.top(), rect.width(), rect.height());
497        }
498        DrawCommand::PopClip => {
499            canvas.pop_clip();
500        }
501
502        // ── Rectangles ────────────────────────────────────────────────────
503        DrawCommand::FillRect { rect, color } => {
504            canvas.fill_rect(rect.left(), rect.top(), rect.width(), rect.height(), *color);
505        }
506        DrawCommand::StrokeRect {
507            rect,
508            thickness,
509            color,
510        } => {
511            canvas.stroke_rect(
512                rect.left(),
513                rect.top(),
514                rect.width(),
515                rect.height(),
516                *thickness,
517                *color,
518            );
519        }
520        DrawCommand::FillRoundedRect {
521            rect,
522            radius,
523            color,
524        } => {
525            canvas.fill_rounded_rect(
526                rect.left(),
527                rect.top(),
528                rect.width(),
529                rect.height(),
530                *radius,
531                *color,
532            );
533        }
534        DrawCommand::FillRoundedRectPerCorner { rect, radii, color } => {
535            canvas.fill_rounded_rect_per_corner(
536                rect.left(),
537                rect.top(),
538                rect.width(),
539                rect.height(),
540                *radii,
541                *color,
542            );
543        }
544
545        // ── Circles / Ellipses ────────────────────────────────────────────
546        DrawCommand::FillCircle {
547            center,
548            radius,
549            color,
550        } => {
551            canvas.fill_circle(center.x, center.y, *radius, *color);
552        }
553        DrawCommand::FillEllipse {
554            center,
555            rx,
556            ry,
557            color,
558        } => {
559            canvas.fill_ellipse(center.x, center.y, *rx, *ry, *color);
560        }
561
562        // ── Lines ─────────────────────────────────────────────────────────
563        DrawCommand::Line { from, to, color } => {
564            canvas.draw_line(from.x, from.y, to.x, to.y, *color);
565        }
566        DrawCommand::LineAa { from, to, color } => {
567            canvas.draw_line_wu(from.x, from.y, to.x, to.y, *color);
568        }
569        DrawCommand::LineThick {
570            from,
571            to,
572            width,
573            color,
574        } => {
575            canvas.draw_line_thick(from.x, from.y, to.x, to.y, *width, *color);
576        }
577        DrawCommand::LineDashed {
578            from,
579            to,
580            dash_len,
581            gap_len,
582            color,
583        } => {
584            canvas.draw_line_dashed(
585                from.x,
586                from.y,
587                to.x,
588                to.y,
589                *color,
590                DashPattern::new(*dash_len, *gap_len),
591            );
592        }
593
594        // ── Paths ─────────────────────────────────────────────────────────
595        DrawCommand::FillPath { path, color } => {
596            canvas.fill_path(path, *color);
597        }
598        DrawCommand::StrokePath { path, style, color } => {
599            canvas.stroke_path(path, style, *color);
600        }
601
602        // ── Gradients ─────────────────────────────────────────────────────
603        DrawCommand::LinearGradient {
604            rect,
605            start,
606            end,
607            stops,
608        } => {
609            canvas.fill_linear_gradient_cmd(*rect, *start, *end, stops);
610        }
611        DrawCommand::RadialGradient {
612            rect,
613            center,
614            radius,
615            stops,
616        } => {
617            canvas.fill_radial_gradient_cmd(*rect, *center, *radius, stops);
618        }
619
620        // ── Images ────────────────────────────────────────────────────────
621        DrawCommand::Image {
622            image,
623            dest,
624            filter,
625        } => {
626            let src = SrcImage::new(&image.rgba, image.width, image.height);
627            let dst_w = dest.width().round() as u32;
628            let dst_h = dest.height().round() as u32;
629            match filter {
630                ImageFilter::Nearest => {
631                    canvas.blit_rgba(src, dest.left(), dest.top(), dst_w, dst_h);
632                }
633                ImageFilter::Bilinear => {
634                    canvas.blit_bilinear(src, dest.left(), dest.top(), dst_w, dst_h);
635                }
636            }
637        }
638        DrawCommand::NineSlice {
639            image,
640            dest,
641            insets,
642        } => {
643            let src = SrcImage::new(&image.rgba, image.width, image.height);
644            let dst_w = dest.width().round() as u32;
645            let dst_h = dest.height().round() as u32;
646            canvas.blit_nine_slice(src, dest.left(), dest.top(), dst_w, dst_h, *insets);
647        }
648
649        // ── Shadows ───────────────────────────────────────────────────────
650        DrawCommand::BoxShadow {
651            rect,
652            offset,
653            blur_radius,
654            color,
655        } if shadow => {
656            canvas.box_shadow_cmd(*rect, *offset, *blur_radius, *color, cache);
657        }
658        DrawCommand::BoxShadow { .. } => {
659            // ShadowQuality::Off → skip entirely (no artifact).
660        }
661
662        // ── Text ─────────────────────────────────────────────────────────
663        // DrawText is handled in execute() before dispatch_command is called.
664        DrawCommand::DrawText { .. } => {}
665
666        // non_exhaustive fallback
667        _ => {}
668    }
669}
670
671// ---------------------------------------------------------------------------
672// Tests
673// ---------------------------------------------------------------------------
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use crate::framebuffer::Framebuffer;
679    #[cfg(feature = "text")]
680    use oxiui_core::paint::DrawList;
681    use oxiui_core::Color;
682    #[cfg(feature = "text")]
683    use oxiui_core::{geometry::Rect, FontSpec};
684
685    /// Test 1: blit an 8×8 all-255 alpha bitmap, verify framebuffer pixel at
686    /// (0,0) matches the text colour.
687    #[test]
688    fn glyph_blit_all_opaque_sets_color() {
689        let mut fb = Framebuffer::new(8, 8);
690        let pixels = vec![255u8; 8 * 8];
691        let color = Color(255, 0, 128, 255); // opaque magenta-ish
692        blit_glyph_bitmap(&mut fb, 0, 0, 8, 8, &pixels, color);
693        let (r, g, b, a) = fb.get_rgba(0, 0).expect("pixel at (0,0)");
694        assert_eq!(r, 255, "red channel mismatch");
695        assert_eq!(g, 0, "green channel mismatch");
696        assert!(b > 100 && b < 160, "blue channel off: {b}");
697        assert_eq!(a, 255, "alpha channel mismatch");
698    }
699
700    /// Blit out-of-bounds glyph pixels must not panic.
701    #[test]
702    fn glyph_blit_oob_is_noop() {
703        let mut fb = Framebuffer::new(4, 4);
704        let pixels = vec![200u8; 4 * 4];
705        // Negative origin: glyph is entirely off-screen.
706        blit_glyph_bitmap(&mut fb, -10, -10, 4, 4, &pixels, Color(255, 0, 0, 255));
707        // Nothing should have changed.
708        assert_eq!(fb.get_rgba(0, 0), Some((0, 0, 0, 0)));
709    }
710
711    /// Blit with zero-alpha pixels must leave framebuffer unchanged.
712    #[test]
713    fn glyph_blit_zero_alpha_is_noop() {
714        let mut fb = Framebuffer::with_fill(4, 4, Color(10, 20, 30, 255));
715        let pixels = vec![0u8; 4 * 4]; // all transparent
716        blit_glyph_bitmap(&mut fb, 0, 0, 4, 4, &pixels, Color(255, 0, 0, 255));
717        assert_eq!(fb.get_rgba(0, 0), Some((10, 20, 30, 255)));
718    }
719
720    /// Partial alpha glyph pixel produces blended colour.
721    #[test]
722    fn glyph_blit_partial_alpha_blends() {
723        let mut fb = Framebuffer::with_fill(4, 4, Color(0, 0, 0, 255));
724        let mut pixels = vec![0u8; 4 * 4];
725        pixels[0] = 128; // one semi-transparent pixel at top-left
726        blit_glyph_bitmap(&mut fb, 0, 0, 4, 4, &pixels, Color(255, 0, 0, 255));
727        let (r, _g, _b, _a) = fb.get_rgba(0, 0).expect("pixel");
728        // Red over black with ~50% alpha → red should be around 127.
729        assert!(r > 50, "expected partial blend, got r={r}");
730    }
731
732    // ── to_bytes tests ──────────────────────────────────────────────────────
733
734    /// Test 8: to_bytes(Argb32).len() == 4 * width * height
735    #[test]
736    fn to_bytes_argb32_correct_length() {
737        use crate::headless::PixelFormat;
738        let backend = SoftBackend::new(10, 8);
739        let bytes = backend.to_bytes(PixelFormat::Argb32);
740        assert_eq!(bytes.len(), 4 * 10 * 8);
741    }
742
743    /// Test 9: Bgra8 byte order: R and B channels swapped vs Argb32.
744    #[test]
745    fn to_bytes_bgra8_rb_swap() {
746        use crate::headless::PixelFormat;
747        // Fill with a known colour: R=10, G=20, B=30, A=255.
748        let mut backend = SoftBackend::new(1, 1);
749        backend.clear(Color(10, 20, 30, 255));
750        let argb = backend.to_bytes(PixelFormat::Argb32);
751        // Argb32 layout: [A, R, G, B]
752        assert_eq!(argb[0], 255, "Argb32[0]=A");
753        assert_eq!(argb[1], 10, "Argb32[1]=R");
754        assert_eq!(argb[2], 20, "Argb32[2]=G");
755        assert_eq!(argb[3], 30, "Argb32[3]=B");
756
757        let bgra = backend.to_bytes(PixelFormat::Bgra8);
758        // Bgra8 layout: [B, G, R, A]
759        assert_eq!(bgra[0], 30, "Bgra8[0]=B");
760        assert_eq!(bgra[1], 20, "Bgra8[1]=G");
761        assert_eq!(bgra[2], 10, "Bgra8[2]=R");
762        assert_eq!(bgra[3], 255, "Bgra8[3]=A");
763    }
764
765    /// Test 10: Rgb565 output has exactly 2 bytes per pixel.
766    #[test]
767    fn to_bytes_rgb565_correct_length() {
768        use crate::headless::PixelFormat;
769        let backend = SoftBackend::new(7, 5);
770        let bytes = backend.to_bytes(PixelFormat::Rgb565);
771        assert_eq!(bytes.len(), 2 * 7 * 5);
772    }
773
774    /// Rgb565 preserves the high bits of each channel.
775    #[test]
776    fn to_bytes_rgb565_round_trip_high_bits() {
777        use crate::headless::PixelFormat;
778        // Pure red: R=255, G=0, B=0 → R5=31, G6=0, B5=0 → 0b11111_000000_00000 = 0xF800
779        let mut backend = SoftBackend::new(1, 1);
780        backend.clear(Color(255, 0, 0, 255));
781        let bytes = backend.to_bytes(PixelFormat::Rgb565);
782        let word = (bytes[0] as u16) << 8 | (bytes[1] as u16);
783        let r5 = word >> 11;
784        assert_eq!(r5, 31, "pure red should give max R5");
785        let g6 = (word >> 5) & 0x3F;
786        assert_eq!(g6, 0, "pure red should give zero G6");
787    }
788
789    /// Quality setter round-trips.
790    #[test]
791    fn backend_quality_round_trip() {
792        use crate::{AaMode, ShadowQuality, SoftRenderQuality};
793        let mut backend = SoftBackend::new(10, 10);
794        let q = SoftRenderQuality::low();
795        backend.set_quality(q.clone());
796        assert_eq!(backend.quality().aa_mode, AaMode::None);
797        assert_eq!(backend.quality().shadow_quality, ShadowQuality::Off);
798    }
799
800    // ── Text feature tests ──────────────────────────────────────────────────
801
802    /// Test: supports_text() returns true when the text feature is enabled.
803    #[cfg(feature = "text")]
804    #[test]
805    fn supports_text_true_with_feature() {
806        let backend = SoftBackend::new(100, 100);
807        // The embedded font must load; if not, the test is still valid
808        // (supports_text returns true only when pipeline is Some).
809        // We assert the pipeline loaded successfully for a proper test.
810        assert!(backend.text_pipeline.is_some(), "embedded font must parse");
811        assert!(
812            backend.supports_text(),
813            "supports_text must be true with font loaded"
814        );
815    }
816
817    /// Test: supports_text() returns false without the text feature.
818    #[cfg(not(feature = "text"))]
819    #[test]
820    fn supports_text_false_without_feature() {
821        let backend = SoftBackend::new(100, 100);
822        assert!(!backend.supports_text());
823    }
824
825    /// Test: DrawText with the text feature produces non-zero pixels in the
826    /// expected region.
827    #[cfg(feature = "text")]
828    #[test]
829    fn draw_text_produces_pixels() {
830        let mut backend = SoftBackend::new(200, 50);
831        // Start with a black background so text pixels are distinguishable.
832        backend.clear(Color(0, 0, 0, 255));
833
834        let mut dl = DrawList::new();
835        dl.push_text(
836            Rect::new(0.0, 0.0, 200.0, 50.0),
837            "A",
838            FontSpec::default(),
839            Color(255, 255, 255, 255),
840        );
841        backend.execute(&dl).expect("execute must succeed");
842
843        // At least one pixel in the framebuffer must be non-black after rendering "A".
844        let has_nonblack = backend.frame().pixels().iter().any(|&px| px != 0xFF000000);
845        assert!(
846            has_nonblack,
847            "DrawText 'A' must produce at least one non-black pixel"
848        );
849    }
850
851    /// Test: DrawText respects an active clip rect.  When the clip excludes the
852    /// text origin entirely, no pixels should be modified.
853    #[cfg(feature = "text")]
854    #[test]
855    fn draw_text_clip_rect_excludes() {
856        let mut backend = SoftBackend::new(200, 50);
857        // Fill with a known background.
858        backend.clear(Color(0, 0, 0, 255));
859
860        let mut dl = DrawList::new();
861        // Clip to the right half only; render text in the left half.
862        // The clip must exclude the glyph completely.
863        dl.push_clip(Rect::new(150.0, 0.0, 50.0, 50.0));
864        dl.push_text(
865            Rect::new(0.0, 0.0, 100.0, 50.0),
866            "A",
867            FontSpec::default(),
868            Color(255, 255, 255, 255),
869        );
870        dl.pop_clip();
871        backend.execute(&dl).expect("execute must succeed");
872
873        // All pixels outside the clip region (x < 150) must remain black.
874        // We check pixels in the left 150 columns.
875        let fb = backend.frame();
876        for y in 0..50u32 {
877            for x in 0..150u32 {
878                let px = fb.get(x, y).unwrap_or(0);
879                assert_eq!(
880                    px, 0xFF000000,
881                    "pixel ({x},{y}) must be unchanged (clipped), got 0x{px:08X}"
882                );
883            }
884        }
885    }
886
887    /// Test: drawing an empty string must not panic and must not modify the fb.
888    #[cfg(feature = "text")]
889    #[test]
890    fn draw_text_no_panic_empty_string() {
891        let mut backend = SoftBackend::new(100, 50);
892        backend.clear(Color(0, 0, 0, 255));
893
894        let mut dl = DrawList::new();
895        dl.push_text(
896            Rect::new(0.0, 0.0, 100.0, 50.0),
897            "",
898            FontSpec::default(),
899            Color(255, 255, 255, 255),
900        );
901        backend.execute(&dl).expect("execute must succeed");
902
903        // All pixels must remain unchanged.
904        let has_nonblack = backend.frame().pixels().iter().any(|&px| px != 0xFF000000);
905        assert!(!has_nonblack, "empty string must not modify any pixels");
906    }
907
908    /// Test: rendering "AB" produces non-empty pixels across a wider region
909    /// than rendering "A" alone, verifying horizontal advance.
910    #[cfg(feature = "text")]
911    #[test]
912    fn draw_text_horizontal_advance() {
913        // Render "A" alone.
914        let mut backend_a = SoftBackend::new(200, 50);
915        backend_a.clear(Color(0, 0, 0, 255));
916        let mut dl_a = DrawList::new();
917        dl_a.push_text(
918            Rect::new(0.0, 0.0, 200.0, 50.0),
919            "A",
920            FontSpec::default(),
921            Color(255, 255, 255, 255),
922        );
923        backend_a.execute(&dl_a).expect("execute A");
924
925        // Render "AB".
926        let mut backend_ab = SoftBackend::new(200, 50);
927        backend_ab.clear(Color(0, 0, 0, 255));
928        let mut dl_ab = DrawList::new();
929        dl_ab.push_text(
930            Rect::new(0.0, 0.0, 200.0, 50.0),
931            "AB",
932            FontSpec::default(),
933            Color(255, 255, 255, 255),
934        );
935        backend_ab.execute(&dl_ab).expect("execute AB");
936
937        // "AB" must have at least as many non-black pixels as "A".
938        let count_nonblack = |fb: &Framebuffer| -> usize {
939            fb.pixels().iter().filter(|&&px| px != 0xFF000000).count()
940        };
941        let count_a = count_nonblack(backend_a.frame());
942        let count_ab = count_nonblack(backend_ab.frame());
943        assert!(
944            count_ab >= count_a,
945            "rendering 'AB' must cover at least as many pixels as 'A' (a={count_a}, ab={count_ab})"
946        );
947        // Both must be non-empty.
948        assert!(count_a > 0, "'A' must produce some non-black pixels");
949        assert!(count_ab > 0, "'AB' must produce some non-black pixels");
950    }
951
952    // ── Clip-aware blit_glyph_clipped tests ────────────────────────────────
953
954    /// blit_glyph_clipped with a clip that excludes the glyph entirely →
955    /// no pixels changed.
956    #[cfg(feature = "text")]
957    #[test]
958    fn blit_glyph_clipped_excludes_fully() {
959        let mut fb = Framebuffer::with_fill(10, 10, Color(0, 0, 0, 255));
960        let pixels = vec![255u8; 4 * 4];
961        // Glyph at (0,0), clip is entirely in the right half.
962        let clip = ClipRect {
963            x0: 5,
964            y0: 0,
965            x1: 10,
966            y1: 10,
967        };
968        blit_glyph_clipped(
969            &mut fb,
970            0,
971            0,
972            4,
973            4,
974            &pixels,
975            Color(255, 255, 255, 255),
976            clip,
977        );
978        // Left half must be unchanged.
979        assert_eq!(fb.get_rgba(0, 0), Some((0, 0, 0, 255)));
980        assert_eq!(fb.get_rgba(4, 0), Some((0, 0, 0, 255)));
981    }
982
983    /// blit_glyph_clipped with a full-fb clip paints all glyph pixels.
984    #[cfg(feature = "text")]
985    #[test]
986    fn blit_glyph_clipped_full_clip_paints() {
987        let mut fb = Framebuffer::new(4, 4);
988        let pixels = vec![255u8; 4 * 4];
989        let clip = ClipRect::full(4, 4);
990        blit_glyph_clipped(&mut fb, 0, 0, 4, 4, &pixels, Color(255, 0, 0, 255), clip);
991        assert!(fb.get(0, 0).unwrap_or(0) != 0, "pixel should be non-zero");
992    }
993}