use crate::{ display_list::{Color, DisplayList, DrawCommand}, geometry::{Point, Rect}, text::{self, TextStyle}, }; pub struct SoftwareRenderer; impl SoftwareRenderer { pub fn render(list: &DisplayList, target: &mut [u32], width: u32, height: u32) { let mut canvas = Canvas { pixels: target, width, height, clip: Rect::new(0.0, 0.0, width as f32, height as f32), }; let mut clips = Vec::new(); for command in list.commands() { match command { DrawCommand::PushClip(rect) => { clips.push(canvas.clip); let x = canvas.clip.x.max(rect.x); let y = canvas.clip.y.max(rect.y); canvas.clip = Rect::new( x, y, (canvas.clip.x + canvas.clip.width) .min(rect.x + rect.width) .max(x) - x, (canvas.clip.y + canvas.clip.height) .min(rect.y + rect.height) .max(y) - y, ); } DrawCommand::PopClip => { if let Some(clip) = clips.pop() { canvas.clip = clip; } } DrawCommand::Clear(color) => canvas.clear(*color), DrawCommand::FillRoundedRect { rect, radius, color, } => canvas.fill_rounded_rect(*rect, *radius, *color), DrawCommand::StrokeRoundedRect { rect, radius, width, color, } => canvas.stroke_rounded_rect(*rect, *radius, *width, *color), DrawCommand::WrappedText { rect, value, style, color, } => canvas.draw_wrapped_text(*rect, value, *style, *color), DrawCommand::Text { origin, value, style, color, } => canvas.draw_text(*origin, value, *style, *color), } } } } struct Canvas<'a> { pixels: &'a mut [u32], width: u32, height: u32, clip: Rect, } impl Canvas<'_> { fn clear(&mut self, color: Color) { self.pixels.fill(pack(color)); } fn fill_rounded_rect(&mut self, rect: Rect, radius: f32, color: Color) { let bounds = self.clamped_bounds(rect); let radius = radius.max(0.0).min(rect.width / 2.0).min(rect.height / 2.0); for y in bounds.1..bounds.3 { for x in bounds.0..bounds.2 { if rounded_rect_contains(rect, radius, x as f32 + 0.5, y as f32 + 0.5) { self.blend_pixel(x, y, color); } } } } fn stroke_rounded_rect(&mut self, rect: Rect, radius: f32, width: f32, color: Color) { let bounds = self.clamped_bounds(rect); let width = width.max(1.0).min(rect.width / 2.0).min(rect.height / 2.0); let outer_radius = radius.max(0.0).min(rect.width / 2.0).min(rect.height / 2.0); let inner = Rect::new( rect.x + width, rect.y + width, (rect.width - width * 2.0).max(0.0), (rect.height - width * 2.0).max(0.0), ); let inner_radius = (outer_radius - width).max(0.0); for y in bounds.1..bounds.3 { for x in bounds.0..bounds.2 { let px = x as f32 + 0.5; let py = y as f32 + 0.5; if rounded_rect_contains(rect, outer_radius, px, py) && !rounded_rect_contains(inner, inner_radius, px, py) { self.blend_pixel(x, y, color); } } } } fn draw_text(&mut self, origin: Point, value: &str, style: TextStyle, color: Color) { let x = origin.x.round() as i32; let y = origin.y.round() as i32; text::draw(value, style, color, |dx, dy, pixel| { self.blend_pixel(x + dx, y + dy, pixel) }); } fn draw_wrapped_text(&mut self, rect: Rect, value: &str, style: TextStyle, color: Color) { if rect.width <= 0.0 || rect.height <= 0.0 { return; } let old = self.clip; let x = old.x.max(rect.x); let y = old.y.max(rect.y); self.clip = Rect::new( x, y, ((old.x + old.width).min(rect.x + rect.width) - x).max(0.0), ((old.y + old.height).min(rect.y + rect.height) - y).max(0.0), ); let left = rect.x.round() as i32; let top = rect.y.round() as i32; text::draw_wrapped(value, style, color, rect.width, |dx, dy, pixel| { self.blend_pixel(left + dx, top + dy, pixel) }); self.clip = old; } fn clamped_bounds(&self, rect: Rect) -> (i32, i32, i32, i32) { ( rect.x.max(self.clip.x).max(0.0).floor() as i32, rect.y.max(self.clip.y).max(0.0).floor() as i32, (rect.x + rect.width) .min(self.clip.x + self.clip.width) .min(self.width as f32) .ceil() as i32, (rect.y + rect.height) .min(self.clip.y + self.clip.height) .min(self.height as f32) .ceil() as i32, ) } fn blend_pixel(&mut self, x: i32, y: i32, source: Color) { if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 || source.alpha == 0 || (x as f32) < self.clip.x || (y as f32) < self.clip.y || (x as f32) >= self.clip.x + self.clip.width || (y as f32) >= self.clip.y + self.clip.height { return; } let index = y as usize * self.width as usize + x as usize; if source.alpha == 255 { self.pixels[index] = pack(source); return; } let destination = self.pixels[index]; let dst_red = (destination >> 16) & 0xff; let dst_green = (destination >> 8) & 0xff; let dst_blue = destination & 0xff; let alpha = source.alpha as u32; let inverse = 255 - alpha; let red = (source.red as u32 * alpha + dst_red * inverse) / 255; let green = (source.green as u32 * alpha + dst_green * inverse) / 255; let blue = (source.blue as u32 * alpha + dst_blue * inverse) / 255; self.pixels[index] = (red << 16) | (green << 8) | blue; } } fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool { if rect.width <= 0.0 || rect.height <= 0.0 { return false; } if radius <= 0.0 { return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; } // Clamp in local coordinates: adding a large origin to a shrinking tab's // half-width can otherwise round the lower bound above the upper bound. let radius = radius.min(rect.width / 2.0).min(rect.height / 2.0); let local_x = x - rect.x; let local_y = y - rect.y; let dx = local_x - local_x.clamp(radius, rect.width - radius); let dy = local_y - local_y.clamp(radius, rect.height - radius); dx * dx + dy * dy <= radius * radius } fn pack(color: Color) -> u32 { (color.red as u32) << 16 | (color.green as u32) << 8 | color.blue as u32 } #[cfg(test)] mod tests { use super::*; #[test] fn wrapped_text_uses_multiple_lines_respects_nested_clips_and_restores_the_clip() { let mut list = DisplayList::default(); list.push(DrawCommand::Clear(Color::rgb(255, 255, 255))); list.push(DrawCommand::PushClip(Rect::new(12.0, 5.0, 20.0, 40.0))); list.push(DrawCommand::WrappedText { rect: Rect::new(10.0, 5.0, 32.0, 40.0), value: "MMMMMMMMMMMMMMMM".into(), style: TextStyle::ui(1), color: Color::rgb(0, 0, 0), }); list.push(DrawCommand::PopClip); list.push(DrawCommand::WrappedText { rect: Rect::new(60.0, 0.0, 0.0, 40.0), value: "invisible".into(), style: TextStyle::ui(1), color: Color::rgb(0, 0, 0), }); list.push(DrawCommand::FillRoundedRect { rect: Rect::new(70.0, 50.0, 2.0, 2.0), radius: 0.0, color: Color::rgb(0, 0, 255), }); let mut pixels = vec![0; 80 * 60]; SoftwareRenderer::render(&list, &mut pixels, 80, 60); let mut second_line = false; for y in 0..60 { for x in 0..80 { let color = pixels[y * 80 + x]; if color != 0xffffff && color != 0x0000ff { assert!((12..32).contains(&x) && (5..45).contains(&y)); second_line |= y >= 25; } } } assert!(second_line); assert_eq!(pixels[50 * 80 + 70], 0x0000ff); } #[test] fn renderer_changes_pixels_inside_a_rectangle() { let mut list = DisplayList::default(); list.push(DrawCommand::Clear(Color::rgb(0, 0, 0))); list.push(DrawCommand::FillRoundedRect { rect: Rect::new(1.0, 1.0, 2.0, 2.0), radius: 0.0, color: Color::rgb(255, 0, 0), }); let mut pixels = vec![0; 16]; SoftwareRenderer::render(&list, &mut pixels, 4, 4); assert_eq!(pixels[5], 0x00ff0000); assert_eq!(pixels[0], 0); } #[test] fn text_edges_are_antialiased_and_respect_opacity() { let mut list = DisplayList::default(); list.push(DrawCommand::Clear(Color::rgb(0, 0, 0))); list.push(DrawCommand::Text { origin: Point::new(2.0, 2.0), value: "Hello 中文".into(), style: TextStyle::ui(1).scaled(1.25), color: Color::rgba(255, 255, 255, 128), }); let mut pixels = vec![0; 256 * 48]; SoftwareRenderer::render(&list, &mut pixels, 256, 48); assert!(pixels.iter().any(|pixel| *pixel > 0)); assert!(pixels.iter().all(|pixel| (pixel & 255) <= 128)); assert!(pixels.iter().any(|pixel| (1..128).contains(&(pixel & 255)))); let mut invisible = DisplayList::default(); invisible.push(DrawCommand::Text { origin: Point::new(2.0, 2.0), value: "Hello 中文".into(), style: TextStyle::ui(1), color: Color::rgba(255, 255, 255, 0), }); let mut pixels = vec![0; 256 * 48]; SoftwareRenderer::render(&invisible, &mut pixels, 256, 48); assert!(pixels.iter().all(|pixel| *pixel == 0)); } }