Skip to main content

agg_gui/gfx_ctx/
draw_impl.rs

1use super::*;
2
3// ---------------------------------------------------------------------------
4// Active-framebuffer helper
5// ---------------------------------------------------------------------------
6
7/// Return a `&mut Framebuffer` for the currently active render target.
8///
9/// If any layers are on the stack, returns the top layer's framebuffer.
10/// Otherwise returns the base framebuffer.  Accepts the two fields as
11/// separate `&mut` references so callers can simultaneously borrow other
12/// `GfxCtx` fields (e.g. `state`, `path`) without triggering borrow
13/// conflicts on `self`.
14#[inline]
15pub(super) fn active_fb<'a>(
16    base_fb: &'a mut Framebuffer,
17    layer_stack: &'a mut Vec<LayerEntry>,
18) -> &'a mut Framebuffer {
19    if let Some(top) = layer_stack.last_mut() {
20        &mut top.fb
21    } else {
22        base_fb
23    }
24}
25
26// ---------------------------------------------------------------------------
27// SrcOver layer compositing
28// ---------------------------------------------------------------------------
29
30/// Composite `src` onto `dst` using SrcOver alpha blending.
31///
32/// AGG writes **premultiplied** RGBA into framebuffers.  The premultiplied
33/// SrcOver formula is:
34///
35/// ```text
36/// out_channel = src_premul + dst_premul × (1 − src_alpha_norm)
37/// ```
38///
39/// This applies identically to all four channels (R, G, B, A), which makes
40/// the implementation straightforward and avoids the division step needed for
41/// straight-alpha compositing.
42///
43/// `dest_x` / `dest_y` are the Y-up pixel coordinates in `dst` where the
44/// bottom-left corner of `src` lands.  Out-of-bounds pixels are silently clipped.
45pub(super) fn composite_framebuffers(
46    dst: &mut Framebuffer,
47    src: &Framebuffer,
48    dest_x: i32,
49    dest_y: i32,
50    alpha: f64,
51    clip: Option<(f64, f64, f64, f64)>,
52) {
53    let src_w = src.width() as i32;
54    let src_h = src.height() as i32;
55    let dst_w = dst.width() as i32;
56    let dst_h = dst.height() as i32;
57
58    // Destination scissor bounds in Y-up pixel space (half-open).  `clip` is a
59    // screen-space rect in the same coordinates as `dest_x/dest_y`; a composite
60    // (e.g. a popped layer) must not paint outside the scissor that was active
61    // when the layer was pushed.
62    let (cx1, cy1, cx2, cy2) = match clip {
63        Some((cx, cy, cw, ch)) => (
64            cx.floor() as i32,
65            cy.floor() as i32,
66            (cx + cw).ceil() as i32,
67            (cy + ch).ceil() as i32,
68        ),
69        None => (0, 0, dst_w, dst_h),
70    };
71
72    let src_px = src.pixels();
73    let dst_px = dst.pixels_mut();
74
75    for sy in 0..src_h {
76        let dy = dest_y + sy;
77        if dy < 0 || dy >= dst_h || dy < cy1 || dy >= cy2 {
78            continue;
79        }
80        for sx in 0..src_w {
81            let dx = dest_x + sx;
82            if dx < 0 || dx >= dst_w || dx < cx1 || dx >= cx2 {
83                continue;
84            }
85            let si = ((sy * src_w + sx) * 4) as usize;
86            let di = ((dy * dst_w + dx) * 4) as usize;
87            let layer_alpha = alpha.clamp(0.0, 1.0) as f32;
88            let sa = (src_px[si + 3] as f32 / 255.0) * layer_alpha;
89            if sa < 1e-4 {
90                continue;
91            } // fully transparent source — skip
92            let inv_sa = 1.0 - sa;
93            // Premultiplied SrcOver — same formula for all four channels.
94            for k in 0..4 {
95                let s = src_px[si + k] as f32 * layer_alpha;
96                let d = dst_px[di + k] as f32;
97                dst_px[di + k] = (s + d * inv_sa).round().clamp(0.0, 255.0) as u8;
98            }
99        }
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Free rasterization helpers — take explicit path and fb references so they
105// can be called for both self.path draws and per-glyph text draws without
106// borrow-checker conflicts.
107// ---------------------------------------------------------------------------
108
109pub(crate) fn rasterize_fill(
110    fb: &mut Framebuffer,
111    path: &mut PathStorage,
112    color: &agg_rust::color::Rgba8,
113    mode: CompOp,
114    clip: Option<(f64, f64, f64, f64)>,
115    fill_rule: FillRule,
116    transform: &TransAffine,
117) {
118    let w = fb.width();
119    let h = fb.height();
120    let stride = (w * 4) as i32;
121    let mut ra = RowAccessor::new();
122    unsafe { ra.attach(fb.pixels_mut().as_mut_ptr(), w, h, stride) };
123    let pf = PixfmtRgba32CompOp::new_with_op(&mut ra, mode);
124    let mut rb = RendererBase::new(pf);
125    apply_clip(&mut rb, clip);
126
127    let mut ras = RasterizerScanlineAa::new();
128    ras.filling_rule(to_agg_fill_rule(fill_rule));
129    let mut sl = ScanlineU8::new();
130    let mut curves = ConvCurve::new(path);
131    let mut transformed = ConvTransform::new(&mut curves, transform.clone());
132    ras.add_path(&mut transformed, 0);
133    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, color);
134}
135
136fn to_agg_fill_rule(rule: FillRule) -> FillingRule {
137    match rule {
138        FillRule::NonZero => FillingRule::NonZero,
139        FillRule::EvenOdd => FillingRule::EvenOdd,
140    }
141}
142
143pub(crate) fn rasterize_stroke(
144    fb: &mut Framebuffer,
145    path: &mut PathStorage,
146    color: &agg_rust::color::Rgba8,
147    width: f64,
148    join: LineJoin,
149    cap: LineCap,
150    miter_limit: f64,
151    dashes: &[f64],
152    dash_offset: f64,
153    mode: CompOp,
154    clip: Option<(f64, f64, f64, f64)>,
155    transform: &TransAffine,
156) {
157    let mut curves = ConvCurve::new(path);
158    if dashes.is_empty() {
159        rasterize_stroke_source(
160            fb,
161            &mut curves,
162            color,
163            width,
164            join,
165            cap,
166            miter_limit,
167            mode,
168            clip,
169            transform,
170        );
171    } else {
172        let mut dash = ConvDash::new(&mut curves);
173        configure_dashes(&mut dash, dashes, dash_offset);
174        rasterize_stroke_source(
175            fb,
176            dash,
177            color,
178            width,
179            join,
180            cap,
181            miter_limit,
182            mode,
183            clip,
184            transform,
185        );
186    }
187}
188
189fn rasterize_stroke_source<VS: VertexSource>(
190    fb: &mut Framebuffer,
191    source: VS,
192    color: &agg_rust::color::Rgba8,
193    width: f64,
194    join: LineJoin,
195    cap: LineCap,
196    miter_limit: f64,
197    mode: CompOp,
198    clip: Option<(f64, f64, f64, f64)>,
199    transform: &TransAffine,
200) {
201    let w = fb.width();
202    let h = fb.height();
203    let stride = (w * 4) as i32;
204    let mut ra = RowAccessor::new();
205    unsafe { ra.attach(fb.pixels_mut().as_mut_ptr(), w, h, stride) };
206    let pf = PixfmtRgba32CompOp::new_with_op(&mut ra, mode);
207    let mut rb = RendererBase::new(pf);
208    apply_clip(&mut rb, clip);
209
210    let mut ras = RasterizerScanlineAa::new();
211    let mut sl = ScanlineU8::new();
212    let mut stroke = ConvStroke::new(source);
213    stroke.set_width(width);
214    stroke.set_line_join(join);
215    stroke.set_line_cap(cap);
216    stroke.set_miter_limit(miter_limit);
217    let mut transformed = ConvTransform::new(&mut stroke, transform.clone());
218    ras.add_path(&mut transformed, 0);
219    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, color);
220}
221
222fn configure_dashes<VS: VertexSource>(dash: &mut ConvDash<VS>, dashes: &[f64], dash_offset: f64) {
223    let mut chunks = dashes.chunks_exact(2);
224    for pair in &mut chunks {
225        dash.add_dash(pair[0], pair[1]);
226    }
227    if let Some(&last) = chunks.remainder().first() {
228        dash.add_dash(last, last);
229    }
230    dash.dash_start(dash_offset);
231}
232
233// ---------------------------------------------------------------------------
234// DrawCtx blanket impl for GfxCtx
235// ---------------------------------------------------------------------------
236
237impl crate::draw_ctx::DrawCtx for GfxCtx<'_> {
238    fn set_fill_color(&mut self, c: crate::color::Color) {
239        self.set_fill_color(c)
240    }
241    fn set_fill_linear_gradient(&mut self, gradient: crate::draw_ctx::LinearGradientPaint) {
242        self.set_fill_linear_gradient(gradient)
243    }
244    fn set_fill_radial_gradient(&mut self, gradient: crate::draw_ctx::RadialGradientPaint) {
245        self.set_fill_radial_gradient(gradient)
246    }
247    fn set_fill_pattern(&mut self, pattern: crate::draw_ctx::PatternPaint) {
248        self.set_fill_pattern(pattern)
249    }
250    fn supports_fill_linear_gradient(&self) -> bool {
251        true
252    }
253    fn supports_fill_radial_gradient(&self) -> bool {
254        true
255    }
256    fn supports_fill_pattern(&self) -> bool {
257        true
258    }
259    fn set_stroke_color(&mut self, c: crate::color::Color) {
260        self.set_stroke_color(c)
261    }
262    fn set_stroke_linear_gradient(&mut self, gradient: crate::draw_ctx::LinearGradientPaint) {
263        self.set_stroke_linear_gradient(gradient)
264    }
265    fn set_stroke_radial_gradient(&mut self, gradient: crate::draw_ctx::RadialGradientPaint) {
266        self.set_stroke_radial_gradient(gradient)
267    }
268    fn set_stroke_pattern(&mut self, pattern: crate::draw_ctx::PatternPaint) {
269        self.set_stroke_pattern(pattern)
270    }
271    fn supports_stroke_linear_gradient(&self) -> bool {
272        true
273    }
274    fn supports_stroke_radial_gradient(&self) -> bool {
275        true
276    }
277    fn supports_stroke_pattern(&self) -> bool {
278        true
279    }
280    fn set_line_width(&mut self, w: f64) {
281        self.set_line_width(w)
282    }
283    fn set_line_join(&mut self, j: agg_rust::math_stroke::LineJoin) {
284        self.set_line_join(j)
285    }
286    fn set_line_cap(&mut self, c: agg_rust::math_stroke::LineCap) {
287        self.set_line_cap(c)
288    }
289    fn set_miter_limit(&mut self, limit: f64) {
290        self.set_miter_limit(limit)
291    }
292    fn set_line_dash(&mut self, dashes: &[f64], offset: f64) {
293        self.set_line_dash(dashes, offset)
294    }
295    fn set_fill_rule(&mut self, rule: crate::draw_ctx::FillRule) {
296        self.set_fill_rule(rule)
297    }
298    fn set_blend_mode(&mut self, m: agg_rust::comp_op::CompOp) {
299        self.set_blend_mode(m)
300    }
301    fn set_global_alpha(&mut self, a: f64) {
302        self.set_global_alpha(a)
303    }
304    fn set_font(&mut self, f: Arc<crate::text::Font>) {
305        self.set_font(f)
306    }
307    fn set_font_size(&mut self, s: f64) {
308        self.set_font_size(s)
309    }
310    fn clip_rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
311        self.clip_rect(x, y, w, h)
312    }
313    fn reset_clip(&mut self) {
314        self.reset_clip()
315    }
316    fn clip_path(&mut self) {
317        self.clip_path()
318    }
319    fn supports_clip_path(&self) -> bool {
320        true
321    }
322    fn clear(&mut self, c: crate::color::Color) {
323        self.clear(c)
324    }
325    fn begin_path(&mut self) {
326        self.begin_path()
327    }
328    fn move_to(&mut self, x: f64, y: f64) {
329        self.move_to(x, y)
330    }
331    fn line_to(&mut self, x: f64, y: f64) {
332        self.line_to(x, y)
333    }
334    fn cubic_to(&mut self, cx1: f64, cy1: f64, cx2: f64, cy2: f64, x: f64, y: f64) {
335        self.cubic_to(cx1, cy1, cx2, cy2, x, y)
336    }
337    fn quad_to(&mut self, cx: f64, cy: f64, x: f64, y: f64) {
338        self.quad_to(cx, cy, x, y)
339    }
340    fn arc_to(&mut self, cx: f64, cy: f64, r: f64, a1: f64, a2: f64, ccw: bool) {
341        self.arc_to(cx, cy, r, a1, a2, ccw)
342    }
343    fn circle(&mut self, cx: f64, cy: f64, r: f64) {
344        self.circle(cx, cy, r)
345    }
346    fn rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
347        self.rect(x, y, w, h)
348    }
349    fn rounded_rect(&mut self, x: f64, y: f64, w: f64, h: f64, r: f64) {
350        self.rounded_rect(x, y, w, h, r)
351    }
352    fn close_path(&mut self) {
353        self.close_path()
354    }
355    fn fill(&mut self) {
356        self.fill()
357    }
358    fn stroke(&mut self) {
359        self.stroke()
360    }
361    fn fill_and_stroke(&mut self) {
362        self.fill_and_stroke()
363    }
364
365    fn draw_triangles_aa(
366        &mut self,
367        vertices: &[[f32; 3]],
368        indices: &[u32],
369        color: crate::color::Color,
370    ) {
371        // Software fallback: rasterise each triangle as a solid filled
372        // polygon.  The per-vertex `alpha` is ignored (software already has
373        // analytic AA via the scanline rasteriser), so halo quads from the
374        // GPU pipeline end up as redundant thin slivers — visually harmless
375        // but inefficient.  Callers that care should check `has_image_blit`
376        // / a similar capability flag; for now this keeps parity with the
377        // trait so the Lion demo renders correctly on the CPU path too.
378        let saved_fill = self.state.fill_color;
379        self.set_fill_color(color);
380        let n_tris = indices.len() / 3;
381        for t in 0..n_tris {
382            let i0 = indices[t * 3] as usize;
383            let i1 = indices[t * 3 + 1] as usize;
384            let i2 = indices[t * 3 + 2] as usize;
385            if i0 >= vertices.len() || i1 >= vertices.len() || i2 >= vertices.len() {
386                continue;
387            }
388            let v0 = vertices[i0];
389            let v1 = vertices[i1];
390            let v2 = vertices[i2];
391            self.begin_path();
392            self.move_to(v0[0] as f64, v0[1] as f64);
393            self.line_to(v1[0] as f64, v1[1] as f64);
394            self.line_to(v2[0] as f64, v2[1] as f64);
395            self.close_path();
396            self.fill();
397        }
398        self.set_fill_color(saved_fill);
399    }
400    fn fill_text(&mut self, t: &str, x: f64, y: f64) {
401        self.fill_text(t, x, y)
402    }
403    fn fill_text_gsv(&mut self, t: &str, x: f64, y: f64, s: f64) {
404        self.fill_text_gsv(t, x, y, s)
405    }
406    fn measure_text(&self, t: &str) -> Option<crate::text::TextMetrics> {
407        self.measure_text(t)
408    }
409    fn transform(&self) -> agg_rust::trans_affine::TransAffine {
410        self.transform()
411    }
412    fn root_transform(&self) -> agg_rust::trans_affine::TransAffine {
413        let mut t = self.transform();
414        for layer in self.layer_stack.iter().rev() {
415            t.premultiply(&agg_rust::trans_affine::TransAffine::new_translation(
416                layer.origin_x,
417                layer.origin_y,
418            ));
419        }
420        t
421    }
422    fn save(&mut self) {
423        self.save()
424    }
425    fn restore(&mut self) {
426        self.restore()
427    }
428    fn translate(&mut self, tx: f64, ty: f64) {
429        self.translate(tx, ty)
430    }
431    fn rotate(&mut self, r: f64) {
432        self.rotate(r)
433    }
434    fn scale(&mut self, sx: f64, sy: f64) {
435        self.scale(sx, sy)
436    }
437    fn set_transform(&mut self, m: agg_rust::trans_affine::TransAffine) {
438        self.set_transform(m)
439    }
440    fn reset_transform(&mut self) {
441        self.reset_transform()
442    }
443    fn push_layer(&mut self, w: f64, h: f64) {
444        self.push_layer(w, h)
445    }
446    fn supports_compositing_layers(&self) -> bool {
447        true
448    }
449    fn push_layer_with_alpha(&mut self, w: f64, h: f64, alpha: f64) {
450        self.push_layer_with_alpha(w, h, alpha)
451    }
452    fn pop_layer(&mut self) {
453        self.pop_layer()
454    }
455
456    fn has_image_blit(&self) -> bool {
457        true
458    }
459
460    fn draw_image_rgba_arc(
461        &mut self,
462        data: &Arc<Vec<u8>>,
463        img_w: u32,
464        img_h: u32,
465        dst_x: f64,
466        dst_y: f64,
467        dst_w: f64,
468        dst_h: f64,
469    ) {
470        // Software backend has no GPU texture cache; the CPU composite path
471        // is the same as the slice entry point.
472        self.draw_image_rgba(data.as_slice(), img_w, img_h, dst_x, dst_y, dst_w, dst_h);
473    }
474
475    fn draw_lcd_backbuffer_arc(
476        &mut self,
477        color: &Arc<Vec<u8>>,
478        alpha: &Arc<Vec<u8>>,
479        _content_version: u64,
480        w: u32,
481        h: u32,
482        dst_x: f64,
483        dst_y: f64,
484        _dst_w: f64,
485        _dst_h: f64,
486    ) {
487        // Software backend composites straight from the CPU planes every frame,
488        // so it has no texture cache to key on `_content_version`.
489        // Per-channel premultiplied src-over directly onto the active
490        // framebuffer.  Preserves LCD chroma: each subpixel's alpha
491        // drives the src-over of that subpixel's colour into the
492        // destination independently of the other two.
493        //
494        // Inputs are **top-row-first** (matches the cache layout); the
495        // destination `Framebuffer` is Y-up with row 0 at the bottom, so
496        // src row `sy` maps to dst row `origin_y + (h-1-sy)`.
497        if w == 0 || h == 0 {
498            return;
499        }
500        let w_u = w as usize;
501        let h_u = h as usize;
502        if color.len() < w_u * h_u * 3 || alpha.len() < w_u * h_u * 3 {
503            return;
504        }
505
506        let t = &self.state.transform;
507        let sx = (dst_x * t.sx + dst_y * t.shx + t.tx).round() as i32;
508        let sy = (dst_x * t.shy + dst_y * t.sy + t.ty).round() as i32;
509        // Honor `global_alpha`: scale both the premultiplied source colour and
510        // the per-channel coverage so LCD-cached text inside a faded subtree
511        // fades with the group.  Multiplying colour and alpha by the same
512        // factor keeps the plane premultiplied-consistent.
513        let ga = self.state.global_alpha.clamp(0.0, 1.0) as f32;
514        // Honor the active clip (screen-space Y-up AABB) so over-scan band
515        // backbuffers (see `Widget::backbuffer_band`) crop their margins to the
516        // widget bounds instead of painting over sibling widgets.
517        let clip = self.state.clip;
518        let fb = active_fb(&mut self.base_fb, &mut self.layer_stack);
519        let fw = fb.width() as i32;
520        let fh = fb.height() as i32;
521        let (cx1, cy1, cx2, cy2) = match clip {
522            Some((cx, cy, cw, ch)) => (
523                cx.floor() as i32,
524                cy.floor() as i32,
525                (cx + cw).ceil() as i32,
526                (cy + ch).ceil() as i32,
527            ),
528            None => (0, 0, fw, fh),
529        };
530        let fw_u = fw as usize;
531        let pixels = fb.pixels_mut();
532
533        for src_y in 0..h_u {
534            // Top-row-first src → Y-up dst: src row 0 (visually top)
535            // lands at dst_y + h - 1 (the visually-top dst row).
536            let dy = sy + (h_u - 1 - src_y) as i32;
537            if dy < 0 || dy >= fh || dy < cy1 || dy >= cy2 {
538                continue;
539            }
540            let dy_u = dy as usize;
541            for src_x in 0..w_u {
542                let dx = sx + src_x as i32;
543                if dx < 0 || dx >= fw || dx < cx1 || dx >= cx2 {
544                    continue;
545                }
546                let ci = (src_y * w_u + src_x) * 3;
547
548                let sa_r = (alpha[ci] as f32 / 255.0) * ga;
549                let sa_g = (alpha[ci + 1] as f32 / 255.0) * ga;
550                let sa_b = (alpha[ci + 2] as f32 / 255.0) * ga;
551                if sa_r == 0.0 && sa_g == 0.0 && sa_b == 0.0 {
552                    continue;
553                }
554
555                let sc_r = (color[ci] as f32 / 255.0) * ga;
556                let sc_g = (color[ci + 1] as f32 / 255.0) * ga;
557                let sc_b = (color[ci + 2] as f32 / 255.0) * ga;
558
559                let di = (dy_u * fw_u + dx as usize) * 4;
560                // Framebuffer holds premultiplied RGBA.  Per-channel
561                // src-over is `dst = src + dst * (1 - src_a)` since src
562                // is already premultiplied.  Alpha composites via
563                // max-channel-alpha so the destination picks up full
564                // opacity wherever any subpixel was painted — matches
565                // "this pixel was drawn on" for subsequent SrcOver blits.
566                let dc_r = pixels[di] as f32 / 255.0;
567                let dc_g = pixels[di + 1] as f32 / 255.0;
568                let dc_b = pixels[di + 2] as f32 / 255.0;
569                let da = pixels[di + 3] as f32 / 255.0;
570
571                let rc_r = sc_r + dc_r * (1.0 - sa_r);
572                let rc_g = sc_g + dc_g * (1.0 - sa_g);
573                let rc_b = sc_b + dc_b * (1.0 - sa_b);
574                let src_a_max = sa_r.max(sa_g).max(sa_b);
575                let ra = src_a_max + da * (1.0 - src_a_max);
576
577                pixels[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
578                pixels[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
579                pixels[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
580                pixels[di + 3] = (ra * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
581            }
582        }
583    }
584
585    fn has_lcd_mask_composite(&self) -> bool {
586        true
587    }
588
589    fn draw_lcd_mask(
590        &mut self,
591        mask: &[u8],
592        mask_w: u32,
593        mask_h: u32,
594        src_color: Color,
595        dst_x: f64,
596        dst_y: f64,
597    ) {
598        // Resolve to the active target (base fb or topmost layer) with
599        // the current CTM applied to the placement origin.  Both the
600        // mask and the Framebuffer are Y-up (row 0 = bottom), so mask
601        // row `my` maps directly to dst row `sy + my`.
602        if mask.len() < (mask_w as usize) * (mask_h as usize) * 3 {
603            return;
604        }
605        let t = &self.state.transform;
606        let sx = dst_x * t.sx + dst_y * t.shx + t.tx;
607        let sy = dst_x * t.shy + dst_y * t.sy + t.ty;
608        let fb = active_fb(&mut self.base_fb, &mut self.layer_stack);
609        let fw = fb.width();
610        let fh = fb.height();
611        let origin_x = sx.round() as i32;
612        let origin_y = sy.round() as i32;
613
614        let sa = src_color.a.clamp(0.0, 1.0);
615        let sr = src_color.r.clamp(0.0, 1.0);
616        let sg = src_color.g.clamp(0.0, 1.0);
617        let sb = src_color.b.clamp(0.0, 1.0);
618        let fw_i = fw as i32;
619        let fh_i = fh as i32;
620        let mw_i = mask_w as i32;
621        let mh_i = mask_h as i32;
622        let pixels = fb.pixels_mut();
623
624        for my in 0..mh_i {
625            // Mask row `my` (Y-up: 0 = bottom) → dst row `origin_y + my`
626            // in the Y-up framebuffer.  No flip.
627            let dy = origin_y + my;
628            if dy < 0 || dy >= fh_i {
629                continue;
630            }
631            for mx in 0..mw_i {
632                let dx = origin_x + mx;
633                if dx < 0 || dx >= fw_i {
634                    continue;
635                }
636                let mi = ((my * mw_i + mx) * 3) as usize;
637                // Per-channel coverage × src alpha — partial-alpha src
638                // (e.g. `text_dim` placeholder colour) fades proportionally.
639                let cr = (mask[mi] as f32 / 255.0) * sa;
640                let cg = (mask[mi + 1] as f32 / 255.0) * sa;
641                let cb = (mask[mi + 2] as f32 / 255.0) * sa;
642                if cr == 0.0 && cg == 0.0 && cb == 0.0 {
643                    continue;
644                }
645                let di = ((dy * fw_i + dx) * 4) as usize;
646                let dr = pixels[di] as f32 / 255.0;
647                let dg = pixels[di + 1] as f32 / 255.0;
648                let db = pixels[di + 2] as f32 / 255.0;
649                let rr = sr * cr + dr * (1.0 - cr);
650                let rg = sg * cg + dg * (1.0 - cg);
651                let rbb = sb * cb + db * (1.0 - cb);
652                pixels[di] = (rr * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
653                pixels[di + 1] = (rg * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
654                pixels[di + 2] = (rbb * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
655                // Alpha unchanged — we're writing onto an existing opaque
656                // (or semi-transparent) surface without introducing new
657                // transparency.
658            }
659        }
660    }
661
662    fn draw_image_rgba(
663        &mut self,
664        data: &[u8],
665        img_w: u32,
666        img_h: u32,
667        dst_x: f64,
668        dst_y: f64,
669        dst_w: f64,
670        dst_h: f64,
671    ) {
672        // Scale the source image into a temporary Framebuffer at dst size,
673        // then composite it onto the current render target using the CTM origin.
674        if img_w == 0 || img_h == 0 || dst_w < 1.0 || dst_h < 1.0 {
675            return;
676        }
677
678        let out_w = dst_w.round() as u32;
679        let out_h = dst_h.round() as u32;
680        let mut scaled = crate::framebuffer::Framebuffer::new(out_w, out_h);
681
682        // Nearest-neighbour scale — sufficient for README screenshots / badges.
683        // `data` is straight-alpha by the `draw_image_rgba` convention; AGG
684        // framebuffers store **premultiplied** RGBA, so we premultiply each
685        // sampled pixel on the way in so `composite_framebuffers` (which uses
686        // premultiplied SrcOver) blends with correct intensity.
687        let px = scaled.pixels_mut();
688        for dy in 0..out_h {
689            for dx in 0..out_w {
690                let sx = (dx as f64 / out_w as f64 * img_w as f64) as u32;
691                // Image is top-row-first; Y-up dst means we flip sy.
692                let sy_img = ((1.0 - (dy as f64 + 0.5) / out_h as f64) * img_h as f64)
693                    .floor()
694                    .clamp(0.0, (img_h - 1) as f64) as u32;
695                let si = ((sy_img * img_w + sx) * 4) as usize;
696                let di = ((dy * out_w + dx) * 4) as usize;
697                if si + 3 < data.len() && di + 3 < px.len() {
698                    let a = data[si + 3] as u32;
699                    if a == 255 {
700                        px[di] = data[si];
701                        px[di + 1] = data[si + 1];
702                        px[di + 2] = data[si + 2];
703                        px[di + 3] = 255;
704                    } else {
705                        // Premultiply: (c * a + 127) / 255 (round-half-up).
706                        px[di] = (((data[si] as u32) * a + 127) / 255) as u8;
707                        px[di + 1] = (((data[si + 1] as u32) * a + 127) / 255) as u8;
708                        px[di + 2] = (((data[si + 2] as u32) * a + 127) / 255) as u8;
709                        px[di + 3] = a as u8;
710                    }
711                }
712            }
713        }
714
715        // Apply CTM translation to get screen-space origin.
716        let (tx, ty) = {
717            let t = self.transform();
718            (t.tx, t.ty)
719        };
720        let screen_x = (tx + dst_x).round() as i32;
721        let screen_y = (ty + dst_y).round() as i32;
722        // Honor the active `global_alpha` so backbuffered widgets (Labels,
723        // buttons) inside a faded subtree fade uniformly with the rest of the
724        // group — without this the blit composited at full opacity regardless
725        // of `set_global_alpha`.
726        let ga = self.state.global_alpha;
727        // Honor the active clip (screen-space Y-up AABB) so image blits are
728        // scissored like every other primitive. Over-scan band backbuffers
729        // (see `Widget::backbuffer_band`) rely on this to crop their margins to
730        // the widget bounds; ordinary full-image blits set no tighter clip than
731        // their bounds, so this is a no-op for them.
732        let clip = self.state.clip;
733        let fb = active_fb(&mut self.base_fb, &mut self.layer_stack);
734        composite_framebuffers(fb, &scaled, screen_x, screen_y, ga, clip);
735    }
736}
737
738/// Apply a Y-up scissor clip to a `RendererBase` (pixel-inclusive coordinates).
739pub(crate) fn apply_clip<PF: agg_rust::pixfmt_rgba::PixelFormat>(
740    rb: &mut RendererBase<PF>,
741    clip: Option<(f64, f64, f64, f64)>,
742) {
743    if let Some((x, y, w, h)) = clip {
744        let x1 = x.floor() as i32;
745        let y1 = y.floor() as i32;
746        let x2 = (x + w).ceil() as i32 - 1;
747        let y2 = (y + h).ceil() as i32 - 1;
748        rb.clip_box_i(x1, y1, x2, y2);
749    }
750}