1use 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
19pub struct SoftBackend {
33 fb: Framebuffer,
34 shadow_cache: GaussianCache,
35 quality: SoftRenderQuality,
37 #[cfg(feature = "text")]
40 text_pipeline: Option<oxiui_text::TextPipeline>,
41}
42
43impl SoftBackend {
44 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 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 #[cfg(feature = "text")]
71 fn init_text_pipeline() -> Option<oxiui_text::TextPipeline> {
72 const FONT_BYTES: &[u8] = include_bytes!("../assets/fallback-font.ttf");
75 oxiui_text::TextPipeline::from_bytes(FONT_BYTES).ok()
76 }
77
78 pub fn set_quality(&mut self, quality: SoftRenderQuality) {
80 self.quality = quality;
81 }
82
83 pub fn quality(&self) -> &SoftRenderQuality {
85 &self.quality
86 }
87
88 fn aa_enabled(&self) -> bool {
90 !matches!(self.quality.aa_mode, AaMode::None)
91 }
92
93 fn shadow_enabled(&self) -> bool {
95 !matches!(self.quality.shadow_quality, ShadowQuality::Off)
96 }
97
98 pub fn frame(&self) -> &Framebuffer {
100 &self.fb
101 }
102
103 pub fn into_framebuffer(self) -> Framebuffer {
105 self.fb
106 }
107
108 pub fn clear(&mut self, color: Color) {
110 self.fb.clear(color);
111 }
112
113 #[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 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 pub fn width(&self) -> u32 {
157 self.fb.width()
158 }
159
160 pub fn height(&self) -> u32 {
162 self.fb.height()
163 }
164
165 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 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 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 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 let mut pending_clips: Vec<(f32, f32, f32, f32)> = Vec::new();
262
263 let mut shadow_clip = ClipStack::new(fb_w, fb_h);
265
266 let mut iter = list.iter().peekable();
268
269 while iter.peek().is_some() {
270 {
272 let mut canvas = Canvas::new(&mut self.fb);
273 canvas.set_aa(aa);
274
275 for &(x, y, w, h) in &pending_clips {
277 canvas.push_clip(x, y, w, h);
278 }
279
280 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 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 }
312
313 #[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 #[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#[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, };
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, };
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#[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 if px < 0 || py < 0 || px >= fb_w || py >= fb_h {
424 continue;
425 }
426 if !clip.contains(px as i64, py as i64) {
428 continue;
429 }
430 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
438pub 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 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
479fn dispatch_command(
488 canvas: &mut Canvas<'_>,
489 cache: &mut GaussianCache,
490 cmd: &DrawCommand,
491 shadow: bool,
492) {
493 match cmd {
494 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 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 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 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 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 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 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 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 }
661
662 DrawCommand::DrawText { .. } => {}
665
666 _ => {}
668 }
669}
670
671#[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]
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); 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 #[test]
702 fn glyph_blit_oob_is_noop() {
703 let mut fb = Framebuffer::new(4, 4);
704 let pixels = vec![200u8; 4 * 4];
705 blit_glyph_bitmap(&mut fb, -10, -10, 4, 4, &pixels, Color(255, 0, 0, 255));
707 assert_eq!(fb.get_rgba(0, 0), Some((0, 0, 0, 0)));
709 }
710
711 #[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]; 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 #[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; 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 assert!(r > 50, "expected partial blend, got r={r}");
730 }
731
732 #[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]
745 fn to_bytes_bgra8_rb_swap() {
746 use crate::headless::PixelFormat;
747 let mut backend = SoftBackend::new(1, 1);
749 backend.clear(Color(10, 20, 30, 255));
750 let argb = backend.to_bytes(PixelFormat::Argb32);
751 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 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]
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 #[test]
776 fn to_bytes_rgb565_round_trip_high_bits() {
777 use crate::headless::PixelFormat;
778 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 #[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 #[cfg(feature = "text")]
804 #[test]
805 fn supports_text_true_with_feature() {
806 let backend = SoftBackend::new(100, 100);
807 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 #[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 #[cfg(feature = "text")]
828 #[test]
829 fn draw_text_produces_pixels() {
830 let mut backend = SoftBackend::new(200, 50);
831 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 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 #[cfg(feature = "text")]
854 #[test]
855 fn draw_text_clip_rect_excludes() {
856 let mut backend = SoftBackend::new(200, 50);
857 backend.clear(Color(0, 0, 0, 255));
859
860 let mut dl = DrawList::new();
861 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 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 #[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 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 #[cfg(feature = "text")]
911 #[test]
912 fn draw_text_horizontal_advance() {
913 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 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 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 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 #[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 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 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 #[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}