use oxiui_core::geometry::Size;
use oxiui_core::paint::{DrawCommand, DrawList, ImageFilter, RenderBackend};
use oxiui_core::{Color, UiError};
use crate::clip::{ClipRect, ClipStack};
use crate::draw::{Canvas, DashPattern, SrcImage};
use crate::framebuffer::Framebuffer;
use crate::shadow::GaussianCache;
use crate::{AaMode, ShadowQuality, SoftRenderQuality};
pub struct SoftBackend {
fb: Framebuffer,
shadow_cache: GaussianCache,
quality: SoftRenderQuality,
#[cfg(feature = "text")]
text_pipeline: Option<oxiui_text::TextPipeline>,
}
impl SoftBackend {
pub fn new(width: u32, height: u32) -> Self {
Self {
fb: Framebuffer::new(width, height),
shadow_cache: GaussianCache::default(),
quality: SoftRenderQuality::balanced(),
#[cfg(feature = "text")]
text_pipeline: Self::init_text_pipeline(),
}
}
pub fn with_background(width: u32, height: u32, bg: Color) -> Self {
Self {
fb: Framebuffer::with_fill(width, height, bg),
shadow_cache: GaussianCache::default(),
quality: SoftRenderQuality::balanced(),
#[cfg(feature = "text")]
text_pipeline: Self::init_text_pipeline(),
}
}
#[cfg(feature = "text")]
fn init_text_pipeline() -> Option<oxiui_text::TextPipeline> {
const FONT_BYTES: &[u8] =
include_bytes!("../../../../oxitext/tests/fixtures/test-font.ttf");
oxiui_text::TextPipeline::from_bytes(FONT_BYTES).ok()
}
pub fn set_quality(&mut self, quality: SoftRenderQuality) {
self.quality = quality;
}
pub fn quality(&self) -> &SoftRenderQuality {
&self.quality
}
fn aa_enabled(&self) -> bool {
!matches!(self.quality.aa_mode, AaMode::None)
}
fn shadow_enabled(&self) -> bool {
!matches!(self.quality.shadow_quality, ShadowQuality::Off)
}
pub fn frame(&self) -> &Framebuffer {
&self.fb
}
pub fn into_framebuffer(self) -> Framebuffer {
self.fb
}
pub fn clear(&mut self, color: Color) {
self.fb.clear(color);
}
#[cfg(feature = "theme")]
pub fn apply_shadow_spec(
&mut self,
rect: (f32, f32, f32, f32),
spec: &oxiui_theme::ShadowSpec,
) {
use crate::shadow::box_shadow;
let (rx, ry, rw, rh) = rect;
let spread = spec.spread;
let inflated = (
rx - spread,
ry - spread,
rw + spread * 2.0,
rh + spread * 2.0,
);
box_shadow(
&mut self.fb,
inflated,
spec.offset_x,
spec.offset_y,
spec.blur,
spec.color,
&mut self.shadow_cache,
);
}
pub fn width(&self) -> u32 {
self.fb.width()
}
pub fn height(&self) -> u32 {
self.fb.height()
}
pub fn to_bytes(&self, format: crate::headless::PixelFormat) -> Vec<u8> {
use crate::framebuffer::unpack;
use crate::headless::PixelFormat;
match format {
PixelFormat::Argb32 => {
self.fb
.pixels()
.iter()
.flat_map(|&p| {
let (r, g, b, a) = unpack(p);
[a, r, g, b]
})
.collect()
}
PixelFormat::Bgra8 => {
self.fb
.pixels()
.iter()
.flat_map(|&p| {
let (r, g, b, a) = unpack(p);
[b, g, r, a]
})
.collect()
}
PixelFormat::Rgb565 => {
self.fb
.pixels()
.iter()
.flat_map(|&p| {
let (r, g, b, _a) = unpack(p);
let r5 = (r as u16) >> 3;
let g6 = (g as u16) >> 2;
let b5 = (b as u16) >> 3;
let rgb565: u16 = (r5 << 11) | (g6 << 5) | b5;
[(rgb565 >> 8) as u8, (rgb565 & 0xFF) as u8]
})
.collect()
}
}
}
}
impl RenderBackend for SoftBackend {
fn surface_size(&self) -> Size {
Size::new(self.fb.width() as f32, self.fb.height() as f32)
}
fn supports_blur(&self) -> bool {
true
}
fn supports_gradients(&self) -> bool {
true
}
fn supports_paths(&self) -> bool {
true
}
fn supports_images(&self) -> bool {
true
}
#[cfg(feature = "text")]
fn supports_text(&self) -> bool {
self.text_pipeline.is_some()
}
#[cfg(not(feature = "text"))]
fn supports_text(&self) -> bool {
false
}
fn execute(&mut self, list: &DrawList) -> Result<(), UiError> {
let aa = self.aa_enabled();
let shadow = self.shadow_enabled();
let fb_w = self.fb.width();
let fb_h = self.fb.height();
let mut pending_clips: Vec<(f32, f32, f32, f32)> = Vec::new();
let mut shadow_clip = ClipStack::new(fb_w, fb_h);
let mut iter = list.iter().peekable();
while iter.peek().is_some() {
{
let mut canvas = Canvas::new(&mut self.fb);
canvas.set_aa(aa);
for &(x, y, w, h) in &pending_clips {
canvas.push_clip(x, y, w, h);
}
loop {
let peek = iter.peek();
let is_text = matches!(peek, Some(DrawCommand::DrawText { .. }));
if peek.is_none() || is_text {
break;
}
let cmd = iter.next().expect("peeked Some above");
match cmd {
DrawCommand::PushClip { rect } => {
let r = (rect.left(), rect.top(), rect.width(), rect.height());
pending_clips.push(r);
shadow_clip.push(ClipRect::from_rect(
rect.left().floor() as i64,
rect.top().floor() as i64,
rect.width().ceil() as i64,
rect.height().ceil() as i64,
));
}
DrawCommand::PopClip => {
pending_clips.pop();
shadow_clip.pop();
}
_ => {}
}
dispatch_command(&mut canvas, &mut self.shadow_cache, cmd, shadow);
}
}
#[cfg(feature = "text")]
if matches!(iter.peek(), Some(DrawCommand::DrawText { .. })) {
if let Some(DrawCommand::DrawText {
text, font, color, ..
}) = iter.next()
{
draw_text_to_fb(
&mut self.fb,
&mut self.text_pipeline,
text,
font,
*color,
shadow_clip.current(),
);
}
continue;
}
#[cfg(not(feature = "text"))]
if matches!(iter.peek(), Some(DrawCommand::DrawText { .. })) {
iter.next();
continue;
}
}
Ok(())
}
}
#[cfg(feature = "text")]
fn draw_text_to_fb(
fb: &mut Framebuffer,
pipeline: &mut Option<oxiui_text::TextPipeline>,
text: &str,
font: &oxiui_core::FontSpec,
color: Color,
clip: ClipRect,
) {
let pipeline = match pipeline {
Some(p) => p,
None => return, };
if text.is_empty() {
return;
}
let style = oxiui_text::TextStyle::new(font.size);
let result = match pipeline.render(text, &style) {
Ok(r) => r,
Err(_) => return, };
for (pg, bm) in result.glyphs.iter().zip(result.bitmaps.iter()) {
if bm.is_empty() {
continue;
}
let blit_x = pg.pos.0.round() as i32;
let blit_y = pg.pos.1.round() as i32;
blit_glyph_clipped(
fb, blit_x, blit_y, bm.width, bm.height, &bm.pixels, color, clip,
);
}
}
#[allow(clippy::too_many_arguments)]
fn blit_glyph_clipped(
fb: &mut Framebuffer,
origin_x: i32,
origin_y: i32,
bm_width: u32,
bm_height: u32,
pixels: &[u8],
color: Color,
clip: ClipRect,
) {
let fb_w = fb.width() as i32;
let fb_h = fb.height() as i32;
for row in 0..bm_height {
for col in 0..bm_width {
let coverage = pixels[(row * bm_width + col) as usize];
if coverage == 0 {
continue;
}
let px = origin_x + col as i32;
let py = origin_y + row as i32;
if px < 0 || py < 0 || px >= fb_w || py >= fb_h {
continue;
}
if !clip.contains(px as i64, py as i64) {
continue;
}
let effective_alpha = ((color.3 as u32) * (coverage as u32) / 255) as u8;
let tinted = Color(color.0, color.1, color.2, effective_alpha);
fb.blend_coverage(px as u32, py as u32, &tinted, 1.0);
}
}
}
pub fn blit_glyph_bitmap(
fb: &mut crate::framebuffer::Framebuffer,
origin_x: i32,
origin_y: i32,
bm_width: u32,
bm_height: u32,
pixels: &[u8],
color: oxiui_core::Color,
) {
let fb_w = fb.width() as i32;
let fb_h = fb.height() as i32;
for row in 0..bm_height {
for col in 0..bm_width {
let alpha = pixels[(row * bm_width + col) as usize];
if alpha == 0 {
continue;
}
let px = origin_x + col as i32;
let py = origin_y + row as i32;
if px < 0 || py < 0 || px >= fb_w || py >= fb_h {
continue;
}
let effective_alpha = ((color.3 as u32) * (alpha as u32) / 255) as u8;
let tinted = oxiui_core::Color(color.0, color.1, color.2, effective_alpha);
fb.blend_coverage(px as u32, py as u32, &tinted, 1.0);
}
}
}
fn dispatch_command(
canvas: &mut Canvas<'_>,
cache: &mut GaussianCache,
cmd: &DrawCommand,
shadow: bool,
) {
match cmd {
DrawCommand::PushClip { rect } => {
canvas.push_clip(rect.left(), rect.top(), rect.width(), rect.height());
}
DrawCommand::PopClip => {
canvas.pop_clip();
}
DrawCommand::FillRect { rect, color } => {
canvas.fill_rect(rect.left(), rect.top(), rect.width(), rect.height(), *color);
}
DrawCommand::StrokeRect {
rect,
thickness,
color,
} => {
canvas.stroke_rect(
rect.left(),
rect.top(),
rect.width(),
rect.height(),
*thickness,
*color,
);
}
DrawCommand::FillRoundedRect {
rect,
radius,
color,
} => {
canvas.fill_rounded_rect(
rect.left(),
rect.top(),
rect.width(),
rect.height(),
*radius,
*color,
);
}
DrawCommand::FillRoundedRectPerCorner { rect, radii, color } => {
canvas.fill_rounded_rect_per_corner(
rect.left(),
rect.top(),
rect.width(),
rect.height(),
*radii,
*color,
);
}
DrawCommand::FillCircle {
center,
radius,
color,
} => {
canvas.fill_circle(center.x, center.y, *radius, *color);
}
DrawCommand::FillEllipse {
center,
rx,
ry,
color,
} => {
canvas.fill_ellipse(center.x, center.y, *rx, *ry, *color);
}
DrawCommand::Line { from, to, color } => {
canvas.draw_line(from.x, from.y, to.x, to.y, *color);
}
DrawCommand::LineAa { from, to, color } => {
canvas.draw_line_wu(from.x, from.y, to.x, to.y, *color);
}
DrawCommand::LineThick {
from,
to,
width,
color,
} => {
canvas.draw_line_thick(from.x, from.y, to.x, to.y, *width, *color);
}
DrawCommand::LineDashed {
from,
to,
dash_len,
gap_len,
color,
} => {
canvas.draw_line_dashed(
from.x,
from.y,
to.x,
to.y,
*color,
DashPattern::new(*dash_len, *gap_len),
);
}
DrawCommand::FillPath { path, color } => {
canvas.fill_path(path, *color);
}
DrawCommand::StrokePath { path, style, color } => {
canvas.stroke_path(path, style, *color);
}
DrawCommand::LinearGradient {
rect,
start,
end,
stops,
} => {
canvas.fill_linear_gradient_cmd(*rect, *start, *end, stops);
}
DrawCommand::RadialGradient {
rect,
center,
radius,
stops,
} => {
canvas.fill_radial_gradient_cmd(*rect, *center, *radius, stops);
}
DrawCommand::Image {
image,
dest,
filter,
} => {
let src = SrcImage::new(&image.rgba, image.width, image.height);
let dst_w = dest.width().round() as u32;
let dst_h = dest.height().round() as u32;
match filter {
ImageFilter::Nearest => {
canvas.blit_rgba(src, dest.left(), dest.top(), dst_w, dst_h);
}
ImageFilter::Bilinear => {
canvas.blit_bilinear(src, dest.left(), dest.top(), dst_w, dst_h);
}
}
}
DrawCommand::NineSlice {
image,
dest,
insets,
} => {
let src = SrcImage::new(&image.rgba, image.width, image.height);
let dst_w = dest.width().round() as u32;
let dst_h = dest.height().round() as u32;
canvas.blit_nine_slice(src, dest.left(), dest.top(), dst_w, dst_h, *insets);
}
DrawCommand::BoxShadow {
rect,
offset,
blur_radius,
color,
} if shadow => {
canvas.box_shadow_cmd(*rect, *offset, *blur_radius, *color, cache);
}
DrawCommand::BoxShadow { .. } => {
}
DrawCommand::DrawText { .. } => {}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::framebuffer::Framebuffer;
use oxiui_core::paint::DrawList;
use oxiui_core::{geometry::Rect, Color, FontSpec};
#[test]
fn glyph_blit_all_opaque_sets_color() {
let mut fb = Framebuffer::new(8, 8);
let pixels = vec![255u8; 8 * 8];
let color = Color(255, 0, 128, 255); blit_glyph_bitmap(&mut fb, 0, 0, 8, 8, &pixels, color);
let (r, g, b, a) = fb.get_rgba(0, 0).expect("pixel at (0,0)");
assert_eq!(r, 255, "red channel mismatch");
assert_eq!(g, 0, "green channel mismatch");
assert!(b > 100 && b < 160, "blue channel off: {b}");
assert_eq!(a, 255, "alpha channel mismatch");
}
#[test]
fn glyph_blit_oob_is_noop() {
let mut fb = Framebuffer::new(4, 4);
let pixels = vec![200u8; 4 * 4];
blit_glyph_bitmap(&mut fb, -10, -10, 4, 4, &pixels, Color(255, 0, 0, 255));
assert_eq!(fb.get_rgba(0, 0), Some((0, 0, 0, 0)));
}
#[test]
fn glyph_blit_zero_alpha_is_noop() {
let mut fb = Framebuffer::with_fill(4, 4, Color(10, 20, 30, 255));
let pixels = vec![0u8; 4 * 4]; blit_glyph_bitmap(&mut fb, 0, 0, 4, 4, &pixels, Color(255, 0, 0, 255));
assert_eq!(fb.get_rgba(0, 0), Some((10, 20, 30, 255)));
}
#[test]
fn glyph_blit_partial_alpha_blends() {
let mut fb = Framebuffer::with_fill(4, 4, Color(0, 0, 0, 255));
let mut pixels = vec![0u8; 4 * 4];
pixels[0] = 128; blit_glyph_bitmap(&mut fb, 0, 0, 4, 4, &pixels, Color(255, 0, 0, 255));
let (r, _g, _b, _a) = fb.get_rgba(0, 0).expect("pixel");
assert!(r > 50, "expected partial blend, got r={r}");
}
#[test]
fn to_bytes_argb32_correct_length() {
use crate::headless::PixelFormat;
let backend = SoftBackend::new(10, 8);
let bytes = backend.to_bytes(PixelFormat::Argb32);
assert_eq!(bytes.len(), 4 * 10 * 8);
}
#[test]
fn to_bytes_bgra8_rb_swap() {
use crate::headless::PixelFormat;
let mut backend = SoftBackend::new(1, 1);
backend.clear(Color(10, 20, 30, 255));
let argb = backend.to_bytes(PixelFormat::Argb32);
assert_eq!(argb[0], 255, "Argb32[0]=A");
assert_eq!(argb[1], 10, "Argb32[1]=R");
assert_eq!(argb[2], 20, "Argb32[2]=G");
assert_eq!(argb[3], 30, "Argb32[3]=B");
let bgra = backend.to_bytes(PixelFormat::Bgra8);
assert_eq!(bgra[0], 30, "Bgra8[0]=B");
assert_eq!(bgra[1], 20, "Bgra8[1]=G");
assert_eq!(bgra[2], 10, "Bgra8[2]=R");
assert_eq!(bgra[3], 255, "Bgra8[3]=A");
}
#[test]
fn to_bytes_rgb565_correct_length() {
use crate::headless::PixelFormat;
let backend = SoftBackend::new(7, 5);
let bytes = backend.to_bytes(PixelFormat::Rgb565);
assert_eq!(bytes.len(), 2 * 7 * 5);
}
#[test]
fn to_bytes_rgb565_round_trip_high_bits() {
use crate::headless::PixelFormat;
let mut backend = SoftBackend::new(1, 1);
backend.clear(Color(255, 0, 0, 255));
let bytes = backend.to_bytes(PixelFormat::Rgb565);
let word = (bytes[0] as u16) << 8 | (bytes[1] as u16);
let r5 = word >> 11;
assert_eq!(r5, 31, "pure red should give max R5");
let g6 = (word >> 5) & 0x3F;
assert_eq!(g6, 0, "pure red should give zero G6");
}
#[test]
fn backend_quality_round_trip() {
use crate::{AaMode, ShadowQuality, SoftRenderQuality};
let mut backend = SoftBackend::new(10, 10);
let q = SoftRenderQuality::low();
backend.set_quality(q.clone());
assert_eq!(backend.quality().aa_mode, AaMode::None);
assert_eq!(backend.quality().shadow_quality, ShadowQuality::Off);
}
#[cfg(feature = "text")]
#[test]
fn supports_text_true_with_feature() {
let backend = SoftBackend::new(100, 100);
assert!(backend.text_pipeline.is_some(), "embedded font must parse");
assert!(
backend.supports_text(),
"supports_text must be true with font loaded"
);
}
#[cfg(not(feature = "text"))]
#[test]
fn supports_text_false_without_feature() {
let backend = SoftBackend::new(100, 100);
assert!(!backend.supports_text());
}
#[cfg(feature = "text")]
#[test]
fn draw_text_produces_pixels() {
let mut backend = SoftBackend::new(200, 50);
backend.clear(Color(0, 0, 0, 255));
let mut dl = DrawList::new();
dl.push_text(
Rect::new(0.0, 0.0, 200.0, 50.0),
"A",
FontSpec::default(),
Color(255, 255, 255, 255),
);
backend.execute(&dl).expect("execute must succeed");
let has_nonblack = backend.frame().pixels().iter().any(|&px| px != 0xFF000000);
assert!(
has_nonblack,
"DrawText 'A' must produce at least one non-black pixel"
);
}
#[cfg(feature = "text")]
#[test]
fn draw_text_clip_rect_excludes() {
let mut backend = SoftBackend::new(200, 50);
backend.clear(Color(0, 0, 0, 255));
let mut dl = DrawList::new();
dl.push_clip(Rect::new(150.0, 0.0, 50.0, 50.0));
dl.push_text(
Rect::new(0.0, 0.0, 100.0, 50.0),
"A",
FontSpec::default(),
Color(255, 255, 255, 255),
);
dl.pop_clip();
backend.execute(&dl).expect("execute must succeed");
let fb = backend.frame();
for y in 0..50u32 {
for x in 0..150u32 {
let px = fb.get(x, y).unwrap_or(0);
assert_eq!(
px, 0xFF000000,
"pixel ({x},{y}) must be unchanged (clipped), got 0x{px:08X}"
);
}
}
}
#[cfg(feature = "text")]
#[test]
fn draw_text_no_panic_empty_string() {
let mut backend = SoftBackend::new(100, 50);
backend.clear(Color(0, 0, 0, 255));
let mut dl = DrawList::new();
dl.push_text(
Rect::new(0.0, 0.0, 100.0, 50.0),
"",
FontSpec::default(),
Color(255, 255, 255, 255),
);
backend.execute(&dl).expect("execute must succeed");
let has_nonblack = backend.frame().pixels().iter().any(|&px| px != 0xFF000000);
assert!(!has_nonblack, "empty string must not modify any pixels");
}
#[cfg(feature = "text")]
#[test]
fn draw_text_horizontal_advance() {
let mut backend_a = SoftBackend::new(200, 50);
backend_a.clear(Color(0, 0, 0, 255));
let mut dl_a = DrawList::new();
dl_a.push_text(
Rect::new(0.0, 0.0, 200.0, 50.0),
"A",
FontSpec::default(),
Color(255, 255, 255, 255),
);
backend_a.execute(&dl_a).expect("execute A");
let mut backend_ab = SoftBackend::new(200, 50);
backend_ab.clear(Color(0, 0, 0, 255));
let mut dl_ab = DrawList::new();
dl_ab.push_text(
Rect::new(0.0, 0.0, 200.0, 50.0),
"AB",
FontSpec::default(),
Color(255, 255, 255, 255),
);
backend_ab.execute(&dl_ab).expect("execute AB");
let count_nonblack = |fb: &Framebuffer| -> usize {
fb.pixels().iter().filter(|&&px| px != 0xFF000000).count()
};
let count_a = count_nonblack(backend_a.frame());
let count_ab = count_nonblack(backend_ab.frame());
assert!(
count_ab >= count_a,
"rendering 'AB' must cover at least as many pixels as 'A' (a={count_a}, ab={count_ab})"
);
assert!(count_a > 0, "'A' must produce some non-black pixels");
assert!(count_ab > 0, "'AB' must produce some non-black pixels");
}
#[test]
fn blit_glyph_clipped_excludes_fully() {
let mut fb = Framebuffer::with_fill(10, 10, Color(0, 0, 0, 255));
let pixels = vec![255u8; 4 * 4];
let clip = ClipRect {
x0: 5,
y0: 0,
x1: 10,
y1: 10,
};
blit_glyph_clipped(
&mut fb,
0,
0,
4,
4,
&pixels,
Color(255, 255, 255, 255),
clip,
);
assert_eq!(fb.get_rgba(0, 0), Some((0, 0, 0, 255)));
assert_eq!(fb.get_rgba(4, 0), Some((0, 0, 0, 255)));
}
#[test]
fn blit_glyph_clipped_full_clip_paints() {
let mut fb = Framebuffer::new(4, 4);
let pixels = vec![255u8; 4 * 4];
let clip = ClipRect::full(4, 4);
blit_glyph_clipped(&mut fb, 0, 0, 4, 4, &pixels, Color(255, 0, 0, 255), clip);
assert!(fb.get(0, 0).unwrap_or(0) != 0, "pixel should be non-zero");
}
}