Skip to main content

agg_gui/
lcd_coverage.rs

1//! LCD subpixel text as a **per-channel coverage mask** that composites
2//! onto arbitrary backgrounds — no bg pre-fill, no destination-color
3//! knowledge required at rasterization time.
4//!
5//! # Why this replaces the pre-fill approach
6//!
7//! The older `PixfmtRgba32Lcd` path baked the caller's background colour
8//! into the rasterised output via a per-channel src-over against the
9//! pre-filled framebuffer.  That coupled the LCD glyphs to one specific
10//! destination and forced us to know that destination everywhere text is
11//! drawn — driving the walk / sample / push / pop complexity.
12//!
13//! Instead, we keep the **three subpixel coverage values independent**:
14//! the output of the rasteriser is three 8-bit channels per pixel
15//! `(cov_r, cov_g, cov_b)` describing how much of each subpixel the glyph
16//! covered.  At composite time a per-channel Porter-Duff `over` blend
17//! mixes the TEXT COLOUR into the live destination:
18//!
19//! ```text
20//! dst.r = src.r * cov.r + dst.r * (1 - cov.r)
21//! dst.g = src.g * cov.g + dst.g * (1 - cov.g)
22//! dst.b = src.b * cov.b + dst.b * (1 - cov.b)
23//! ```
24//!
25//! The coverage mask is the same regardless of where it lands; the blend
26//! naturally produces the correct LCD chroma against any background.
27//!
28//! See `lcd-subpixel-compositing.md` at the repository root for the full
29//! derivation.
30//!
31//! # Pipeline
32//!
33//! ```text
34//! shape_text (rustybuzz kerning + fallback chain — unchanged)
35//!   │
36//! per-glyph PathStorage → ConvTransform(scale_x_3) → PixfmtGray8
37//!   (8-bit grayscale coverage at 3× horizontal resolution)
38//!   │
39//! 5-tap low-pass filter per output channel
40//!   │
41//! packed (cov_r, cov_g, cov_b) 3-byte mask
42//! ```
43
44use std::cell::RefCell;
45use std::collections::{HashMap, VecDeque};
46use std::sync::Arc;
47
48use agg_rust::color::Gray8;
49use agg_rust::conv_curve::ConvCurve;
50use agg_rust::conv_transform::ConvTransform;
51
52// ---------------------------------------------------------------------------
53// LcdBuffer — opaque 3-byte-per-pixel RGB render target
54// ---------------------------------------------------------------------------
55//
56// Analogue of `Framebuffer` for widgets that opt into
57// [`crate::widget::BackbufferMode::LcdCoverage`].  Every fill into an
58// `LcdBuffer` goes through the 3× horizontal supersample + 5-tap filter
59// pipeline and composites per-channel via Porter-Duff src-over.  The
60// buffer has no alpha channel — it's intended to be fully covered by
61// opaque fills and blitted as an opaque RGB texture.
62
63/// LCD coverage buffer, row 0 = bottom (matches `Framebuffer` convention).
64///
65/// **Two planes, 3 bytes per pixel each:**
66///
67/// - `color`: per-channel **premultiplied** RGB colour accumulated from
68///   every paint so far.  `(R_color, G_color, B_color)` where each byte
69///   is `channel_color * channel_alpha`.
70/// - `alpha`: per-channel alpha/coverage accumulated from every paint so
71///   far.  `(R_alpha, G_alpha, B_alpha)` where each byte is the combined
72///   opacity of that subpixel column (0 = untouched, 255 = fully opaque).
73///
74/// **Why per-channel alpha?**  LCD subpixel rendering produces a distinct
75/// coverage value per R/G/B channel, so a single per-pixel alpha can't
76/// represent the output correctly at glyph edges and fractional image
77/// boundaries.  Splitting alpha per-channel gives each subpixel its own
78/// Porter-Duff state: paints accumulate independently through the same
79/// premultiplied src-over math you'd use for a normal RGBA surface, just
80/// three streams instead of one.  A cached `LcdBuffer` with partial
81/// coverage can be composited onto any destination without the "black
82/// rect where unpainted" failure mode that killed the first-cut design.
83pub struct LcdBuffer {
84    color:  Vec<u8>,
85    alpha:  Vec<u8>,
86    width:  u32,
87    height: u32,
88}
89
90impl LcdBuffer {
91    /// Allocate a fully-transparent buffer (color zero, alpha zero
92    /// everywhere).  "Transparent" here means the per-channel alpha is
93    /// 0, so composite-onto-destination leaves the destination
94    /// unchanged wherever no paint has landed yet.
95    pub fn new(width: u32, height: u32) -> Self {
96        let bytes = (width as usize) * (height as usize) * 3;
97        Self {
98            color: vec![0u8; bytes],
99            alpha: vec![0u8; bytes],
100            width,
101            height,
102        }
103    }
104
105    #[inline] pub fn width(&self)  -> u32 { self.width }
106    #[inline] pub fn height(&self) -> u32 { self.height }
107
108    #[inline] pub fn color_plane(&self)     -> &[u8]     { &self.color }
109    #[inline] pub fn alpha_plane(&self)     -> &[u8]     { &self.alpha }
110    #[inline] pub fn color_plane_mut(&mut self) -> &mut [u8] { &mut self.color }
111    #[inline] pub fn alpha_plane_mut(&mut self) -> &mut [u8] { &mut self.alpha }
112
113    /// Both planes mutably in one borrow — for inner loops that update
114    /// a pixel's colour and alpha together (image blit, manual composite).
115    #[inline]
116    pub fn planes_mut(&mut self) -> (&mut [u8], &mut [u8]) {
117        (&mut self.color, &mut self.alpha)
118    }
119
120    /// Consume the buffer, returning the owned `(color, alpha)` planes
121    /// as a pair — used when moving the painted pixels into `Arc`s for
122    /// a widget's backbuffer cache or for GPU texture upload.
123    pub fn into_planes(self) -> (Vec<u8>, Vec<u8>) { (self.color, self.alpha) }
124
125    /// Top-row-first copy of the colour plane, suitable for a plain
126    /// RGB8 upload or CPU blit.  Row 0 of the output is the VISUAL
127    /// top of the buffer (Y-up → Y-down flip).
128    pub fn color_plane_flipped(&self) -> Vec<u8> {
129        flip_plane(&self.color, self.width, self.height)
130    }
131
132    /// Top-row-first copy of the alpha plane.
133    pub fn alpha_plane_flipped(&self) -> Vec<u8> {
134        flip_plane(&self.alpha, self.width, self.height)
135    }
136
137    /// Collapse both planes into a single top-row-first straight-alpha
138    /// RGBA8 image suitable for the existing blit pipeline (one texture,
139    /// standard `SRC_ALPHA, ONE_MINUS_SRC_ALPHA` blend).
140    ///
141    /// The per-channel alphas get collapsed to a single per-pixel alpha
142    /// via `max(R_alpha, G_alpha, B_alpha)`; RGB is recovered by dividing
143    /// the premult colour by that max alpha (straight-alpha form).  This
144    /// conversion is **lossy** when the three subpixel alphas diverge
145    /// (the whole point of the per-channel representation is lost under
146    /// collapse).  It's correct for typical monochrome-text cases where
147    /// all three alphas agree, and degrades gracefully otherwise —
148    /// Phase 5.2's two-plane blit path preserves the full per-channel
149    /// information through upload and shader.
150    pub fn to_rgba8_top_down_collapsed(&self) -> Vec<u8> {
151        let w = self.width  as usize;
152        let h = self.height as usize;
153        let mut out = vec![0u8; w * h * 4];
154        for y in 0..h {
155            let src_y = h - 1 - y;
156            for x in 0..w {
157                let si = (src_y * w + x) * 3;
158                let di = (y * w + x) * 4;
159                let ra = self.alpha[si];
160                let ga = self.alpha[si + 1];
161                let ba = self.alpha[si + 2];
162                let a  = ra.max(ga).max(ba);
163                if a == 0 { continue; } // fully transparent → keep RGBA zero
164                let af = a as f32 / 255.0;
165                let rc = self.color[si]     as f32 / 255.0;
166                let gc = self.color[si + 1] as f32 / 255.0;
167                let bc = self.color[si + 2] as f32 / 255.0;
168                out[di]     = ((rc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
169                out[di + 1] = ((gc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
170                out[di + 2] = ((bc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
171                out[di + 3] = a;
172            }
173        }
174        out
175    }
176
177    // ── Paint primitives ────────────────────────────────────────────────────
178    //
179    // These are the foundation operations every higher layer (LcdGfxCtx,
180    // path-fill helpers, image blit) eventually composes into.  They write
181    // directly into the 3-byte-per-pixel coverage store with no intermediate
182    // allocation.
183
184    /// Fill the entire buffer with a solid colour.  Every subpixel gets
185    /// the same premultiplied colour contribution and the same alpha —
186    /// a flat clear has no per-subpixel differentiation, so the three
187    /// alpha channels are all set to `color.a` and the three colour
188    /// channels to `color.rgb * color.a`.
189    pub fn clear(&mut self, color: Color) {
190        let a  = color.a.clamp(0.0, 1.0);
191        let r_c = ((color.r.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
192        let g_c = ((color.g.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
193        let b_c = ((color.b.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
194        let a_byte = (a * 255.0 + 0.5) as u8;
195        for px in self.color.chunks_exact_mut(3) {
196            px[0] = r_c;
197            px[1] = g_c;
198            px[2] = b_c;
199        }
200        for px in self.alpha.chunks_exact_mut(3) {
201            px[0] = a_byte;
202            px[1] = a_byte;
203            px[2] = a_byte;
204        }
205    }
206
207    /// Fill an AGG path through the LCD pipeline: rasterize at 3× X
208    /// resolution → 5-tap filter → per-channel src-over composite into
209    /// this buffer.  `transform` is applied to `path` before the 3× X
210    /// scale (typically the caller's CTM); the path's coordinates are
211    /// in the buffer's pixel space (Y-up, origin = bottom-left).
212    /// Optional `clip` is a screen-space rect (post-CTM, in mask pixel
213    /// coords) — pixels outside it are unaffected.
214    ///
215    /// First non-text primitive on the buffer.  Future fill / stroke /
216    /// image-blit entry points either call this directly (for solid
217    /// fills / outlines) or open their own `LcdMaskBuilder` scope when
218    /// they need to batch many paths into one mask.
219    ///
220    /// First-cut implementation: rasterizes at the buffer's full size.
221    /// A later optimization can compute the path's bbox and size the
222    /// scratch tightly — measurable win for small paths in large
223    /// buffers, but architecturally identical and not required for
224    /// correctness.
225    pub fn fill_path(
226        &mut self,
227        path:      &mut PathStorage,
228        color:     Color,
229        transform: &TransAffine,
230        clip:      Option<(f64, f64, f64, f64)>,
231    ) {
232        if self.width == 0 || self.height == 0 { return; }
233        let mut builder = LcdMaskBuilder::new(self.width, self.height).with_clip(clip);
234        builder.with_paths(transform, |add| { add(path); });
235        let mask = builder.finalize();
236        // Convert clip → integer pixel rect for composite-time enforcement.
237        // The gray-buffer raster clip should already have zeroed coverage
238        // outside, but the 5-tap filter can leak ±2 subpixels at clip
239        // edges; composite-time clip catches that.
240        let clip_i = clip.map(rect_to_pixel_clip);
241        self.composite_mask(&mask, color, 0, 0, clip_i);
242    }
243
244    /// Composite an [`LcdMask`] into this buffer using per-channel
245    /// **premultiplied** Porter-Duff src-over.  Each subpixel column's
246    /// effective alpha is `src.a × mask.channel_coverage`, and colour +
247    /// alpha both accumulate under the standard premult src-over:
248    ///
249    /// ```text
250    /// eff_a_c        = src.a * mask.c
251    /// buf.color_c   := src.c * eff_a_c + buf.color_c * (1 - eff_a_c)
252    /// buf.alpha_c   := eff_a_c         + buf.alpha_c * (1 - eff_a_c)
253    /// ```
254    ///
255    /// `(dst_x, dst_y)` is the mask's bottom-left in this buffer's Y-up
256    /// pixel grid; mask row `my` writes to buffer row `dst_y + my`.
257    /// Optional `clip` (in this buffer's integer pixel coords:
258    /// `(x1, y1, x2, y2)`, half-open) suppresses writes outside its
259    /// bounds — used by widgets that paint inside a clipping parent.
260    pub fn composite_mask(
261        &mut self,
262        mask:  &LcdMask,
263        src:   Color,
264        dst_x: i32,
265        dst_y: i32,
266        clip:  Option<(i32, i32, i32, i32)>,
267    ) {
268        if mask.width == 0 || mask.height == 0 { return; }
269        let sa = src.a.clamp(0.0, 1.0);
270        let sr = src.r.clamp(0.0, 1.0);
271        let sg = src.g.clamp(0.0, 1.0);
272        let sb = src.b.clamp(0.0, 1.0);
273        let dst_w_i = self.width  as i32;
274        let dst_h_i = self.height as i32;
275        let dst_w_u = self.width as usize;
276        let mw = mask.width  as i32;
277        let mh = mask.height as i32;
278        let (cx1, cy1, cx2, cy2) = match clip {
279            Some((cx1, cy1, cx2, cy2)) =>
280                (cx1.max(0), cy1.max(0), cx2.min(dst_w_i), cy2.min(dst_h_i)),
281            None => (0, 0, dst_w_i, dst_h_i),
282        };
283        if cx1 >= cx2 || cy1 >= cy2 { return; }
284
285        for my in 0..mh {
286            let dy = dst_y + my;
287            if dy < cy1 || dy >= cy2 { continue; }
288            let dy_u = dy as usize;
289            for mx in 0..mw {
290                let dx = dst_x + mx;
291                if dx < cx1 || dx >= cx2 { continue; }
292                let mi = ((my * mw + mx) * 3) as usize;
293                // Per-channel effective alpha = src colour alpha × mask coverage.
294                let ea_r = sa * (mask.data[mi]     as f32 / 255.0);
295                let ea_g = sa * (mask.data[mi + 1] as f32 / 255.0);
296                let ea_b = sa * (mask.data[mi + 2] as f32 / 255.0);
297                if ea_r == 0.0 && ea_g == 0.0 && ea_b == 0.0 { continue; }
298
299                let di = (dy_u * dst_w_u + (dx as usize)) * 3;
300                // Read existing premult colour + per-channel alpha.
301                let bc_r = self.color[di]     as f32 / 255.0;
302                let bc_g = self.color[di + 1] as f32 / 255.0;
303                let bc_b = self.color[di + 2] as f32 / 255.0;
304                let ba_r = self.alpha[di]     as f32 / 255.0;
305                let ba_g = self.alpha[di + 1] as f32 / 255.0;
306                let ba_b = self.alpha[di + 2] as f32 / 255.0;
307                // Premult src-over per channel.  `src.c × eff_a` is the
308                // premultiplied source colour contribution; it adds to
309                // the buffer's existing premult colour, weighted by
310                // (1 - eff_a).  Alpha stream does the same Porter-Duff
311                // composite independently per channel.
312                let rc_r = sr * ea_r + bc_r * (1.0 - ea_r);
313                let rc_g = sg * ea_g + bc_g * (1.0 - ea_g);
314                let rc_b = sb * ea_b + bc_b * (1.0 - ea_b);
315                let ra_r = ea_r + ba_r * (1.0 - ea_r);
316                let ra_g = ea_g + ba_g * (1.0 - ea_g);
317                let ra_b = ea_b + ba_b * (1.0 - ea_b);
318
319                self.color[di]     = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
320                self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
321                self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
322                self.alpha[di]     = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
323                self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
324                self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
325            }
326        }
327    }
328
329    /// Composite `src` onto this buffer at offset `(dst_x, dst_y)` via
330    /// **per-channel premultiplied src-over** — the buffer-level
331    /// analogue of [`Self::composite_mask`].  Each of the three
332    /// subpixel columns applies `src.ch_alpha` as its own
333    /// Porter-Duff weight:
334    ///
335    /// ```text
336    /// buf.color_c := src.color_c + buf.color_c * (1 - src.alpha_c)
337    /// buf.alpha_c := src.alpha_c + buf.alpha_c * (1 - src.alpha_c)
338    /// ```
339    ///
340    /// Untouched source pixels (alpha zero on every channel) don't
341    /// change the buffer at all — exactly the semantic that makes a
342    /// popped layer leave unpainted areas alone, no seed trick needed.
343    pub fn composite_buffer(
344        &mut self,
345        src:   &LcdBuffer,
346        dst_x: i32,
347        dst_y: i32,
348        clip:  Option<(i32, i32, i32, i32)>,
349    ) {
350        if src.width == 0 || src.height == 0 { return; }
351        let dst_w_i = self.width  as i32;
352        let dst_h_i = self.height as i32;
353        let dst_w_u = self.width as usize;
354        let src_w_u = src.width  as usize;
355        let sw = src.width  as i32;
356        let sh = src.height as i32;
357        let (cx1, cy1, cx2, cy2) = match clip {
358            Some((x1, y1, x2, y2)) =>
359                (x1.max(0), y1.max(0), x2.min(dst_w_i), y2.min(dst_h_i)),
360            None => (0, 0, dst_w_i, dst_h_i),
361        };
362        if cx1 >= cx2 || cy1 >= cy2 { return; }
363
364        for sy in 0..sh {
365            let dy = dst_y + sy;
366            if dy < cy1 || dy >= cy2 { continue; }
367            let dy_u = dy as usize;
368            let sy_u = sy as usize;
369            for sx in 0..sw {
370                let dx = dst_x + sx;
371                if dx < cx1 || dx >= cx2 { continue; }
372                let si = (sy_u * src_w_u + sx as usize) * 3;
373                let di = (dy_u * dst_w_u + dx as usize) * 3;
374
375                let sa_r = src.alpha[si]     as f32 / 255.0;
376                let sa_g = src.alpha[si + 1] as f32 / 255.0;
377                let sa_b = src.alpha[si + 2] as f32 / 255.0;
378                if sa_r == 0.0 && sa_g == 0.0 && sa_b == 0.0 { continue; }
379
380                let sc_r = src.color[si]     as f32 / 255.0;
381                let sc_g = src.color[si + 1] as f32 / 255.0;
382                let sc_b = src.color[si + 2] as f32 / 255.0;
383
384                let bc_r = self.color[di]     as f32 / 255.0;
385                let bc_g = self.color[di + 1] as f32 / 255.0;
386                let bc_b = self.color[di + 2] as f32 / 255.0;
387                let ba_r = self.alpha[di]     as f32 / 255.0;
388                let ba_g = self.alpha[di + 1] as f32 / 255.0;
389                let ba_b = self.alpha[di + 2] as f32 / 255.0;
390
391                // src is already premultiplied, so `sc + bc*(1-sa)` is the
392                // plain Porter-Duff expression — no additional modulation.
393                let rc_r = sc_r + bc_r * (1.0 - sa_r);
394                let rc_g = sc_g + bc_g * (1.0 - sa_g);
395                let rc_b = sc_b + bc_b * (1.0 - sa_b);
396                let ra_r = sa_r + ba_r * (1.0 - sa_r);
397                let ra_g = sa_g + ba_g * (1.0 - sa_g);
398                let ra_b = sa_b + ba_b * (1.0 - sa_b);
399
400                self.color[di]     = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
401                self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
402                self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
403                self.alpha[di]     = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
404                self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
405                self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
406            }
407        }
408    }
409}
410
411// ── helpers ───────────────────────────────────────────────────────────────
412
413/// Y-flip a 3-byte/pixel plane (Y-up row 0 = bottom → top-row-first).
414fn flip_plane(src: &[u8], width: u32, height: u32) -> Vec<u8> {
415    let row_bytes = (width * 3) as usize;
416    let mut out = vec![0u8; src.len()];
417    for y in 0..height as usize {
418        let dst_y = height as usize - 1 - y;
419        out[dst_y * row_bytes .. (dst_y + 1) * row_bytes]
420            .copy_from_slice(&src[y * row_bytes .. (y + 1) * row_bytes]);
421    }
422    out
423}
424use agg_rust::path_storage::PathStorage;
425use agg_rust::pixfmt_gray::PixfmtGray8;
426use agg_rust::rasterizer_scanline_aa::RasterizerScanlineAa;
427use agg_rust::renderer_base::RendererBase;
428use agg_rust::renderer_scanline::render_scanlines_aa_solid;
429use agg_rust::rendering_buffer::RowAccessor;
430use agg_rust::scanline_u::ScanlineU8;
431use agg_rust::trans_affine::TransAffine;
432
433use crate::color::Color;
434use crate::text::{measure_text_metrics, shape_text, Font};
435
436/// Identity transform — exposed so call sites that don't otherwise
437/// depend on `agg_rust::trans_affine::TransAffine` can pass one.
438pub fn identity_xform() -> TransAffine { TransAffine::new() }
439
440// ---------------------------------------------------------------------------
441// Cached LCD text raster
442// ---------------------------------------------------------------------------
443//
444// The mask is fully determined by `(text, font_ptr, font_size)` — colour is
445// applied at composite time, and placement coordinates are just translations
446// the caller handles.  Caching keeps `fill_text` roughly as fast as the old
447// grayscale path: AGG rasterisation runs once per unique text string, and
448// GL backends can further cache the uploaded texture keyed on the returned
449// `Arc`'s pointer identity (see `demo-gl`'s `arc_texture_cache` pattern).
450
451/// Result of [`rasterize_text_lcd_cached`].  Callers composite the mask
452/// at `(x - baseline_x_in_mask, y - baseline_y_in_mask)` where `(x, y)`
453/// is the target baseline position in local / screen coordinates.
454pub struct CachedLcdText {
455    /// 3-byte-per-pixel coverage mask, Y-up (row 0 = bottom).  Shared
456    /// `Arc` so GL backends can key a texture cache on its pointer
457    /// identity — one upload per unique raster result.
458    pub pixels:              Arc<Vec<u8>>,
459    pub width:               u32,
460    pub height:              u32,
461    /// Mask-local x of the glyph origin (= padding inset).
462    pub baseline_x_in_mask:  f64,
463    /// Mask-local Y-up y of the glyph baseline.
464    pub baseline_y_in_mask:  f64,
465}
466
467const MASK_PAD: f64 = 2.0;
468
469#[derive(Clone, PartialEq, Eq, Hash)]
470struct LcdMaskKey {
471    text:      String,
472    font_ptr:  usize,
473    size_bits: u64,
474    /// Typography-style fingerprint — every parameter that `shape_text`
475    /// now applies must be part of the cache key, or a slider drag would
476    /// keep serving stale masks rendered in the previous style.  Bits
477    /// are read off the f64s so we inherit `Eq` / `Hash`.
478    width_bits:          u64,
479    italic_bits:         u64,
480    interval_bits:       u64,
481    hint_y:              bool,
482    faux_weight_bits:    u64,
483    primary_weight_bits: u64,
484    gamma_bits:          u64,
485}
486
487struct LcdMaskEntry {
488    pixels:              Arc<Vec<u8>>,
489    width:               u32,
490    height:              u32,
491    baseline_x_in_mask:  f64,
492    baseline_y_in_mask:  f64,
493}
494
495thread_local! {
496    static MASK_CACHE: RefCell<HashMap<LcdMaskKey, LcdMaskEntry>>
497        = RefCell::new(HashMap::new());
498    static MASK_LRU: RefCell<VecDeque<LcdMaskKey>>
499        = RefCell::new(VecDeque::new());
500}
501
502const MASK_CACHE_MAX: usize = 1024;
503
504/// Rasterise `text` in `font` at `size` into a 3-channel LCD coverage mask,
505/// caching the result so subsequent calls with the same `(text, font, size)`
506/// return the shared `Arc` without re-running AGG.
507pub fn rasterize_text_lcd_cached(
508    font: &Arc<Font>,
509    text: &str,
510    size: f64,
511) -> CachedLcdText {
512    // Snapshot the current typography style once so the same values
513    // used for the cache key are also used to size the mask below.
514    let width_now    = crate::font_settings::current_width();
515    let italic_now   = crate::font_settings::current_faux_italic();
516    let interval_now = crate::font_settings::current_interval();
517    let hint_y_now   = crate::font_settings::hinting_enabled();
518    let fweight_now  = crate::font_settings::current_faux_weight();
519    let pweight_now  = crate::font_settings::current_primary_weight();
520    let gamma_now    = crate::font_settings::current_gamma();
521
522    let key = LcdMaskKey {
523        text:      text.to_string(),
524        font_ptr:  Arc::as_ptr(font) as *const () as usize,
525        size_bits: size.to_bits(),
526        width_bits:          width_now.to_bits(),
527        italic_bits:         italic_now.to_bits(),
528        interval_bits:       interval_now.to_bits(),
529        hint_y:              hint_y_now,
530        faux_weight_bits:    fweight_now.to_bits(),
531        primary_weight_bits: pweight_now.to_bits(),
532        gamma_bits:          gamma_now.to_bits(),
533    };
534    // Cache hit path — bump LRU, return shared Arc.
535    let hit = MASK_CACHE.with(|m| {
536        m.borrow().get(&key).map(|e| CachedLcdText {
537            pixels:             Arc::clone(&e.pixels),
538            width:              e.width,
539            height:             e.height,
540            baseline_x_in_mask: e.baseline_x_in_mask,
541            baseline_y_in_mask: e.baseline_y_in_mask,
542        })
543    });
544    if let Some(got) = hit {
545        MASK_LRU.with(|lru| {
546            let mut lru = lru.borrow_mut();
547            // Move key to back (most recently used).
548            if let Some(pos) = lru.iter().position(|k| k == &key) {
549                lru.remove(pos);
550            }
551            lru.push_back(key);
552        });
553        return got;
554    }
555
556    // Cache miss — run the rasteriser.
557    let m   = measure_text_metrics(font, text, size);
558    // Extra horizontal slack when Width != 1.0 (last glyph outline is
559    // scaled beyond its advance) or Faux Italic != 0 (shear lifts the
560    // top-right of each glyph past the advance column).  Without this
561    // a slider drag past 1.0/0 would crop glyph stems at the mask
562    // edges.
563    let width_slack  = (width_now - 1.0).abs() * size;
564    let italic_slack = (italic_now.abs() / 3.0) * (m.ascent + m.descent);
565    let extra_pad    = (width_slack + italic_slack).ceil();
566    let pad_x        = MASK_PAD + extra_pad;
567    let bw  = (m.width  + pad_x   * 2.0).ceil().max(1.0) as u32;
568    let bh  = (m.ascent + m.descent + MASK_PAD * 2.0).ceil().max(1.0) as u32;
569    let bx  = pad_x;
570    // Snap the mask's internal baseline Y to a whole pixel **only when
571    // the user has hinting enabled** — the same checkbox that drives
572    // the per-glyph `gy` snap inside `shape_text`.  This keeps the
573    // two renderers aligned at integer pixels when the user opted in
574    // to hinting, and leaves both at their natural sub-pixel positions
575    // when they opted out (the small residual LCD/RGBA Y mismatch when
576    // hinting is OFF is intrinsic to LCD's composite-row-alignment
577    // requirement, not something we can paper over without forcing a
578    // permanent snap that the user explicitly rejected).
579    let by_unhinted = MASK_PAD + m.descent;
580    let by = if hint_y_now { by_unhinted.round() } else { by_unhinted };
581    let mask = rasterize_lcd_mask(
582        font, text, size, bx, by, bw, bh, &TransAffine::new(),
583    );
584    let pixels = Arc::new(mask.data);
585    let entry = LcdMaskEntry {
586        pixels:              Arc::clone(&pixels),
587        width:               bw,
588        height:              bh,
589        baseline_x_in_mask:  bx,
590        baseline_y_in_mask:  by,
591    };
592
593    MASK_CACHE.with(|m| m.borrow_mut().insert(key.clone(), entry));
594    MASK_LRU.with(|lru| {
595        let mut lru = lru.borrow_mut();
596        lru.push_back(key.clone());
597        // LRU evict to cap — drop the oldest Arc strong refs so GL
598        // texture caches holding a Weak will see them expire and
599        // release their textures.
600        while lru.len() > MASK_CACHE_MAX {
601            if let Some(old) = lru.pop_front() {
602                MASK_CACHE.with(|m| m.borrow_mut().remove(&old));
603            }
604        }
605    });
606
607    CachedLcdText {
608        pixels,
609        width:              bw,
610        height:             bh,
611        baseline_x_in_mask: bx,
612        baseline_y_in_mask: by,
613    }
614}
615
616/// 3-byte-per-pixel LCD coverage mask.  Callers composite via
617/// [`composite_lcd_mask`].  The distinction from a normal RGBA image is
618/// crucial: the three channels are **independent coverage values**, not
619/// an RGB colour — they drive a per-channel blend where each subpixel
620/// mixes the source colour with the destination colour by its own amount.
621pub struct LcdMask {
622    pub data:   Vec<u8>, // len = width * height * 3, stride = width * 3
623    pub width:  u32,
624    pub height: u32,
625}
626
627/// FreeType-default 5-tap weights; sum = 9.  Heavier filter weights reduce
628/// colour fringing at the cost of sharpness; tuning against this table is
629/// the standard knob for "darker / lighter" LCD text.  These are the
630/// legacy baked-in weights — still used as the fallback when the
631/// Primary Weight global sits at its default `1/3` (at which point
632/// `lcd_filter_weights()` below reproduces `[1, 2, 3, 2, 1] / 9`).
633const FILTER_WEIGHTS: [u32; 5] = [1, 2, 3, 2, 1];
634const FILTER_SUM:     u32       = 9;
635
636/// Per-frame tap weights for the 5-tap LCD filter, as f64 pre-normalised
637/// so the five samples always sum to 1.0.  Parameterised on the Primary
638/// Weight global (`font_settings::current_primary_weight`): the middle
639/// tap carries `p * 9` units, the two shoulder taps 2 each, the two
640/// outer taps 1 each — a direct analogue of the agg-rust
641/// `LcdDistributionLut::new(primary, 2/9, 1/9)` construction.
642///
643/// Called once per mask rasterisation; the inner loop multiplies each
644/// sample by the corresponding weight.  At the default `primary = 1/3`
645/// the output is identical (up to rounding) to the legacy integer
646/// `[1, 2, 3, 2, 1] / 9` filter.
647fn lcd_filter_weights() -> [f64; 5] {
648    let p_units = crate::font_settings::current_primary_weight() * 9.0;
649    let weights = [1.0, 2.0, p_units, 2.0, 1.0];
650    let sum = weights.iter().sum::<f64>().max(1e-9);
651    [
652        weights[0] / sum,
653        weights[1] / sum,
654        weights[2] / sum,
655        weights[3] / sum,
656        weights[4] / sum,
657    ]
658}
659
660/// Rasterize `text` at baseline `(x, y)` into a 3-channel coverage mask
661/// of size `mask_w × mask_h`.  `transform` is applied before the 3× X
662/// scale that puts the path into the high-resolution grayscale buffer.
663///
664/// The returned mask has **no colour**; at composite time `composite_lcd_mask`
665/// mixes the caller's desired text colour into the destination through the
666/// per-channel coverage.
667pub fn rasterize_lcd_mask(
668    font:      &Font,
669    text:      &str,
670    size:      f64,
671    x:         f64,
672    y:         f64,
673    mask_w:    u32,
674    mask_h:    u32,
675    transform: &TransAffine,
676) -> LcdMask {
677    rasterize_lcd_mask_multi(font, &[(text, x, y)], size, mask_w, mask_h, transform)
678}
679
680/// Multi-span variant: raster several `(text, x, y)` tuples into a
681/// single mask.  Used by wrapped-text `Label` so every line shares one
682/// 3×-wide gray buffer and one filter pass.  The gray buffer is written
683/// cumulatively by AGG (glyphs in different pixels don't interact, so
684/// non-overlapping lines just occupy disjoint rows).
685///
686/// Now a thin wrapper over [`LcdMaskBuilder`] — kept as a free function
687/// because the cached text path keys on `(text, font, size)` and never
688/// needs to interleave non-text paths.  Generic callers should reach
689/// for the builder directly.
690pub fn rasterize_lcd_mask_multi(
691    font:      &Font,
692    spans:     &[(&str, f64, f64)],
693    size:      f64,
694    mask_w:    u32,
695    mask_h:    u32,
696    transform: &TransAffine,
697) -> LcdMask {
698    let mut builder = LcdMaskBuilder::new(mask_w, mask_h);
699    builder.with_paths(transform, |add| {
700        for (text, x, y) in spans {
701            if text.is_empty() { continue; }
702            let (mut paths, _) = shape_text(font, text, size, *x, *y);
703            for path in paths.iter_mut() {
704                add(path);
705            }
706        }
707    });
708    builder.finalize()
709}
710
711/// Convert a screen-space float clip rect `(x, y, w, h)` to the
712/// integer pixel clip box `(x1, y1, x2, y2)` (half-open) used by
713/// [`LcdBuffer::composite_mask`].  Floor on the left/bottom and ceil on
714/// the right/top so any pixel touched by the clip rect (even partially)
715/// is included — matches the AGG raster-clip convention.
716pub fn rect_to_pixel_clip(rect: (f64, f64, f64, f64)) -> (i32, i32, i32, i32) {
717    let (x, y, w, h) = rect;
718    (
719        x.floor() as i32,
720        y.floor() as i32,
721        (x + w).ceil() as i32,
722        (y + h).ceil() as i32,
723    )
724}
725
726// ── LcdMaskBuilder ──────────────────────────────────────────────────────────
727//
728// Lifts the inner "rasterize one or more AGG paths at 3× X resolution →
729// 5-tap low-pass filter → packed 3-byte LCD coverage mask" pipeline out
730// of the text-only entry points so any path source can drive it.  This
731// is the seam any new caller (rect fill, stroke, future widget paint)
732// hooks into when it needs LCD-aware coverage output.
733
734/// Accumulator for an [`LcdMask`].  Build the gray buffer with one or
735/// more `with_paths` calls (each opens an AGG rasterizer scope), then
736/// `finalize` to apply the 5-tap filter and produce the packed mask.
737pub struct LcdMaskBuilder {
738    gray:   Vec<u8>,
739    gray_w: u32,
740    gray_h: u32,
741    mask_w: u32,
742    mask_h: u32,
743    /// Optional screen-space clip rect (in mask pixel coords, post-CTM).
744    /// Applied to the AGG renderer as a `clip_box_i` with X scaled by 3
745    /// before any path is added, so any rasterised coverage outside the
746    /// clip gets dropped at raster time (no need to also clip during
747    /// the filter pass — zero gray = zero mask).
748    clip:   Option<(f64, f64, f64, f64)>,
749}
750
751impl LcdMaskBuilder {
752    /// Allocate a zeroed builder for an `mask_w × mask_h` output mask.
753    /// The internal gray buffer is `(3 × mask_w) × mask_h` bytes.
754    pub fn new(mask_w: u32, mask_h: u32) -> Self {
755        let gray_w = mask_w.saturating_mul(3);
756        let gray_h = mask_h;
757        let gray   = vec![0u8; (gray_w as usize) * (gray_h as usize)];
758        Self { gray, gray_w, gray_h, mask_w, mask_h, clip: None }
759    }
760
761    /// Set a clip rectangle in screen-space (mask pixel coords).  All
762    /// subsequent `with_paths` calls render only inside the clip;
763    /// pixels outside it stay zero in the gray buffer (and therefore
764    /// produce zero coverage in the final filtered mask).  Builder-style;
765    /// chain after `new`.
766    pub fn with_clip(mut self, clip: Option<(f64, f64, f64, f64)>) -> Self {
767        self.clip = clip;
768        self
769    }
770
771    /// Open an AGG rasterizer scope and let `f` add as many paths as
772    /// it likes via the supplied `&mut FnMut(&mut PathStorage)`.  All
773    /// paths share `transform`, with X supersampled by 3 inside the
774    /// scope.  Lifetimes prevent us from keeping the renderer alive
775    /// across separate method calls (it borrows `self.gray`), so the
776    /// closure pattern scopes the borrow precisely.
777    pub fn with_paths<F>(&mut self, transform: &TransAffine, f: F)
778    where F: FnOnce(&mut dyn FnMut(&mut PathStorage)),
779    {
780        rasterize_paths_into_gray(
781            &mut self.gray, self.gray_w, self.gray_h, transform, self.clip, f,
782        );
783    }
784
785    /// Apply the 5-tap low-pass filter to the gray buffer and return
786    /// the packed mask.  Consumes the builder; callers usually composite
787    /// the result via [`LcdBuffer::composite_mask`] or
788    /// [`composite_lcd_mask`].
789    pub fn finalize(self) -> LcdMask {
790        if self.mask_w == 0 || self.mask_h == 0 {
791            return LcdMask { data: Vec::new(), width: self.mask_w, height: self.mask_h };
792        }
793        let data = apply_5_tap_filter(
794            &self.gray, self.gray_w, self.mask_w, self.mask_h,
795        );
796        LcdMask { data, width: self.mask_w, height: self.mask_h }
797    }
798}
799
800/// Internal: run one AGG rasterizer scope writing into `gray` at 3× X
801/// scale.  The closure receives an `add` function that takes a mutable
802/// `PathStorage` and renders it with curve flattening + the X-scaled
803/// transform applied.  Optional `clip` (in mask pixel coords) is
804/// applied to the renderer with X scaled by 3 to match the gray
805/// buffer; rasterised coverage outside the clip is dropped at raster
806/// time.
807fn rasterize_paths_into_gray<F>(
808    gray:      &mut [u8],
809    gray_w:    u32,
810    gray_h:    u32,
811    transform: &TransAffine,
812    clip:      Option<(f64, f64, f64, f64)>,
813    f:         F,
814)
815where F: FnOnce(&mut dyn FnMut(&mut PathStorage)),
816{
817    if gray_w == 0 || gray_h == 0 { return; }
818    let stride = gray_w as i32;
819    let mut ra = RowAccessor::new();
820    unsafe { ra.attach(gray.as_mut_ptr(), gray_w, gray_h, stride); }
821    let pf = PixfmtGray8::new(&mut ra);
822    let mut rb  = RendererBase::new(pf);
823    if let Some((cx, cy, cw, ch)) = clip {
824        // Clip box is in mask pixel coords.  The gray buffer is 3× X,
825        // so multiply X bounds by 3 to land on the right subpixels.
826        // `clip_box_i` is inclusive on both ends, so the right/top
827        // edges use `-1` after the ceil.
828        let x1 = (cx.floor() as i32).saturating_mul(3);
829        let y1 = cy.floor() as i32;
830        let x2 = ((cx + cw).ceil() as i32).saturating_mul(3) - 1;
831        let y2 = (cy + ch).ceil() as i32 - 1;
832        rb.clip_box_i(x1, y1, x2, y2);
833    }
834    let mut ras = RasterizerScanlineAa::new();
835    let mut sl  = ScanlineU8::new();
836
837    // Full coverage = 255.  AGG writes `gray_value * alpha / 255` per
838    // pixel; with value = 255 the output byte equals AGG's coverage
839    // estimate at that pixel — exactly what the 5-tap filter expects
840    // as input.
841    let cov_color = Gray8::new_opaque(255);
842
843    let mut xform = *transform;
844    xform.sx  *= 3.0;
845    xform.shx *= 3.0;
846    xform.tx  *= 3.0;
847    // shy, sy, ty unchanged — only X is supersampled.
848
849    let mut add = |path: &mut PathStorage| {
850        let mut curves = ConvCurve::new(path);
851        let mut tx     = ConvTransform::new(&mut curves, xform);
852        ras.reset();
853        ras.add_path(&mut tx, 0);
854        render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &cov_color);
855    };
856    f(&mut add);
857}
858
859/// Internal: run the 5-tap low-pass filter over `gray` and produce the
860/// packed `(R,G,B)` mask.  See module docs for the per-channel formula
861/// and phase shift.
862fn apply_5_tap_filter(gray: &[u8], gray_w: u32, mask_w: u32, mask_h: u32) -> Vec<u8> {
863    // Decide once whether the current parameters reproduce the legacy
864    // integer filter exactly.  When they do (primary = 1/3, gamma = 1),
865    // run the original byte-for-byte path so every label cached before
866    // any slider-driven raster produces the EXACT same bytes it did
867    // pre-phase-3.  This is a correctness fast path, not just a
868    // performance one — f64 arithmetic on e.g. (128+256+384+256+128)/9
869    // rounds to 127.999… which truncates to 127, where the integer
870    // version gives a clean 128.  Sub-u8 drift on cached masks is
871    // invisible in isolation but accumulates into a faint "fade"
872    // across a paragraph of text, so we keep the old path exact.
873    let primary  = crate::font_settings::current_primary_weight();
874    let gamma    = crate::font_settings::current_gamma();
875    let is_default_primary = ((primary - 1.0 / 3.0).abs()) < 1e-6;
876    let is_default_gamma   = ((gamma - 1.0).abs()) < 1e-6;
877    if is_default_primary && is_default_gamma {
878        return apply_5_tap_filter_legacy(gray, gray_w, mask_w, mask_h);
879    }
880
881    let mut data = vec![0u8; (mask_w as usize) * (mask_h as usize) * 3];
882    let gw = gray_w as i32;
883    // Parameterised path — f64 weights driven by Primary Weight, plus
884    // a gamma curve applied to the per-channel coverage AFTER the
885    // filter sum so light AA edges strengthen or weaken uniformly.
886    let w = lcd_filter_weights();
887    let inv_g = 1.0 / gamma.max(1e-3);
888    let need_gamma = !is_default_gamma;
889    let apply_gamma = |c: f64| -> f64 {
890        if !need_gamma { return c; }
891        let t = (c / 255.0).clamp(0.0, 1.0);
892        t.powf(inv_g) * 255.0
893    };
894    for py in 0..mask_h {
895        let row_start = (py as usize) * (gray_w as usize);
896        let row = &gray[row_start .. row_start + gray_w as usize];
897        for px in 0..mask_w {
898            let base = (px as i32) * 3;
899            let sample = |off: i32| -> f64 {
900                let pos = base + off;
901                if pos < 0 || pos >= gw { 0.0 } else { row[pos as usize] as f64 }
902            };
903            // R samples [-2..=2], G shifts +1, B shifts +2 (phase offsets
904            // between the three physical subpixels of the output pixel).
905            let cov_r = w[0] * sample(-2) + w[1] * sample(-1)
906                      + w[2] * sample( 0) + w[3] * sample( 1)
907                      + w[4] * sample( 2);
908            let cov_g = w[0] * sample(-1) + w[1] * sample( 0)
909                      + w[2] * sample( 1) + w[3] * sample( 2)
910                      + w[4] * sample( 3);
911            let cov_b = w[0] * sample( 0) + w[1] * sample( 1)
912                      + w[2] * sample( 2) + w[3] * sample( 3)
913                      + w[4] * sample( 4);
914            let mi = ((py as usize) * (mask_w as usize) + (px as usize)) * 3;
915            // `.round()` here matches the classic integer filter's
916            // rounding semantics more closely than bare `as u8` (which
917            // truncates) — minor but measurable difference near mid-gray.
918            data[mi]     = apply_gamma(cov_r).round().clamp(0.0, 255.0) as u8;
919            data[mi + 1] = apply_gamma(cov_g).round().clamp(0.0, 255.0) as u8;
920            data[mi + 2] = apply_gamma(cov_b).round().clamp(0.0, 255.0) as u8;
921        }
922    }
923    data
924}
925
926/// Byte-exact legacy 5-tap filter — preserved for the
927/// primary-weight = 1/3, gamma = 1 default path so cached text
928/// rasterised before phase 3 matches what we produce now.
929fn apply_5_tap_filter_legacy(gray: &[u8], gray_w: u32, mask_w: u32, mask_h: u32) -> Vec<u8> {
930    let mut data = vec![0u8; (mask_w as usize) * (mask_h as usize) * 3];
931    let gw = gray_w as i32;
932    for py in 0..mask_h {
933        let row_start = (py as usize) * (gray_w as usize);
934        let row = &gray[row_start .. row_start + gray_w as usize];
935        for px in 0..mask_w {
936            let base = (px as i32) * 3;
937            let sample = |off: i32| -> u32 {
938                let pos = base + off;
939                if pos < 0 || pos >= gw { 0 } else { row[pos as usize] as u32 }
940            };
941            let cov_r = (FILTER_WEIGHTS[0] * sample(-2)
942                       + FILTER_WEIGHTS[1] * sample(-1)
943                       + FILTER_WEIGHTS[2] * sample(0)
944                       + FILTER_WEIGHTS[3] * sample(1)
945                       + FILTER_WEIGHTS[4] * sample(2)) / FILTER_SUM;
946            let cov_g = (FILTER_WEIGHTS[0] * sample(-1)
947                       + FILTER_WEIGHTS[1] * sample(0)
948                       + FILTER_WEIGHTS[2] * sample(1)
949                       + FILTER_WEIGHTS[3] * sample(2)
950                       + FILTER_WEIGHTS[4] * sample(3)) / FILTER_SUM;
951            let cov_b = (FILTER_WEIGHTS[0] * sample(0)
952                       + FILTER_WEIGHTS[1] * sample(1)
953                       + FILTER_WEIGHTS[2] * sample(2)
954                       + FILTER_WEIGHTS[3] * sample(3)
955                       + FILTER_WEIGHTS[4] * sample(4)) / FILTER_SUM;
956            let mi = ((py as usize) * (mask_w as usize) + (px as usize)) * 3;
957            data[mi]     = cov_r.min(255) as u8;
958            data[mi + 1] = cov_g.min(255) as u8;
959            data[mi + 2] = cov_b.min(255) as u8;
960        }
961    }
962    data
963}
964
965/// Composite an [`LcdMask`] onto `dst_rgba` using per-channel Porter-Duff
966/// "over": each subpixel mixes `src_color` into the live destination by
967/// its own coverage.  The destination colour is whatever pixels are
968/// currently at the target rect — so this works over any background.
969///
970/// Both the mask and `dst_rgba` are **Y-up** (row 0 = bottom), matching
971/// `agg-gui`'s `Framebuffer` convention.  `(dst_x, dst_y)` is the mask's
972/// bottom-left in the destination's Y-up pixel grid; mask row `my` is
973/// written to destination row `dst_y + my`.
974pub fn composite_lcd_mask(
975    dst_rgba: &mut [u8],
976    dst_w:    u32,
977    dst_h:    u32,
978    mask:     &LcdMask,
979    src:      Color,
980    dst_x:    i32,
981    dst_y:    i32,
982) {
983    if mask.width == 0 || mask.height == 0 { return; }
984    let sa = src.a.clamp(0.0, 1.0);
985    let sr = src.r.clamp(0.0, 1.0);
986    let sg = src.g.clamp(0.0, 1.0);
987    let sb = src.b.clamp(0.0, 1.0);
988    let dst_w_i = dst_w as i32;
989    let dst_h_i = dst_h as i32;
990    let mw = mask.width  as i32;
991    let mh = mask.height as i32;
992
993    for my in 0..mh {
994        // Both buffers Y-up: mask row my → dst row dst_y + my.
995        let dy = dst_y + my;
996        if dy < 0 || dy >= dst_h_i { continue; }
997        for mx in 0..mw {
998            let dx = dst_x + mx;
999            if dx < 0 || dx >= dst_w_i { continue; }
1000            let mi = ((my * mw + mx) * 3) as usize;
1001            // Effective per-channel src-over weight is `mask_cov × src.a`.
1002            // Callers using a Color with alpha < 1 (e.g. placeholder text
1003            // painted in a half-opacity "dim" colour) depend on this to
1004            // get a partially-faded blit; without the alpha modulation
1005            // the blit is full-opacity regardless of src.a.
1006            let cr = (mask.data[mi]     as f32 / 255.0) * sa;
1007            let cg = (mask.data[mi + 1] as f32 / 255.0) * sa;
1008            let cb = (mask.data[mi + 2] as f32 / 255.0) * sa;
1009            if cr == 0.0 && cg == 0.0 && cb == 0.0 { continue; }
1010
1011            let di = ((dy * dst_w_i + dx) * 4) as usize;
1012            let dr = dst_rgba[di]     as f32 / 255.0;
1013            let dg = dst_rgba[di + 1] as f32 / 255.0;
1014            let db = dst_rgba[di + 2] as f32 / 255.0;
1015
1016            // Per-channel source-over in sRGB space.  Gamma-aware
1017            // linearization is the correct next step (see the design
1018            // doc); sRGB-direct is adequate for first-cut validation
1019            // and matches what FreeType does in its non-linear mode.
1020            let rr = sr * cr + dr * (1.0 - cr);
1021            let rg = sg * cg + dg * (1.0 - cg);
1022            let rbb = sb * cb + db * (1.0 - cb);
1023
1024            dst_rgba[di]     = (rr  * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
1025            dst_rgba[di + 1] = (rg  * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
1026            dst_rgba[di + 2] = (rbb * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
1027            // Alpha unchanged — mask composites onto the existing dst
1028            // without introducing transparency.
1029        }
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036    use std::sync::Arc;
1037
1038    const FONT_BYTES: &[u8] =
1039        include_bytes!("../../demo/assets/CascadiaCode.ttf");
1040
1041    fn font() -> Arc<Font> {
1042        Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
1043    }
1044
1045    /// The rasteriser must produce some non-zero coverage for ordinary
1046    /// text — sanity check that the pipeline wires up at all.
1047    #[test]
1048    fn test_lcd_mask_has_coverage() {
1049        let mask = rasterize_lcd_mask(
1050            &font(), "Hello", 16.0, 4.0, 12.0,
1051            200, 40, &TransAffine::new(),
1052        );
1053        let total: u64 = mask.data.iter().map(|&b| b as u64).sum();
1054        assert!(total > 0, "rasterize_lcd_mask produced all-zero coverage");
1055    }
1056
1057    /// Edge pixels must exhibit **per-channel variation** — the
1058    /// defining property of LCD subpixel rendering.  Without the 5-tap
1059    /// filter's phase shift between R/G/B, the three channels would be
1060    /// identical at every pixel.
1061    #[test]
1062    fn test_lcd_mask_has_channel_variation() {
1063        let mask = rasterize_lcd_mask(
1064            &font(), "Wing", 24.0, 4.0, 16.0,
1065            400, 40, &TransAffine::new(),
1066        );
1067        let mut saw = false;
1068        for px in mask.data.chunks_exact(3) {
1069            let r = px[0];
1070            let g = px[1];
1071            let b = px[2];
1072            let mx = r.max(g).max(b);
1073            let mn = r.min(g).min(b);
1074            if mx > 20 && (mx - mn) > 10 {
1075                saw = true;
1076                break;
1077            }
1078        }
1079        assert!(saw, "no per-channel variation at edges");
1080    }
1081
1082    /// Compositing the mask must mix text into any destination bg and
1083    /// produce plausibly darker pixels for dark-on-light, and plausibly
1084    /// lighter pixels for light-on-dark, regardless of which bg the mask
1085    /// was rastered against (it wasn't rastered against any).
1086    #[test]
1087    fn test_composite_dark_on_light_and_light_on_dark() {
1088        let mask = rasterize_lcd_mask(
1089            &font(), "Hi", 20.0, 2.0, 14.0,
1090            80, 24, &TransAffine::new(),
1091        );
1092
1093        // Dark text on white.
1094        let mut fb_white = vec![255u8; 80 * 24 * 4];
1095        composite_lcd_mask(&mut fb_white, 80, 24, &mask, Color::black(), 0, 0);
1096        let sum_white: u64 = fb_white.chunks_exact(4)
1097            .map(|p| (p[0] as u64 + p[1] as u64 + p[2] as u64))
1098            .sum();
1099        assert!(sum_white < 80 * 24 * 3 * 255,
1100                "dark-on-white composite left every pixel white");
1101
1102        // Light text on black.
1103        let mut fb_black = vec![0u8; 80 * 24 * 4];
1104        for chunk in fb_black.chunks_exact_mut(4) { chunk[3] = 255; }
1105        composite_lcd_mask(&mut fb_black, 80, 24, &mask, Color::white(), 0, 0);
1106        let sum_black: u64 = fb_black.chunks_exact(4)
1107            .map(|p| (p[0] as u64 + p[1] as u64 + p[2] as u64))
1108            .sum();
1109        assert!(sum_black > 0,
1110                "light-on-black composite left every pixel black");
1111    }
1112
1113    /// `composite_lcd_mask` must honour `src.a` — multiply each channel's
1114    /// coverage by the source alpha.  Without this, partial-alpha text
1115    /// (e.g. a placeholder drawn in a half-opacity "dim" colour) blits
1116    /// at full opacity, looking solid instead of faded.
1117    ///
1118    /// Regression test for the bug visible in the search-box placeholder
1119    /// where "Search..." rendered at full intensity in LCD mode.
1120    #[test]
1121    fn test_composite_lcd_mask_honours_src_alpha() {
1122        // Single pixel, full coverage on all three channels.
1123        let mask = LcdMask { data: vec![255, 255, 255], width: 1, height: 1 };
1124
1125        // Opaque black on white → full black.
1126        let mut fb_full = vec![255u8, 255, 255, 255];
1127        composite_lcd_mask(&mut fb_full, 1, 1, &mask, Color::rgba(0.0, 0.0, 0.0, 1.0), 0, 0);
1128        assert_eq!(fb_full[0], 0, "alpha=1 black-on-white should fully cover → R=0");
1129
1130        // Half-alpha black on white → ~50% grey.
1131        let mut fb_half = vec![255u8, 255, 255, 255];
1132        composite_lcd_mask(&mut fb_half, 1, 1, &mask, Color::rgba(0.0, 0.0, 0.0, 0.5), 0, 0);
1133        // Expected: cov = 1.0 × 0.5 = 0.5; dst = 0×0.5 + 255×0.5 ≈ 128.
1134        assert!(fb_half[0] >= 120 && fb_half[0] <= 135,
1135            "alpha=0.5 black-on-white should land near R=128, got {}", fb_half[0]);
1136
1137        // Zero-alpha: dst unchanged.
1138        let mut fb_zero = vec![255u8, 255, 255, 255];
1139        composite_lcd_mask(&mut fb_zero, 1, 1, &mask, Color::rgba(0.0, 0.0, 0.0, 0.0), 0, 0);
1140        assert_eq!(fb_zero[0], 255, "alpha=0 must leave destination untouched");
1141    }
1142
1143    // ── LcdBuffer paint primitives ──────────────────────────────────────────
1144
1145    /// `LcdBuffer::clear` writes the requested colour into every pixel.
1146    /// Premultiplied alpha applies uniformly across all three channels —
1147    /// the buffer has no alpha store, so partial-alpha is realised by
1148    /// darkening the colour, not by storing transparency.
1149    #[test]
1150    fn test_lcd_buffer_clear_writes_solid_color() {
1151        let mut buf = LcdBuffer::new(4, 3);
1152        buf.clear(Color::rgba(1.0, 0.5, 0.25, 1.0));
1153        for px in buf.color_plane().chunks_exact(3) {
1154            assert_eq!(px[0], 255);
1155            assert_eq!(px[1], 128);
1156            assert_eq!(px[2], 64);
1157        }
1158
1159        // Half-alpha → premultiplied colour at half intensity.
1160        let mut buf2 = LcdBuffer::new(2, 2);
1161        buf2.clear(Color::rgba(1.0, 1.0, 1.0, 0.5));
1162        for px in buf2.color_plane().chunks_exact(3) {
1163            assert_eq!(px[0], 128);
1164            assert_eq!(px[1], 128);
1165            assert_eq!(px[2], 128);
1166        }
1167    }
1168
1169    // ── Per-channel alpha: the new capability ────────────────────────────────
1170
1171    /// Fresh buffer is fully transparent (both planes zero).  This is
1172    /// the defining change from the old 3-byte LcdBuffer: unpainted
1173    /// regions no longer read as "intentional black" on composite.
1174    #[test]
1175    fn test_lcd_buffer_fresh_is_fully_transparent() {
1176        let buf = LcdBuffer::new(8, 4);
1177        assert!(buf.color_plane().iter().all(|&b| b == 0),
1178            "fresh buffer's color plane must be zero");
1179        assert!(buf.alpha_plane().iter().all(|&b| b == 0),
1180            "fresh buffer's alpha plane must be zero (= fully transparent)");
1181    }
1182
1183    /// Paint black text onto a transparent buffer.  The premultiplied
1184    /// colour is black × alpha = 0, so `color_plane` stays all zeros —
1185    /// but `alpha_plane` picks up coverage at text pixels and stays
1186    /// zero elsewhere.  That zero-alpha outside-text region is exactly
1187    /// the property that lets a cached LcdBuffer blit onto any parent
1188    /// without the "black rect where unpainted" failure mode.
1189    #[test]
1190    fn test_lcd_buffer_transparent_plus_black_text_leaves_alpha_only() {
1191        let f = font();
1192        let mask = rasterize_lcd_mask(&f, "Hi", 20.0, 2.0, 14.0, 80, 24, &TransAffine::new());
1193        let mut buf = LcdBuffer::new(80, 24);
1194        buf.composite_mask(&mask, Color::black(), 0, 0, None);
1195
1196        assert!(buf.color_plane().iter().all(|&b| b == 0),
1197            "black-text-on-transparent: premult colour is 0, so color_plane stays zero");
1198        let alpha_nonzero = buf.alpha_plane().iter().filter(|&&b| b > 0).count();
1199        assert!(alpha_nonzero > 0,
1200            "alpha_plane must show coverage where text was rasterized");
1201
1202        // Corners of the buffer (far from text) must stay fully transparent.
1203        let bottom_left_i  = 0;
1204        let bottom_right_i = (80 - 1) * 3;
1205        let top_left_i     = (23 * 80) * 3;
1206        let top_right_i    = (23 * 80 + 79) * 3;
1207        for i in [bottom_left_i, bottom_right_i, top_left_i, top_right_i] {
1208            assert_eq!(&buf.alpha_plane()[i .. i + 3], &[0u8, 0, 0],
1209                "corner at byte offset {i} should be transparent");
1210        }
1211    }
1212
1213    /// Opaque red text deposits premultiplied red into the colour plane
1214    /// AND full alpha into the alpha plane at fully-covered subpixels.
1215    /// This is the crisp case where per-channel alpha == per-channel
1216    /// coverage, no divergence.
1217    #[test]
1218    fn test_lcd_buffer_red_text_writes_premultiplied_color() {
1219        let f = font();
1220        let w = 80u32; let h = 24u32;
1221        let mask = rasterize_lcd_mask(&f, "I", 24.0, 4.0, 18.0, w, h, &TransAffine::new());
1222        let mut buf = LcdBuffer::new(w, h);
1223        buf.composite_mask(&mask, Color::rgba(1.0, 0.0, 0.0, 1.0), 0, 0, None);
1224
1225        // Look for at least one pixel where the R channel is fully
1226        // covered: R_alpha = 255, R_color = 255 (premult red × 1),
1227        // and G/B colour stay zero (red source has no G or B).
1228        let mut saw_full_red = false;
1229        for i in (0..(w * h) as usize).map(|p| p * 3) {
1230            if buf.alpha_plane()[i]     == 255
1231            && buf.color_plane()[i]     == 255
1232            && buf.color_plane()[i + 1] == 0
1233            && buf.color_plane()[i + 2] == 0
1234            {
1235                saw_full_red = true;
1236                break;
1237            }
1238        }
1239        assert!(saw_full_red, "expected at least one fully-covered pure-red pixel");
1240    }
1241
1242    /// `composite_buffer` leaves dst untouched wherever src has alpha=0.
1243    /// The defining behavioural property of the two-plane design: a
1244    /// sub-layer with painted content plus unpainted margins flushes
1245    /// back onto its parent without clobbering the margins.
1246    #[test]
1247    fn test_lcd_buffer_composite_buffer_leaves_dst_untouched_where_src_is_transparent() {
1248        // src: all transparent (no paint).
1249        let src = LcdBuffer::new(4, 4);
1250
1251        // dst: solid white.
1252        let mut dst = LcdBuffer::new(4, 4);
1253        dst.clear(Color::white());
1254
1255        // Snapshot expected values: white everywhere, full alpha.
1256        for px in dst.color_plane().chunks_exact(3) { assert_eq!(px, [255, 255, 255]); }
1257        for px in dst.alpha_plane().chunks_exact(3) { assert_eq!(px, [255, 255, 255]); }
1258
1259        // Composite transparent src onto white dst.  Must leave dst unchanged.
1260        dst.composite_buffer(&src, 0, 0, None);
1261        for px in dst.color_plane().chunks_exact(3) {
1262            assert_eq!(px, [255, 255, 255], "dst colour must survive transparent src composite");
1263        }
1264        for px in dst.alpha_plane().chunks_exact(3) {
1265            assert_eq!(px, [255, 255, 255], "dst alpha must survive transparent src composite");
1266        }
1267    }
1268
1269    /// `composite_buffer`: a fully-opaque src pixel fully replaces the
1270    /// corresponding dst pixel; a fully-transparent src pixel leaves
1271    /// dst alone.  This is exactly the Porter-Duff src-over you'd want
1272    /// for any layer-flush operation, just expressed per-channel.
1273    #[test]
1274    fn test_lcd_buffer_composite_buffer_opaque_pixel_replaces_dst() {
1275        // src: pixel (1,1) painted opaque red, rest transparent.
1276        let mut src = LcdBuffer::new(3, 3);
1277        // Manually set pixel (1,1) premultiplied red + full alpha on all three channels.
1278        let i = (1 * 3 + 1) * 3;
1279        src.color_plane_mut()[i]     = 255;  // R premult = 1.0 * 1.0 = 1.0 → 255
1280        src.color_plane_mut()[i + 1] = 0;
1281        src.color_plane_mut()[i + 2] = 0;
1282        src.alpha_plane_mut()[i]     = 255;
1283        src.alpha_plane_mut()[i + 1] = 255;
1284        src.alpha_plane_mut()[i + 2] = 255;
1285
1286        // dst: solid white.
1287        let mut dst = LcdBuffer::new(3, 3);
1288        dst.clear(Color::white());
1289
1290        dst.composite_buffer(&src, 0, 0, None);
1291
1292        // Pixel (1,1) should now be red (fully replaced).
1293        assert_eq!(&dst.color_plane()[i .. i + 3], &[255, 0, 0],
1294            "opaque src pixel must fully replace dst pixel's colour");
1295        assert_eq!(&dst.alpha_plane()[i .. i + 3], &[255, 255, 255],
1296            "alpha stays full opacity after opaque-src overwrite");
1297
1298        // Corner (0,0) — src transparent → dst white unchanged.
1299        assert_eq!(&dst.color_plane()[0 .. 3], &[255, 255, 255],
1300            "corner should retain dst white (src was transparent there)");
1301    }
1302
1303    // ── Legacy tests (opaque content — still valid under new semantics) ──────
1304
1305    /// Compositing a non-empty mask onto a cleared buffer must leave at
1306    /// least some pixels modified — proves the path connects.
1307    #[test]
1308    fn test_lcd_buffer_composite_mask_deposits_coverage() {
1309        let mask = rasterize_lcd_mask(
1310            &font(), "Hi", 20.0, 2.0, 14.0,
1311            80, 24, &TransAffine::new(),
1312        );
1313        let mut buf = LcdBuffer::new(80, 24);
1314        buf.clear(Color::white());                       // white bg
1315        let before: u64 = buf.color_plane().iter().map(|&b| b as u64).sum();
1316        buf.composite_mask(&mask, Color::black(), 0, 0, None); // black text
1317        let after: u64 = buf.color_plane().iter().map(|&b| b as u64).sum();
1318        assert!(after < before,
1319            "compositing dark text onto white bg should reduce summed brightness");
1320    }
1321
1322    // ── LcdMaskBuilder + LcdBuffer::fill_path ───────────────────────────────
1323
1324    /// **Refactor regression** — the legacy `rasterize_lcd_mask_multi`
1325    /// must produce byte-identical output after being rewritten as a
1326    /// thin wrapper over `LcdMaskBuilder`.  If the bytes drift, every
1327    /// cached glyph mask in the existing text path subtly changes and
1328    /// the equivalence chain to all the prior tests breaks.
1329    #[test]
1330    fn test_lcd_mask_builder_matches_legacy_text_path() {
1331        let f = font();
1332        let w: u32 = 120;
1333        let h: u32 = 30;
1334        let xform  = TransAffine::new();
1335
1336        // Legacy path.
1337        let legacy = rasterize_lcd_mask_multi(
1338            &f, &[("Equiv", 4.0, 18.0)], 22.0, w, h, &xform,
1339        );
1340
1341        // Builder path — same setup spelt out by hand.
1342        let mut builder = LcdMaskBuilder::new(w, h);
1343        builder.with_paths(&xform, |add| {
1344            let (mut paths, _) = crate::text::shape_text(&f, "Equiv", 22.0, 4.0, 18.0);
1345            for p in paths.iter_mut() { add(p); }
1346        });
1347        let built = builder.finalize();
1348
1349        assert_eq!(legacy.width,  built.width);
1350        assert_eq!(legacy.height, built.height);
1351        assert_eq!(legacy.data, built.data,
1352            "LcdMaskBuilder must reproduce rasterize_lcd_mask_multi byte-for-byte");
1353    }
1354
1355    /// Non-text smoke test for the path entry point — fill a small
1356    /// rectangular AGG path through the LCD pipeline and verify pixels
1357    /// inside the rect are dark, outside are untouched.  Exercises the
1358    /// builder + composite_mask seam without any text shaping involved.
1359    #[test]
1360    fn test_lcd_buffer_fill_path_solid_rect() {
1361        use agg_rust::basics::PATH_FLAGS_NONE;
1362        let mut buf = LcdBuffer::new(20, 10);
1363        buf.clear(Color::white());
1364
1365        // Rectangle from (5, 3) to (15, 7) in Y-up pixel space.
1366        let mut path = PathStorage::new();
1367        path.move_to( 5.0, 3.0);
1368        path.line_to(15.0, 3.0);
1369        path.line_to(15.0, 7.0);
1370        path.line_to( 5.0, 7.0);
1371        path.close_polygon(PATH_FLAGS_NONE);
1372
1373        buf.fill_path(&mut path, Color::black(), &TransAffine::new(), None);
1374
1375        let pixel = |x: usize, y: usize| -> (u8, u8, u8) {
1376            let i = (y * 20 + x) * 3;
1377            (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2])
1378        };
1379
1380        // Centre of rect — fully covered, must be black on every channel.
1381        assert_eq!(pixel(10, 5), (0, 0, 0),
1382            "interior pixel of solid rect should be fully covered black");
1383        // Outside rect — untouched, must stay white.
1384        assert_eq!(pixel(1, 1), (255, 255, 255),
1385            "pixel outside rect should be untouched");
1386        assert_eq!(pixel(18, 8), (255, 255, 255),
1387            "pixel outside rect should be untouched");
1388    }
1389
1390    /// **End-to-end equivalence** — proves the new path-driven LcdBuffer
1391    /// pipeline matches the existing text-driven one for the same glyph
1392    /// outlines, when both are composited onto the same starting bg.
1393    /// This is the contract the LcdGfxCtx (Step 2) relies on.
1394    #[test]
1395    fn test_lcd_buffer_fill_path_matches_text_pipeline_for_glyphs() {
1396        let f = font();
1397        let w: u32 = 80;
1398        let h: u32 = 24;
1399        let size = 18.0;
1400        let baseline = (4.0_f64, 14.0_f64);
1401
1402        // Way A — legacy: rasterize text mask, composite_mask onto white buffer.
1403        let legacy_mask = rasterize_lcd_mask_multi(
1404            &f, &[("ag", baseline.0, baseline.1)], size, w, h, &TransAffine::new(),
1405        );
1406        let mut buf_a = LcdBuffer::new(w, h);
1407        buf_a.clear(Color::white());
1408        buf_a.composite_mask(&legacy_mask, Color::black(), 0, 0, None);
1409
1410        // Way B — builder + fill_path: shape glyphs to paths, fill each onto a
1411        // freshly cleared buffer.  The end result must be pixel-identical.
1412        let (mut paths, _) = crate::text::shape_text(&f, "ag", size, baseline.0, baseline.1);
1413        let mut buf_b = LcdBuffer::new(w, h);
1414        buf_b.clear(Color::white());
1415        // Each glyph is its own path; compose them in one mask via the builder
1416        // so adjacent glyphs share the same gray buffer (matches the legacy
1417        // batching — separate fill_path calls would also work but each would
1418        // re-run the filter independently and two adjacent glyphs near a
1419        // pixel boundary could disagree on the filter input by one subpixel).
1420        let mut builder = LcdMaskBuilder::new(w, h);
1421        builder.with_paths(&TransAffine::new(), |add| {
1422            for p in paths.iter_mut() { add(p); }
1423        });
1424        let mask_b = builder.finalize();
1425        buf_b.composite_mask(&mask_b, Color::black(), 0, 0, None);
1426
1427        assert_eq!(buf_a.color_plane(), buf_b.color_plane(),
1428            "fill_path-via-builder must match legacy text mask pipeline byte-for-byte");
1429    }
1430
1431    /// **Equivalence test** — the load-bearing one for this step.
1432    ///
1433    /// Painting `text` two ways must produce identical RGB:
1434    ///
1435    ///   A. Existing `composite_lcd_mask` writing into a white RGBA frame.
1436    ///   B. New `LcdBuffer::clear(white) + composite_mask(black)` route.
1437    ///
1438    /// If these diverge, the new buffer-side compositor doesn't match the
1439    /// existing one and any LcdGfxCtx built on top of it will subtly
1440    /// disagree with the legacy text path.  This is the contract that
1441    /// future widget-level migrations rely on.
1442    #[test]
1443    fn test_lcd_buffer_composite_matches_composite_lcd_mask() {
1444        let w: u32 = 100;
1445        let h: u32 = 28;
1446        let mask = rasterize_lcd_mask(
1447            &font(), "Equiv", 22.0, 4.0, 18.0, w, h, &TransAffine::new(),
1448        );
1449
1450        // Way A — straight RGBA composite.
1451        let mut rgba = vec![255u8; (w * h * 4) as usize];
1452        composite_lcd_mask(&mut rgba, w, h, &mask, Color::black(), 0, 0);
1453
1454        // Way B — paint into LcdBuffer, then read RGB out directly.
1455        let mut buf = LcdBuffer::new(w, h);
1456        buf.clear(Color::white());
1457        buf.composite_mask(&mask, Color::black(), 0, 0, None);
1458
1459        for y in 0..h as usize {
1460            for x in 0..w as usize {
1461                let ai = (y * w as usize + x) * 4;
1462                let bi = (y * w as usize + x) * 3;
1463                let a_rgb = (rgba[ai], rgba[ai + 1], rgba[ai + 2]);
1464                let b_rgb = (buf.color_plane()[bi], buf.color_plane()[bi + 1], buf.color_plane()[bi + 2]);
1465                assert_eq!(a_rgb, b_rgb,
1466                    "RGB mismatch at ({x},{y}): RGBA-path={a_rgb:?} LcdBuffer-path={b_rgb:?}");
1467            }
1468        }
1469    }
1470
1471}