Skip to main content

agg_gui/
lcd_gfx_ctx.rs

1//! `LcdGfxCtx` — a [`DrawCtx`] implementation whose render target is an
2//! [`LcdBuffer`] (3 bytes/pixel coverage store) instead of a regular
3//! RGBA [`Framebuffer`].  All paint primitives flow through the LCD
4//! pipeline (3× horizontal supersample → 5-tap filter → per-channel
5//! src-over) so the buffer accumulates the same per-channel coverage
6//! representation regardless of whether each call originated from a
7//! text raster, a path fill, or a future image blit.
8//!
9//! # Where this fits in the architecture
10//!
11//! When a widget opts into [`crate::widget::BackbufferMode::LcdCoverage`],
12//! `paint_subtree_backbuffered` allocates an `LcdBuffer` and hands its
13//! children an `LcdGfxCtx` (rather than a `GfxCtx` over an RGBA
14//! `Framebuffer`).  Children paint normally — the same `DrawCtx`
15//! methods, the same widget code — but every primitive flows through
16//! the LCD pipeline.  When all children have painted, the host either:
17//!
18//!   - composites the buffer onto the destination RGBA framebuffer via
19//!     [`crate::lcd_coverage::composite_lcd_mask`] (software path), or
20//!   - uploads the buffer as an RGB texture and runs the dual-source-blend
21//!     shader (GL path).
22//!
23//! Either way, the buffer is the single intermediate that decouples
24//! "what was painted" from "how it lands on the destination."
25//!
26//! # Status
27//!
28//! Step 2 of the LCD-architecture migration.  The MVP implements the
29//! primitives needed to drive an end-to-end equivalence test against
30//! the legacy `GfxCtx + lcd_mode=true` path: state setters, transform
31//! stack, axis-aligned `rect`/`fill`, `fill_text`, `clear`, and
32//! `draw_lcd_mask`.  Curve / stroke / image-blit / clip primitives are
33//! marked `// TODO step 2c` and will land before any widget actually
34//! paints into an `LcdGfxCtx`.
35
36use std::f64::consts::PI;
37use std::sync::Arc;
38
39use agg_rust::arc::Arc as AggArc;
40use agg_rust::basics::PATH_FLAGS_NONE;
41use agg_rust::comp_op::CompOp;
42use agg_rust::conv_curve::ConvCurve;
43use agg_rust::conv_stroke::ConvStroke;
44use agg_rust::gsv_text::GsvText;
45use agg_rust::math_stroke::{LineCap, LineJoin};
46use agg_rust::path_storage::PathStorage;
47use agg_rust::rounded_rect::RoundedRect;
48use agg_rust::trans_affine::TransAffine;
49
50use crate::color::Color;
51use crate::draw_ctx::DrawCtx;
52use crate::lcd_coverage::{rasterize_text_lcd_cached, LcdBuffer, LcdMask};
53use crate::text::{measure_text_metrics, Font, TextMetrics};
54
55// ── State ──────────────────────────────────────────────────────────────────
56//
57// Mirror of `GfxCtx`'s private `GfxState` so widgets that already set up
58// fill colour / font / transform on a `GfxCtx` see the same field shape
59// here — and so Step 2c can copy over any logic from the existing fill
60// paths without translation.
61
62#[derive(Clone)]
63struct LcdState {
64    transform:    TransAffine,
65    fill_color:   Color,
66    stroke_color: Color,
67    line_width:   f64,
68    line_join:    LineJoin,
69    line_cap:     LineCap,
70    blend_mode:   CompOp,
71    global_alpha: f64,
72    font:         Option<Arc<Font>>,
73    font_size:    f64,
74    /// Scissor clip in Y-up screen space `(x, y, w, h)`.  Stored but not
75    /// yet enforced — `LcdMaskBuilder` doesn't accept a clip param yet.
76    /// Step 2c.
77    clip:         Option<(f64, f64, f64, f64)>,
78}
79
80impl Default for LcdState {
81    fn default() -> Self {
82        Self {
83            transform:    TransAffine::new(),
84            fill_color:   Color::black(),
85            stroke_color: Color::black(),
86            line_width:   1.0,
87            line_join:    LineJoin::Round,
88            line_cap:     LineCap::Round,
89            blend_mode:   CompOp::SrcOver,
90            global_alpha: 1.0,
91            font:         None,
92            font_size:    16.0,
93            clip:         None,
94        }
95    }
96}
97
98// ── LcdLayer ───────────────────────────────────────────────────────────────
99//
100// One entry on the `LcdGfxCtx` layer stack, created by `push_layer`.
101// Owns its own `LcdBuffer`; `pop_layer` flushes it back into the
102// previously-active buffer at the recorded origin.
103//
104// Mirrors the role of `gfx_ctx::LayerEntry` — the field shape is kept
105// close so widget code that uses `push_layer` / `pop_layer` has the
106// same mental model on either ctx type.  The compositing semantics
107// differ (see `LcdBuffer::composite_buffer`): RGBA layers do
108// alpha-aware SrcOver, LCD layers do full-replace, because the
109// coverage buffer has no alpha to distinguish "untouched" from
110// "intentionally black".
111
112struct LcdLayer {
113    buffer:      LcdBuffer,
114    /// State snapshot at the moment `push_layer` was called.  Restored
115    /// verbatim on `pop_layer` so transform / clip / colour all return
116    /// to their pre-layer values.
117    saved_state: LcdState,
118    saved_stack: Vec<LcdState>,
119    /// Where the layer's bottom-left lands in the parent buffer's
120    /// coords.  Captured from the CTM's translation at push time.
121    origin_x:    f64,
122    origin_y:    f64,
123}
124
125// ── LcdGfxCtx ──────────────────────────────────────────────────────────────
126
127/// Cairo-style stateful 2D graphics context whose render target is an
128/// [`LcdBuffer`].  Borrows the buffer mutably for the lifetime of the
129/// ctx; let the ctx drop and the buffer is free to be uploaded /
130/// composited / read.
131pub struct LcdGfxCtx<'a> {
132    base_buffer: &'a mut LcdBuffer,
133    /// Offscreen layer stack.  Empty when rendering directly to
134    /// `base_buffer`.  Each `push_layer` pushes a new owned
135    /// `LcdBuffer`; subsequent paint primitives target the topmost
136    /// layer until the matching `pop_layer` flushes it back.
137    layer_stack: Vec<LcdLayer>,
138    state:       LcdState,
139    state_stack: Vec<LcdState>,
140    /// Accumulated path, reset by `begin_path`.  Same role as in
141    /// `GfxCtx` — the `fill` / `stroke` calls consume it.
142    path:        PathStorage,
143}
144
145impl<'a> LcdGfxCtx<'a> {
146    pub fn new(buffer: &'a mut LcdBuffer) -> Self {
147        Self {
148            base_buffer: buffer,
149            layer_stack: Vec::new(),
150            state:       LcdState::default(),
151            state_stack: Vec::new(),
152            path:        PathStorage::new(),
153        }
154    }
155
156    /// Read-only view of the underlying buffer — for callers that need
157    /// to inspect output without releasing the ctx.  Returns the base
158    /// buffer; callers inspecting mid-paint while a layer is active
159    /// see only state committed before the current layer's push.
160    pub fn buffer(&self) -> &LcdBuffer { self.base_buffer }
161
162    /// Active paint target: the topmost layer's buffer if any, else
163    /// the base buffer.  Every paint primitive routes through this so
164    /// `push_layer`/`pop_layer` redirects automatically.
165    fn active_buffer(&mut self) -> &mut LcdBuffer {
166        if let Some(layer) = self.layer_stack.last_mut() {
167            &mut layer.buffer
168        } else {
169            &mut *self.base_buffer
170        }
171    }
172}
173
174// ── DrawCtx impl ───────────────────────────────────────────────────────────
175
176impl<'a> DrawCtx for LcdGfxCtx<'a> {
177    // ── State ─────────────────────────────────────────────────────────────
178    fn set_fill_color  (&mut self, color: Color) { self.state.fill_color   = color; }
179    fn set_stroke_color(&mut self, color: Color) { self.state.stroke_color = color; }
180    fn set_line_width  (&mut self, w: f64)       { self.state.line_width   = w; }
181    fn set_line_join   (&mut self, j: LineJoin)  { self.state.line_join    = j; }
182    fn set_line_cap    (&mut self, c: LineCap)   { self.state.line_cap     = c; }
183    fn set_blend_mode  (&mut self, m: CompOp)    { self.state.blend_mode   = m; }
184    fn set_global_alpha(&mut self, a: f64)       { self.state.global_alpha = a.clamp(0.0, 1.0); }
185
186    // ── Font ──────────────────────────────────────────────────────────────
187    fn set_font     (&mut self, f: Arc<Font>) { self.state.font      = Some(f); }
188    fn set_font_size(&mut self, s: f64)       { self.state.font_size = s.max(1.0); }
189
190    // ── Clipping ──────────────────────────────────────────────────────────
191    fn clip_rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
192        // TODO step 2c — currently stored but not enforced; LcdMaskBuilder
193        // needs a clip-aware variant before we can honour it during fill.
194        let t = &self.state.transform;
195        let corners = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)];
196        let mut sx_min = f64::INFINITY;
197        let mut sy_min = f64::INFINITY;
198        let mut sx_max = f64::NEG_INFINITY;
199        let mut sy_max = f64::NEG_INFINITY;
200        for (lx, ly) in corners {
201            let mut sx = lx; let mut sy = ly;
202            t.transform(&mut sx, &mut sy);
203            if sx < sx_min { sx_min = sx; }
204            if sx > sx_max { sx_max = sx; }
205            if sy < sy_min { sy_min = sy; }
206            if sy > sy_max { sy_max = sy; }
207        }
208        let new_clip = (sx_min, sy_min, (sx_max - sx_min).max(0.0), (sy_max - sy_min).max(0.0));
209        self.state.clip = Some(match self.state.clip {
210            Some((cx, cy, cw, ch)) => {
211                let x1 = sx_min.max(cx);
212                let y1 = sy_min.max(cy);
213                let x2 = (new_clip.0 + new_clip.2).min(cx + cw);
214                let y2 = (new_clip.1 + new_clip.3).min(cy + ch);
215                (x1, y1, (x2 - x1).max(0.0), (y2 - y1).max(0.0))
216            }
217            None => new_clip,
218        });
219    }
220    fn reset_clip(&mut self) { self.state.clip = None; }
221
222    // ── Clear ─────────────────────────────────────────────────────────────
223    fn clear(&mut self, color: Color) { self.active_buffer().clear(color); }
224
225    // ── Path building ─────────────────────────────────────────────────────
226    fn begin_path(&mut self)                  { self.path = PathStorage::new(); }
227    fn move_to(&mut self, x: f64, y: f64)     { self.path.move_to(x, y); }
228    fn line_to(&mut self, x: f64, y: f64)     { self.path.line_to(x, y); }
229    fn cubic_to(&mut self, cx1: f64, cy1: f64, cx2: f64, cy2: f64, x: f64, y: f64) {
230        self.path.curve4(cx1, cy1, cx2, cy2, x, y);
231    }
232    fn quad_to(&mut self, cx: f64, cy: f64, x: f64, y: f64) {
233        self.path.curve3(cx, cy, x, y);
234    }
235    fn arc_to(&mut self, cx: f64, cy: f64, r: f64, start_angle: f64, end_angle: f64, ccw: bool) {
236        let mut arc = AggArc::new(cx, cy, r, r, start_angle, end_angle, ccw);
237        self.path.concat_path(&mut arc, 0);
238    }
239    fn circle(&mut self, cx: f64, cy: f64, r: f64) {
240        self.arc_to(cx, cy, r, 0.0, 2.0 * PI, true);
241        self.path.close_polygon(PATH_FLAGS_NONE);
242    }
243    fn rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
244        self.path.move_to(x, y);
245        self.path.line_to(x + w, y);
246        self.path.line_to(x + w, y + h);
247        self.path.line_to(x, y + h);
248        self.path.close_polygon(PATH_FLAGS_NONE);
249    }
250    fn rounded_rect(&mut self, x: f64, y: f64, w: f64, h: f64, r: f64) {
251        let r = r.min(w * 0.5).min(h * 0.5).max(0.0);
252        let mut rr = RoundedRect::new(x, y, x + w, y + h, r);
253        rr.normalize_radius();
254        self.path.concat_path(&mut rr, 0);
255    }
256    fn close_path(&mut self) { self.path.close_polygon(PATH_FLAGS_NONE); }
257
258    // ── Path drawing ──────────────────────────────────────────────────────
259    fn fill(&mut self) {
260        let mut color = self.state.fill_color;
261        color.a *= self.state.global_alpha as f32;
262        let xform = self.state.transform;
263        let clip  = self.state.clip;
264        // Borrow gymnastics: `fill_path` needs `&mut path` AND `&mut buffer`,
265        // both fields of `self`.  Take the path out, fill into the active
266        // buffer, then put the path back — preserves the "path persists
267        // across fill calls" GfxCtx contract.
268        let mut path = std::mem::replace(&mut self.path, PathStorage::new());
269        self.active_buffer().fill_path(&mut path, color, &xform, clip);
270        self.path = path;
271    }
272    fn stroke(&mut self) {
273        // Materialize the stroked outline as a flat polygon, then route it
274        // through the same `fill_path` LCD pipeline as a regular fill.
275        // This is one indirection more than `GfxCtx::stroke` (which feeds
276        // `ConvStroke` straight to AGG) — we accept the extra `concat_path`
277        // because it avoids duplicating the gray-buffer scaffolding here
278        // and keeps `LcdBuffer::fill_path` the single inner primitive.
279        //
280        // Stroke width is in user coordinates (matches `GfxCtx`): the CTM
281        // applied inside `fill_path` scales it just like any other geometry,
282        // so a 1-px stroke at scale=2 paints 2 pixels wide.
283        let mut color = self.state.stroke_color;
284        color.a *= self.state.global_alpha as f32;
285        let mut materialized = PathStorage::new();
286        {
287            let mut curves = ConvCurve::new(&mut self.path);
288            let mut stroke = ConvStroke::new(&mut curves);
289            stroke.set_width(self.state.line_width);
290            stroke.set_line_join(self.state.line_join);
291            stroke.set_line_cap(self.state.line_cap);
292            materialized.concat_path(&mut stroke, 0);
293        }
294        let xform = self.state.transform;
295        let clip  = self.state.clip;
296        self.active_buffer().fill_path(&mut materialized, color, &xform, clip);
297    }
298    fn fill_and_stroke(&mut self) {
299        self.fill();
300        self.stroke();
301    }
302
303    fn draw_triangles_aa(
304        &mut self,
305        vertices: &[[f32; 3]],
306        indices:  &[u32],
307        color:    crate::color::Color,
308    ) {
309        // LCD-coverage-cache backbuffer doesn't have a dedicated halo-AA
310        // path; rasterise each triangle as a solid fill, same as the
311        // software `GfxCtx` path.
312        let saved_fill = self.state.fill_color;
313        self.state.fill_color = color;
314        let n = indices.len() / 3;
315        for t in 0..n {
316            let i0 = indices[t * 3    ] as usize;
317            let i1 = indices[t * 3 + 1] as usize;
318            let i2 = indices[t * 3 + 2] as usize;
319            if i0 >= vertices.len() || i1 >= vertices.len() || i2 >= vertices.len() { continue; }
320            let v0 = vertices[i0];
321            let v1 = vertices[i1];
322            let v2 = vertices[i2];
323            self.begin_path();
324            self.move_to(v0[0] as f64, v0[1] as f64);
325            self.line_to(v1[0] as f64, v1[1] as f64);
326            self.line_to(v2[0] as f64, v2[1] as f64);
327            self.close_path();
328            self.fill();
329        }
330        self.state.fill_color = saved_fill;
331    }
332
333    // ── Text ──────────────────────────────────────────────────────────────
334    fn fill_text(&mut self, text: &str, x: f64, y: f64) {
335        let font = match self.state.font.clone() {
336            Some(f) => f,
337            None => return,
338        };
339        let mut color = self.state.fill_color;
340        color.a *= self.state.global_alpha as f32;
341
342        // HiDPI: rasterise at the **physical** font size (logical × CTM
343        // scale).  See `gfx_ctx::fill_text` for the long version; short
344        // version: the mask composites 1:1 at its rasterised pixel count,
345        // so caching at logical size would shrink text on 2×/3× displays.
346        let t = &self.state.transform;
347        let ctm_scale = (t.sx * t.sx + t.shy * t.shy).sqrt().max(1e-6);
348        let phys_size = self.state.font_size * ctm_scale;
349        let cached = rasterize_text_lcd_cached(&font, text, phys_size);
350        // Match the legacy CPU LCD compositor: apply CTM to the destination
351        // origin, then snap to integer pixels.  Sub-pixel placement of an
352        // LCD mask smears the per-channel phase pattern across pixel
353        // boundaries (see `gfx_ctx::draw_lcd_mask` for the long story).
354        // Divide `baseline_*_in_mask` by `ctm_scale` so offsets stay in
355        // logical units that the CTM multiplies back to physical.
356        let dst_x = x - cached.baseline_x_in_mask / ctm_scale;
357        let dst_y = y - cached.baseline_y_in_mask / ctm_scale;
358        let sx = (dst_x * t.sx + dst_y * t.shx + t.tx).round() as i32;
359        let sy = (dst_x * t.shy + dst_y * t.sy + t.ty).round() as i32;
360
361        // Construct a borrowed-shape `LcdMask` for the cached bytes.  The
362        // clone is wasteful — Step 2b should give `composite_mask` a
363        // slice variant so we can hand it `&cached.pixels[..]` with no
364        // allocation.  For an MVP it doesn't matter.
365        let mask = LcdMask {
366            data:   (*cached.pixels).clone(),
367            width:  cached.width,
368            height: cached.height,
369        };
370        let clip_i = self.state.clip.map(crate::lcd_coverage::rect_to_pixel_clip);
371        self.active_buffer().composite_mask(&mask, color, sx, sy, clip_i);
372    }
373
374    fn fill_text_gsv(&mut self, text: &str, x: f64, y: f64, size: f64) {
375        // GSV is AGG's stroke-vector font — used for placeholder text
376        // before the real font is loaded.  We materialize the stroked
377        // outline into a flat path and feed it through `fill_path`,
378        // same shape as `stroke`.  Stroke width follows GfxCtx's choice
379        // of `size * 0.1` for visual parity.
380        let mut color = self.state.fill_color;
381        color.a *= self.state.global_alpha as f32;
382        let mut gsv = GsvText::new();
383        gsv.size(size, 0.0);
384        gsv.start_point(x, y);
385        gsv.text(text);
386        let mut materialized = PathStorage::new();
387        {
388            let mut stroke = ConvStroke::new(&mut gsv);
389            stroke.set_width(size * 0.1);
390            materialized.concat_path(&mut stroke, 0);
391        }
392        let xform = self.state.transform;
393        let clip  = self.state.clip;
394        self.active_buffer().fill_path(&mut materialized, color, &xform, clip);
395    }
396
397    fn measure_text(&self, text: &str) -> Option<TextMetrics> {
398        let font = self.state.font.as_ref()?;
399        Some(measure_text_metrics(font, text, self.state.font_size))
400    }
401
402    // ── Transform ─────────────────────────────────────────────────────────
403    fn transform(&self) -> TransAffine { self.state.transform }
404    fn save   (&mut self) { self.state_stack.push(self.state.clone()); }
405    fn restore(&mut self) {
406        if let Some(s) = self.state_stack.pop() { self.state = s; }
407    }
408    fn translate(&mut self, tx: f64, ty: f64) {
409        self.state.transform.premultiply(&TransAffine::new_translation(tx, ty));
410    }
411    fn rotate(&mut self, radians: f64) {
412        self.state.transform.premultiply(&TransAffine::new_rotation(radians));
413    }
414    fn scale(&mut self, sx: f64, sy: f64) {
415        self.state.transform.premultiply(&TransAffine::new_scaling(sx, sy));
416    }
417    fn set_transform(&mut self, m: TransAffine) { self.state.transform = m; }
418    fn reset_transform(&mut self)               { self.state.transform = TransAffine::new(); }
419
420    // ── Compositing layers ────────────────────────────────────────────────
421    //
422    // `push_layer` redirects subsequent paint into a fresh `LcdBuffer`;
423    // `pop_layer` flushes that buffer back into the previously-active
424    // one at the layer's recorded origin (the CTM translation at push
425    // time).  Compositing is full-replace — see `LcdBuffer::composite_buffer`
426    // for why LCD layers can't do alpha-aware SrcOver and what that
427    // means for callers.
428
429    fn push_layer(&mut self, width: f64, height: f64) {
430        let origin_x = self.state.transform.tx;
431        let origin_y = self.state.transform.ty;
432        let lw = width.ceil().max(1.0)  as u32;
433        let lh = height.ceil().max(1.0) as u32;
434        let mut layer_buffer = LcdBuffer::new(lw, lh);
435
436        // Seed the layer with the parent's pixels at the layer's bounds.
437        // Without this, an LCD layer would composite-replace the parent
438        // region with its zero-init (black) wherever the user didn't paint —
439        // any "untouched" pixel inside the layer would visibly clear the
440        // parent on pop.  GfxCtx's RGBA layer dodges this with
441        // alpha=0 + SrcOver; LcdBuffer has no alpha, so we inherit the
442        // parent's content as the "neutral" starting state instead.
443        let dx = -(origin_x.round() as i32);
444        let dy = -(origin_y.round() as i32);
445        let parent_ref: &LcdBuffer = if let Some(layer) = self.layer_stack.last() {
446            &layer.buffer
447        } else {
448            &*self.base_buffer
449        };
450        layer_buffer.composite_buffer(parent_ref, dx, dy, None);
451
452        let saved_state = self.state.clone();
453        let saved_stack = std::mem::take(&mut self.state_stack);
454        self.layer_stack.push(LcdLayer {
455            buffer: layer_buffer,
456            saved_state,
457            saved_stack,
458            origin_x,
459            origin_y,
460        });
461        // Drawing inside the layer uses local coords (origin = layer's
462        // bottom-left).  Match `GfxCtx::push_layer` semantics — the new
463        // sub-region paints into a clean transform / no clip.
464        self.state.transform = TransAffine::new();
465        self.state.clip      = None;
466    }
467
468    fn pop_layer(&mut self) {
469        let Some(layer) = self.layer_stack.pop() else { return; };
470        // Restore the state snapshot captured at push time.
471        self.state       = layer.saved_state;
472        self.state_stack = layer.saved_stack;
473        // Composite the layer onto whatever buffer is now active (could
474        // be the base buffer, or another layer if we were nested).
475        // Origin is in the parent's coords; round so the layer lands on
476        // the integer pixel grid (same reason `draw_lcd_mask` rounds).
477        let dst_x = layer.origin_x.round() as i32;
478        let dst_y = layer.origin_y.round() as i32;
479        let clip_i = self.state.clip.map(crate::lcd_coverage::rect_to_pixel_clip);
480        self.active_buffer().composite_buffer(&layer.buffer, dst_x, dst_y, clip_i);
481    }
482
483    // ── LCD mask compositing — native format for this ctx ─────────────────
484    //
485    // Unlike `GfxCtx` (which has a separate `lcd_mode` flag), an
486    // `LcdGfxCtx`'s render target IS an LCD coverage buffer.  Compositing
487    // an `LcdMask` is the most direct primitive available.
488
489    fn draw_lcd_mask(
490        &mut self,
491        mask:      &[u8],
492        mask_w:    u32,
493        mask_h:    u32,
494        src_color: Color,
495        dst_x:     f64,
496        dst_y:     f64,
497    ) {
498        if mask.len() < (mask_w as usize) * (mask_h as usize) * 3 { return; }
499        let lcd_mask = LcdMask { data: mask.to_vec(), width: mask_w, height: mask_h };
500        let t = &self.state.transform;
501        let sx = (dst_x * t.sx + dst_y * t.shx + t.tx).round() as i32;
502        let sy = (dst_x * t.shy + dst_y * t.sy + t.ty).round() as i32;
503        let clip_i = self.state.clip.map(crate::lcd_coverage::rect_to_pixel_clip);
504        self.active_buffer().composite_mask(&lcd_mask, src_color, sx, sy, clip_i);
505    }
506
507    fn has_lcd_mask_composite(&self) -> bool { true }
508
509    // ── Image blitting ────────────────────────────────────────────────────
510    //
511    // Images are written as plain colour content — every subpixel of every
512    // destination pixel mixes the source colour by the source's alpha.
513    // We deliberately DON'T run image data through the 3× supersample +
514    // 5-tap filter (the pipeline is for coverage, not colour) — that would
515    // smear chroma across pixel boundaries and tint sharp icon edges with
516    // R/G/B fringing.  This matches the convention of every LCD text
517    // renderer (FreeType / CoreText / DirectWrite): subpixel treatment is
518    // for glyph coverage; bitmaps go through standard alpha compositing.
519
520    fn has_image_blit(&self) -> bool { true }
521
522    fn draw_image_rgba(
523        &mut self,
524        data:  &[u8],
525        img_w: u32,
526        img_h: u32,
527        dst_x: f64,
528        dst_y: f64,
529        dst_w: f64,
530        dst_h: f64,
531    ) {
532        if img_w == 0 || img_h == 0 { return; }
533        if dst_w <= 0.0 || dst_h <= 0.0 { return; }
534        if data.len() < (img_w as usize) * (img_h as usize) * 4 { return; }
535
536        // Apply CTM to destination origin, snap to integer pixel grid.
537        // Pixel-snap matters here for the same reason it matters for LCD
538        // text: NEAREST sampling at fractional offsets picks the wrong
539        // texel half the time and the icon visibly shifts.  Sample-area
540        // size is taken from the CTM's scale factors — for the typical
541        // pure-translation CTM that's just dst_w × dst_h.
542        let t = &self.state.transform;
543        let ox = (dst_x * t.sx + dst_y * t.shx + t.tx).round() as i32;
544        let oy = (dst_x * t.shy + dst_y * t.sy + t.ty).round() as i32;
545        let scaled_w = ((dst_w * t.sx).abs()).round() as i32;
546        let scaled_h = ((dst_h * t.sy).abs()).round() as i32;
547        if scaled_w <= 0 || scaled_h <= 0 { return; }
548
549        let global_alpha = (self.state.global_alpha as f32).clamp(0.0, 1.0);
550        let clip_i       = self.state.clip.map(crate::lcd_coverage::rect_to_pixel_clip);
551
552        let buf = self.active_buffer();
553        let buf_w   = buf.width()  as i32;
554        let buf_h   = buf.height() as i32;
555        let buf_w_u = buf_w as usize;
556        let img_w_u = img_w as usize;
557
558        // Intersect any active clip with the buffer's bounds — the inner
559        // loop becomes a single range check per pixel against this rect.
560        let (cx1, cy1, cx2, cy2) = match clip_i {
561            Some((x1, y1, x2, y2)) => (x1.max(0), y1.max(0), x2.min(buf_w), y2.min(buf_h)),
562            None => (0, 0, buf_w, buf_h),
563        };
564        if cx1 >= cx2 || cy1 >= cy2 { return; }
565
566        let (color_plane, alpha_plane) = buf.planes_mut();
567        for ly in 0..scaled_h {
568            let dy = oy + ly;
569            if dy < cy1 || dy >= cy2 { continue; }
570            // ly = 0 is bottom of dst rect (Y-up).  Source image is stored
571            // top-row-first, so the bottom of the visual image is row
572            // `img_h - 1` and that's what we sample first.
573            let frac_y = (ly as f64 + 0.5) / (scaled_h as f64);
574            let sy_visual = (frac_y * img_h as f64) as u32;
575            let sy_visual = sy_visual.min(img_h - 1);
576            let sy_storage = (img_h - 1 - sy_visual) as usize;
577
578            for lx in 0..scaled_w {
579                let dx = ox + lx;
580                if dx < cx1 || dx >= cx2 { continue; }
581                let frac_x = (lx as f64 + 0.5) / (scaled_w as f64);
582                let sx_storage = ((frac_x * img_w as f64) as u32).min(img_w - 1) as usize;
583
584                // Source image is straight-alpha RGBA; effective src alpha =
585                // image alpha × ctx global_alpha.  Regular images have one
586                // alpha per pixel — we apply it identically across all three
587                // subpixel channels (no per-subpixel variation for source).
588                // That's the one case where the per-channel-alpha buffer
589                // takes redundant data; true per-subpixel image edges would
590                // come from a rasteriser-based image path, not NEAREST blit.
591                let si = (sy_storage * img_w_u + sx_storage) * 4;
592                let sa = (data[si + 3] as f32 / 255.0) * global_alpha;
593                if sa <= 0.0 { continue; }
594                let sr = (data[si]     as f32 / 255.0) * sa;   // premultiply
595                let sg = (data[si + 1] as f32 / 255.0) * sa;
596                let sb = (data[si + 2] as f32 / 255.0) * sa;
597
598                let di = ((dy as usize) * buf_w_u + (dx as usize)) * 3;
599
600                // Read current premult colour + per-channel alpha.
601                let bc_r = color_plane[di]     as f32 / 255.0;
602                let bc_g = color_plane[di + 1] as f32 / 255.0;
603                let bc_b = color_plane[di + 2] as f32 / 255.0;
604                let ba_r = alpha_plane[di]     as f32 / 255.0;
605                let ba_g = alpha_plane[di + 1] as f32 / 255.0;
606                let ba_b = alpha_plane[di + 2] as f32 / 255.0;
607
608                // Premult src-over per channel (all three share `sa` since
609                // the source image had a single per-pixel alpha).
610                let rc_r = sr + bc_r * (1.0 - sa);
611                let rc_g = sg + bc_g * (1.0 - sa);
612                let rc_b = sb + bc_b * (1.0 - sa);
613                let ra_r = sa + ba_r * (1.0 - sa);
614                let ra_g = sa + ba_g * (1.0 - sa);
615                let ra_b = sa + ba_b * (1.0 - sa);
616
617                color_plane[di]     = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
618                color_plane[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
619                color_plane[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
620                alpha_plane[di]     = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
621                alpha_plane[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
622                alpha_plane[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
623            }
624        }
625    }
626}
627
628// ── Tests ──────────────────────────────────────────────────────────────────
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use crate::framebuffer::Framebuffer;
634    use crate::gfx_ctx::GfxCtx;
635
636    const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
637
638    fn font() -> Arc<Font> {
639        Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
640    }
641
642    /// Smoke test: an `LcdGfxCtx` constructed over a fresh `LcdBuffer`
643    /// can `clear` + `set_fill_color` + `set_font` + `fill_text` without
644    /// panicking, and produces non-zero coverage somewhere.  Catches
645    /// any state-plumbing typo that would silently no-op the path.
646    #[test]
647    fn test_lcd_gfx_ctx_basic_fill_text_smoke() {
648        let mut buf = LcdBuffer::new(80, 24);
649        {
650            let mut ctx = LcdGfxCtx::new(&mut buf);
651            ctx.clear(Color::white());
652            ctx.set_fill_color(Color::black());
653            ctx.set_font(font());
654            ctx.set_font_size(16.0);
655            ctx.fill_text("ABC", 4.0, 14.0);
656        }
657        // Some pixels should be darker than white (where text was painted).
658        let any_dark = buf.color_plane().chunks_exact(3)
659            .any(|p| p[0] < 250 || p[1] < 250 || p[2] < 250);
660        assert!(any_dark, "fill_text via LcdGfxCtx left buffer fully white");
661    }
662
663    /// **End-to-end equivalence (Step 2 contract).**
664    ///
665    /// Painting the SAME text two ways must produce byte-identical RGB:
666    ///
667    ///   A. Legacy: `GfxCtx` over an RGBA `Framebuffer` with `lcd_mode=true`.
668    ///   B. New:    `LcdGfxCtx` over an `LcdBuffer`.
669    ///
670    /// Both routes go through `rasterize_text_lcd_cached` (same mask) and
671    /// per-channel src-over compositing (same math); the only difference
672    /// is destination format (4 bytes vs 3 bytes per pixel).  If the RGB
673    /// triplets diverge, the new ctx is producing a different mask
674    /// placement or compositor than the existing one, and any widget
675    /// rewired to paint into an `LcdGfxCtx` would visibly disagree with
676    /// today's text rendering.  This is the contract Step 3 (wiring the
677    /// ctx into `paint_subtree_backbuffered`) builds on.
678    #[test]
679    fn test_lcd_gfx_ctx_text_matches_legacy_lcd_mode() {
680        let f  = font();
681        let w  = 120u32;
682        let h  = 28u32;
683
684        // Way A — legacy `GfxCtx + lcd_mode=true` onto RGBA `Framebuffer`.
685        let mut fb = Framebuffer::new(w, h);
686        {
687            let mut ctx = GfxCtx::new(&mut fb);
688            ctx.set_lcd_mode(true);
689            ctx.clear(Color::white());
690            ctx.set_fill_color(Color::black());
691            ctx.set_font(Arc::clone(&f));
692            ctx.set_font_size(18.0);
693            <GfxCtx as DrawCtx>::fill_text(&mut ctx, "Hello!", 4.0, 18.0);
694        }
695
696        // Way B — new `LcdGfxCtx` onto `LcdBuffer`.
697        let mut buf = LcdBuffer::new(w, h);
698        {
699            let mut ctx = LcdGfxCtx::new(&mut buf);
700            ctx.clear(Color::white());
701            ctx.set_fill_color(Color::black());
702            ctx.set_font(Arc::clone(&f));
703            ctx.set_font_size(18.0);
704            ctx.fill_text("Hello!", 4.0, 18.0);
705        }
706
707        // Compare RGB triplets at every pixel — alpha column in `fb`
708        // is not part of the contract (LcdBuffer has no alpha to match
709        // against).
710        for y in 0..h as usize {
711            for x in 0..w as usize {
712                let ai = (y * w as usize + x) * 4;
713                let bi = (y * w as usize + x) * 3;
714                let a_rgb = (fb.pixels()[ai], fb.pixels()[ai + 1], fb.pixels()[ai + 2]);
715                let b_rgb = (buf.color_plane()[bi], buf.color_plane()[bi + 1], buf.color_plane()[bi + 2]);
716                assert_eq!(a_rgb, b_rgb,
717                    "pixel mismatch at ({x},{y}): legacy={a_rgb:?} LcdGfxCtx={b_rgb:?}");
718            }
719        }
720    }
721
722    // ── Step 2c: stroke / arc / circle / rounded_rect / image blit ──────────
723
724    /// `stroke` of a horizontal line must deposit dark pixels along the
725    /// line's path.  Uses width=1, so we expect the line's row to read
726    /// noticeably darker than the surrounding rows.
727    #[test]
728    fn test_lcd_gfx_ctx_stroke_horizontal_line() {
729        let mut buf = LcdBuffer::new(20, 11);
730        {
731            let mut ctx = LcdGfxCtx::new(&mut buf);
732            ctx.clear(Color::white());
733            ctx.set_stroke_color(Color::black());
734            ctx.set_line_width(1.0);
735            ctx.begin_path();
736            ctx.move_to(2.0, 5.0);
737            ctx.line_to(18.0, 5.0);
738            ctx.stroke();
739        }
740        let row_brightness = |y: usize| -> u32 {
741            (4..16).map(|x| {
742                let i = (y * 20 + x) * 3;
743                buf.color_plane()[i] as u32 + buf.color_plane()[i + 1] as u32 + buf.color_plane()[i + 2] as u32
744            }).sum()
745        };
746        let line  = row_brightness(5);  // line row in Y-up
747        let above = row_brightness(8);
748        let below = row_brightness(2);
749        assert!(line < above, "stroke row should be darker than row above (line={line}, above={above})");
750        assert!(line < below, "stroke row should be darker than row below (line={line}, below={below})");
751    }
752
753    /// `circle` then `fill` must darken the centre but leave a corner
754    /// well outside the disc untouched — proves arc emission + concat
755    /// produce a closed region rather than degenerating to nothing.
756    #[test]
757    fn test_lcd_gfx_ctx_circle_darkens_center_not_corner() {
758        let mut buf = LcdBuffer::new(20, 20);
759        {
760            let mut ctx = LcdGfxCtx::new(&mut buf);
761            ctx.clear(Color::white());
762            ctx.set_fill_color(Color::black());
763            ctx.begin_path();
764            ctx.circle(10.0, 10.0, 5.0);
765            ctx.fill();
766        }
767        let pixel = |x: usize, y: usize| -> (u8, u8, u8) {
768            let i = (y * 20 + x) * 3;
769            (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2])
770        };
771        let (cr, cg, cb) = pixel(10, 10);
772        assert!(cr < 60 && cg < 60 && cb < 60,
773            "circle centre should be dark; got ({cr}, {cg}, {cb})");
774        let (xr, xg, xb) = pixel(1, 1);
775        assert!(xr > 240 && xg > 240 && xb > 240,
776            "outside-circle corner should stay white; got ({xr}, {xg}, {xb})");
777    }
778
779    /// `rounded_rect` — corner pixels must remain background (rounded
780    /// off), while the centre is filled.  Catches a missing
781    /// `concat_path` or a bogus radius normalize that would degenerate
782    /// the rounded rect to a sharp rect or to nothing.
783    ///
784    /// Rect (0,0)–(20,20) with r=8: the BL corner arc has centre (8,8)
785    /// and radius 8, so any pixel outside that arc (distance from (8,8)
786    /// > 8) but inside the bbox is in the "rounded-off" region.  We
787    /// pick (1,1) which is ~9.9 px from (8,8) — well past the arc edge,
788    /// so AA leak from the LCD filter (which has ±2 subpixel = ~0.67
789    /// pixel reach) cannot reach it.
790    #[test]
791    fn test_lcd_gfx_ctx_rounded_rect_clips_corners() {
792        let mut buf = LcdBuffer::new(20, 20);
793        {
794            let mut ctx = LcdGfxCtx::new(&mut buf);
795            ctx.clear(Color::white());
796            ctx.set_fill_color(Color::black());
797            ctx.begin_path();
798            ctx.rounded_rect(0.0, 0.0, 20.0, 20.0, 8.0);
799            ctx.fill();
800        }
801        let pixel = |x: usize, y: usize| -> (u8, u8, u8) {
802            let i = (y * 20 + x) * 3;
803            (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2])
804        };
805        // Centre fully inside the rounded rect → dark.
806        let (cr, cg, cb) = pixel(10, 10);
807        assert!(cr < 50 && cg < 50 && cb < 50,
808            "rounded rect centre should be dark; got ({cr}, {cg}, {cb})");
809        // Far corner of the bbox (1, 1) — beyond the corner arc, inside
810        // the rounded-off region.  Must remain white.
811        let (xr, xg, xb) = pixel(1, 1);
812        assert!(xr > 240 && xg > 240 && xb > 240,
813            "rounded rect corner area should stay white; got ({xr}, {xg}, {xb})");
814        // Mid-edge (10, 1) — inside the rect on its straight bottom edge,
815        // far from any corner arc.  Must be dark.
816        let (er, eg, eb) = pixel(10, 1);
817        assert!(er < 50 && eg < 50 && eb < 50,
818            "rounded rect mid-edge should be dark; got ({er}, {eg}, {eb})");
819    }
820
821    /// Image blit with Y-flip: a 2×2 source image with distinct colours
822    /// per cell (top-left=red, top-right=green, bottom-left=blue,
823    /// bottom-right=opaque-grey).  After blit into a Y-up LcdBuffer at
824    /// (1,1), the source's top row must land at the buffer's TOP-of-rect
825    /// row (Y-up = higher Y), the bottom row at the BOTTOM-of-rect row.
826    /// Catches any Y-flip arithmetic mistake.
827    #[test]
828    fn test_lcd_gfx_ctx_image_blit_y_flips_correctly() {
829        // RGBA, top-row first.
830        let img: Vec<u8> = vec![
831            // Row 0 (top): red, green
832            255,   0,   0, 255,    0, 255,   0, 255,
833            // Row 1 (bottom): blue, grey
834              0,   0, 255, 255,  128, 128, 128, 255,
835        ];
836        let mut buf = LcdBuffer::new(8, 8);
837        {
838            let mut ctx = LcdGfxCtx::new(&mut buf);
839            ctx.clear(Color::black());
840            ctx.draw_image_rgba(&img, 2, 2, 1.0, 1.0, 2.0, 2.0);
841        }
842        let pixel = |x: usize, y: usize| -> (u8, u8, u8) {
843            let i = (y * 8 + x) * 3;
844            (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2])
845        };
846        // Y-up: y=1 is bottom row of dst rect, y=2 is top.  Source's top
847        // row (row 0 in storage) is the visually-top row, which lands at
848        // buffer y=2.
849        assert_eq!(pixel(1, 2), (255,   0,   0), "top-left source must land at top-left of dst rect (Y-up high)");
850        assert_eq!(pixel(2, 2), (  0, 255,   0), "top-right source must land at top-right of dst rect");
851        assert_eq!(pixel(1, 1), (  0,   0, 255), "bottom-left source must land at bottom-left of dst rect (Y-up low)");
852        assert_eq!(pixel(2, 1), (128, 128, 128), "bottom-right source must land at bottom-right of dst rect");
853        // Outside the blit rect — untouched.
854        assert_eq!(pixel(0, 0), (0, 0, 0), "pixel outside blit rect should be untouched");
855    }
856
857    /// Image blit alpha — a half-transparent source over a known bg
858    /// must produce per-channel src-over output (alpha is the same on
859    /// all three subpixels for image data, by design).
860    #[test]
861    fn test_lcd_gfx_ctx_image_blit_alpha_blends_with_destination() {
862        // Single pixel: red at 50% alpha (straight-alpha encoding).
863        let img: Vec<u8> = vec![255, 0, 0, 128];
864        let mut buf = LcdBuffer::new(4, 4);
865        {
866            let mut ctx = LcdGfxCtx::new(&mut buf);
867            ctx.clear(Color::white());
868            ctx.draw_image_rgba(&img, 1, 1, 1.0, 1.0, 1.0, 1.0);
869        }
870        let i = (1 * 4 + 1) * 3;
871        let (r, g, b) = (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2]);
872        // Expected: src(255,0,0) * 0.502 + dst(255,255,255) * 0.498
873        //         = (255, ~127, ~127)  (slightly biased by quantization)
874        assert!(r > 250,           "R should be near 255 (bg + src red); got {r}");
875        assert!(g > 120 && g < 140, "G should be near 127 (white minus alpha-attenuated red); got {g}");
876        assert!(b > 120 && b < 140, "B should be near 127; got {b}");
877    }
878
879    // ── Step 2d.1: clip enforcement ─────────────────────────────────────────
880
881    /// `fill` of a rect that crosses the clip boundary must darken
882    /// only the pixels inside the clip; the half outside the clip
883    /// stays untouched.  Catches a missing clip plumb-through to
884    /// either the AGG raster step or the composite step.
885    #[test]
886    fn test_lcd_gfx_ctx_clip_rect_constrains_fill() {
887        let mut buf = LcdBuffer::new(20, 10);
888        {
889            let mut ctx = LcdGfxCtx::new(&mut buf);
890            ctx.clear(Color::white());
891            ctx.set_fill_color(Color::black());
892            ctx.clip_rect(0.0, 0.0, 10.0, 10.0);   // clip to LEFT half
893            ctx.begin_path();
894            ctx.rect(2.0, 2.0, 16.0, 6.0);          // straddles the clip edge
895            ctx.fill();
896        }
897        let pixel = |x: usize, y: usize| -> (u8, u8, u8) {
898            let i = (y * 20 + x) * 3;
899            (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2])
900        };
901        // Inside clip + inside rect → dark.
902        let (lr, lg, lb) = pixel(5, 5);
903        assert!(lr < 50 && lg < 50 && lb < 50,
904            "pixel inside clip + rect should be dark; got ({lr}, {lg}, {lb})");
905        // Outside clip but inside rect → must stay white.
906        let (rr, rg, rb) = pixel(15, 5);
907        assert!(rr > 240 && rg > 240 && rb > 240,
908            "pixel outside clip should stay white; got ({rr}, {rg}, {rb})");
909    }
910
911    /// `fill_text` honours the clip — text that runs past the clip
912    /// edge should leave the post-clip region untouched.  Set up a
913    /// long string and a short clip; sample beyond the clip edge.
914    #[test]
915    fn test_lcd_gfx_ctx_clip_rect_constrains_fill_text() {
916        let mut buf = LcdBuffer::new(120, 24);
917        {
918            let mut ctx = LcdGfxCtx::new(&mut buf);
919            ctx.clear(Color::white());
920            ctx.set_fill_color(Color::black());
921            ctx.set_font(font());
922            ctx.set_font_size(18.0);
923            ctx.clip_rect(0.0, 0.0, 40.0, 24.0);    // clip to first ~40 px
924            ctx.fill_text("MMMMMMMMMMMM", 2.0, 18.0);
925        }
926        // Inside clip, on glyph stroke → expect some dark pixel in the
927        // first 40 px columns.
928        let mut saw_dark_inside = false;
929        for x in 0..40 {
930            for y in 0..24 {
931                let i = (y * 120 + x) * 3;
932                if buf.color_plane()[i] < 100 { saw_dark_inside = true; break; }
933            }
934            if saw_dark_inside { break; }
935        }
936        assert!(saw_dark_inside, "expected some dark text pixel inside the clip");
937
938        // Outside clip — every pixel beyond x=42 (a small margin past
939        // the clip edge to absorb the 5-tap filter's ±2 subpixel reach)
940        // must remain white.
941        for x in 42..120 {
942            for y in 0..24 {
943                let i = (y * 120 + x) * 3;
944                let (r, g, b) = (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2]);
945                assert!(r > 240 && g > 240 && b > 240,
946                    "pixel at ({x},{y}) outside clip should stay white; got ({r}, {g}, {b})");
947            }
948        }
949    }
950
951    /// `draw_image_rgba` honours the clip — pixels outside the clip
952    /// rect stay untouched even though the source image's destination
953    /// rect overlaps them.
954    #[test]
955    fn test_lcd_gfx_ctx_clip_rect_constrains_image_blit() {
956        // Solid red 10×10 RGBA.
957        let img: Vec<u8> = (0..10*10).flat_map(|_| [255u8, 0, 0, 255]).collect();
958        let mut buf = LcdBuffer::new(20, 10);
959        {
960            let mut ctx = LcdGfxCtx::new(&mut buf);
961            ctx.clear(Color::white());
962            ctx.clip_rect(0.0, 0.0, 5.0, 10.0);     // clip to leftmost 5 columns
963            ctx.draw_image_rgba(&img, 10, 10, 0.0, 0.0, 10.0, 10.0);
964        }
965        let pixel = |x: usize, y: usize| -> (u8, u8, u8) {
966            let i = (y * 20 + x) * 3;
967            (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2])
968        };
969        // Inside clip → red.
970        assert_eq!(pixel(2, 5), (255, 0, 0), "inside clip should show source red");
971        // Outside clip → white (image suppressed there).
972        assert_eq!(pixel(7, 5), (255, 255, 255), "outside clip should stay white");
973    }
974
975    /// `reset_clip` removes a previously-set clip — paint after the
976    /// reset should reach the full buffer again.
977    #[test]
978    fn test_lcd_gfx_ctx_reset_clip_restores_full_buffer() {
979        let mut buf = LcdBuffer::new(20, 10);
980        {
981            let mut ctx = LcdGfxCtx::new(&mut buf);
982            ctx.clear(Color::white());
983            ctx.set_fill_color(Color::black());
984            ctx.clip_rect(0.0, 0.0, 5.0, 10.0);
985            ctx.reset_clip();
986            ctx.begin_path();
987            ctx.rect(2.0, 2.0, 16.0, 6.0);          // would be clipped at x=5 if clip remained
988            ctx.fill();
989        }
990        // Pixel at x=15 should now be dark (no clip blocking it).
991        let i = (5 * 20 + 15) * 3;
992        let (r, g, b) = (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2]);
993        assert!(r < 50 && g < 50 && b < 50,
994            "after reset_clip, fill at x=15 should be dark; got ({r}, {g}, {b})");
995    }
996
997    /// Nested `clip_rect` calls intersect — the second call narrows
998    /// the active clip, doesn't replace it.  Mirrors `GfxCtx::clip_rect`
999    /// semantics so widget code that nests clips behaves identically.
1000    #[test]
1001    fn test_lcd_gfx_ctx_clip_rect_nests_via_intersection() {
1002        let mut buf = LcdBuffer::new(20, 20);
1003        {
1004            let mut ctx = LcdGfxCtx::new(&mut buf);
1005            ctx.clear(Color::white());
1006            ctx.set_fill_color(Color::black());
1007            // Outer clip: left half.
1008            ctx.clip_rect(0.0, 0.0, 10.0, 20.0);
1009            // Inner clip: top half.  Intersection = top-left quadrant.
1010            ctx.clip_rect(0.0, 10.0, 20.0, 10.0);
1011            ctx.begin_path();
1012            ctx.rect(0.0, 0.0, 20.0, 20.0);          // would fill everything if no clip
1013            ctx.fill();
1014        }
1015        let pixel = |x: usize, y: usize| -> (u8, u8, u8) {
1016            let i = (y * 20 + x) * 3;
1017            (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2])
1018        };
1019        // Top-left (inside intersection) — dark.
1020        let (tlr, tlg, tlb) = pixel(2, 17);
1021        assert!(tlr < 50 && tlg < 50 && tlb < 50,
1022            "top-left should be dark; got ({tlr}, {tlg}, {tlb})");
1023        // Top-right (outside outer clip) — white.
1024        let (trr, trg, trb) = pixel(17, 17);
1025        assert!(trr > 240 && trg > 240 && trb > 240,
1026            "top-right should stay white; got ({trr}, {trg}, {trb})");
1027        // Bottom-left (outside inner clip) — white.
1028        let (blr, blg, blb) = pixel(2, 2);
1029        assert!(blr > 240 && blg > 240 && blb > 240,
1030            "bottom-left should stay white; got ({blr}, {blg}, {blb})");
1031    }
1032
1033    // ── Step 2d.2: push_layer / pop_layer ───────────────────────────────────
1034
1035    /// Sanity: paint inside a `push_layer`/`pop_layer` block lands in
1036    /// the parent buffer at the recorded origin.  Catches a missing
1037    /// composite-on-pop or a wrong-origin bug.
1038    #[test]
1039    fn test_lcd_gfx_ctx_push_pop_layer_flushes_into_parent() {
1040        let mut buf = LcdBuffer::new(20, 20);
1041        {
1042            let mut ctx = LcdGfxCtx::new(&mut buf);
1043            ctx.clear(Color::white());
1044            // Translate the parent so the layer lands at (5, 5) in the
1045            // base buffer's coords — exercises the origin pickup from
1046            // the CTM at push time.
1047            ctx.translate(5.0, 5.0);
1048            ctx.push_layer(8.0, 8.0);
1049            ctx.set_fill_color(Color::black());
1050            ctx.begin_path();
1051            ctx.rect(0.0, 0.0, 8.0, 8.0);          // fills the whole layer
1052            ctx.fill();
1053            ctx.pop_layer();
1054        }
1055        let pixel = |x: usize, y: usize| -> (u8, u8, u8) {
1056            let i = (y * 20 + x) * 3;
1057            (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2])
1058        };
1059        // Inside the layer's destination region in the parent → dark.
1060        assert_eq!(pixel(8, 8), (0, 0, 0), "interior of flushed layer should be dark");
1061        // Just outside the layer's region → still white.
1062        assert_eq!(pixel(2, 2), (255, 255, 255), "outside layer region should stay white");
1063        assert_eq!(pixel(15, 15), (255, 255, 255), "outside layer region should stay white");
1064    }
1065
1066    /// State must be restored after `pop_layer`: the fill colour, font
1067    /// size, transform, and clip rect set inside the layer must NOT
1068    /// leak out into the parent's subsequent paint.  Also: the layer's
1069    /// transform starts at identity (matches `GfxCtx::push_layer`).
1070    #[test]
1071    fn test_lcd_gfx_ctx_push_pop_layer_restores_state() {
1072        let mut buf = LcdBuffer::new(20, 20);
1073        {
1074            let mut ctx = LcdGfxCtx::new(&mut buf);
1075            ctx.clear(Color::white());
1076
1077            ctx.set_fill_color(Color::white());     // pre-layer fill colour
1078            ctx.translate(3.0, 4.0);
1079            assert_eq!((ctx.transform().tx, ctx.transform().ty), (3.0, 4.0));
1080
1081            ctx.push_layer(10.0, 10.0);
1082            // Inside the layer transform must reset to identity.
1083            assert_eq!((ctx.transform().tx, ctx.transform().ty), (0.0, 0.0),
1084                "push_layer must reset transform inside the layer");
1085            // Mutate state inside the layer.
1086            ctx.set_fill_color(Color::rgba(0.1, 0.2, 0.3, 1.0));
1087            ctx.translate(1.0, 1.0);
1088            ctx.pop_layer();
1089
1090            // After pop: transform restored to (3, 4); fill colour restored
1091            // to white.
1092            assert_eq!((ctx.transform().tx, ctx.transform().ty), (3.0, 4.0),
1093                "pop_layer must restore transform to its push-time value");
1094
1095            // Verify fill colour by painting and inspecting bg-untouched
1096            // pixels.  We fill a small rect into the parent — if the
1097            // fill colour were the leaked dark teal, those pixels would
1098            // be that, not white.
1099            ctx.begin_path();
1100            ctx.rect(0.0, 0.0, 4.0, 4.0);
1101            ctx.fill();
1102        }
1103        // The post-pop fill happens at translate(3,4), filling rect (3..7, 4..8).
1104        // Fill colour is white (restored) → those pixels must be white.
1105        let i = (5 * 20 + 5) * 3;
1106        let (r, g, b) = (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2]);
1107        assert_eq!((r, g, b), (255, 255, 255), "post-pop fill must use restored white colour");
1108    }
1109
1110    /// Paint inside a layer must NOT touch the parent buffer until pop.
1111    /// Inspect the parent buffer mid-layer and verify the painted pixels
1112    /// haven't appeared yet.
1113    #[test]
1114    fn test_lcd_gfx_ctx_push_layer_isolates_paint_until_pop() {
1115        let mut buf = LcdBuffer::new(20, 20);
1116        {
1117            let mut ctx = LcdGfxCtx::new(&mut buf);
1118            ctx.clear(Color::white());
1119            ctx.push_layer(10.0, 10.0);
1120            ctx.set_fill_color(Color::black());
1121            ctx.begin_path();
1122            ctx.rect(0.0, 0.0, 10.0, 10.0);
1123            ctx.fill();
1124            // Mid-layer: parent buffer's pixels must still be all white.
1125            let base = ctx.buffer();
1126            assert!(base.color_plane().chunks_exact(3).all(|p| p[0] == 255 && p[1] == 255 && p[2] == 255),
1127                "base buffer must not see layer paint until pop_layer");
1128            ctx.pop_layer();
1129        }
1130        // After pop: pixels (0..10, 0..10) should be dark.
1131        let i = (5 * 20 + 5) * 3;
1132        let (r, g, b) = (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2]);
1133        assert_eq!((r, g, b), (0, 0, 0), "after pop_layer, painted pixels should appear in base");
1134    }
1135
1136    /// Nested layers compose correctly: outer layer flushes the inner
1137    /// layer's contribution as part of its own flush.  Catches stack
1138    /// management bugs where a pop misroutes which buffer becomes
1139    /// "active" after.
1140    #[test]
1141    fn test_lcd_gfx_ctx_push_layer_nests() {
1142        let mut buf = LcdBuffer::new(30, 30);
1143        {
1144            let mut ctx = LcdGfxCtx::new(&mut buf);
1145            ctx.clear(Color::white());
1146            ctx.translate(2.0, 2.0);
1147            ctx.push_layer(20.0, 20.0);                // outer layer at (2,2)
1148            ctx.set_fill_color(Color::black());
1149
1150            ctx.translate(4.0, 4.0);
1151            ctx.push_layer(8.0, 8.0);                  // inner layer at (4,4) within outer
1152            ctx.begin_path();
1153            ctx.rect(0.0, 0.0, 8.0, 8.0);
1154            ctx.fill();
1155            ctx.pop_layer();                           // flush inner → outer at (4,4)
1156
1157            ctx.pop_layer();                           // flush outer → base at (2,2)
1158        }
1159        // Inner layer fills (0..8, 0..8) of itself.  Outer composites it
1160        // at (4,4) → outer pixels (4..12, 4..12) = inner content.  Base
1161        // composites outer at (2,2) → base pixels (6..14, 6..14) = inner
1162        // black region.
1163        let pixel = |x: usize, y: usize| -> (u8, u8, u8) {
1164            let i = (y * 30 + x) * 3;
1165            (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2])
1166        };
1167        assert_eq!(pixel(10, 10), (0, 0, 0), "centre of nested layer region should be dark");
1168        assert_eq!(pixel(2, 2),   (255, 255, 255), "well outside nested region should stay white");
1169        assert_eq!(pixel(20, 20), (255, 255, 255), "well outside nested region should stay white");
1170    }
1171
1172    /// Unmatched `pop_layer` (no preceding `push_layer`) must be a
1173    /// silent no-op — same contract as `GfxCtx::pop_layer`.
1174    #[test]
1175    fn test_lcd_gfx_ctx_unmatched_pop_layer_is_noop() {
1176        let mut buf = LcdBuffer::new(8, 8);
1177        {
1178            let mut ctx = LcdGfxCtx::new(&mut buf);
1179            ctx.clear(Color::white());
1180            ctx.pop_layer();   // must not panic
1181            ctx.set_fill_color(Color::black());
1182            ctx.begin_path();
1183            ctx.rect(0.0, 0.0, 8.0, 8.0);
1184            ctx.fill();
1185        }
1186        // Subsequent paint still works — sample an INTERIOR pixel; the
1187        // 5-tap LCD filter naturally produces partial coverage at the
1188        // buffer edges (subpixel samples beyond the buffer read as 0)
1189        // which is a known + correct property of the pipeline.
1190        let i = (4 * 8 + 4) * 3;
1191        let (r, g, b) = (buf.color_plane()[i], buf.color_plane()[i + 1], buf.color_plane()[i + 2]);
1192        assert_eq!((r, g, b), (0, 0, 0), "subsequent paint after unmatched pop should still work");
1193    }
1194
1195    /// CTM must be honoured by `fill_text` — translating the ctx by
1196    /// `(dx, dy)` then drawing at `(x, y)` should land at the same pixel
1197    /// as drawing at `(x+dx, y+dy)` with no translation.  Guards against
1198    /// "forgot to apply CTM in the LCD path" bugs (we hit one of those
1199    /// in the legacy path two iterations ago).
1200    #[test]
1201    fn test_lcd_gfx_ctx_fill_text_honours_translation() {
1202        let f = font();
1203        let w = 100u32;
1204        let h = 24u32;
1205
1206        let mut buf_a = LcdBuffer::new(w, h);
1207        {
1208            let mut ctx = LcdGfxCtx::new(&mut buf_a);
1209            ctx.clear(Color::white());
1210            ctx.set_fill_color(Color::black());
1211            ctx.set_font(Arc::clone(&f));
1212            ctx.set_font_size(16.0);
1213            ctx.translate(10.0, 4.0);
1214            ctx.fill_text("Hi", 0.0, 12.0);
1215        }
1216
1217        let mut buf_b = LcdBuffer::new(w, h);
1218        {
1219            let mut ctx = LcdGfxCtx::new(&mut buf_b);
1220            ctx.clear(Color::white());
1221            ctx.set_fill_color(Color::black());
1222            ctx.set_font(f);
1223            ctx.set_font_size(16.0);
1224            ctx.fill_text("Hi", 10.0, 16.0);
1225        }
1226
1227        assert_eq!(buf_a.color_plane(), buf_b.color_plane(),
1228            "translate(10,4) + fill_text(0,12) must equal fill_text(10,16)");
1229    }
1230}