Skip to main content

rosace_render/
canvas.rs

1use std::collections::HashMap;
2
3use tiny_skia::{FillRule, GradientStop, LinearGradient, Mask, Paint, PathBuilder, Pixmap, SpreadMode, Stroke, Transform};
4use rosace_core::types::{Point, Rect, Size};
5
6/// Cubic Bézier circle-approximation constant (4/3 · tan(π/8)).
7const KAPPA: f32 = 0.552_285;
8
9/// Perceptual coverage curve for text anti-aliasing. Linear alpha makes
10/// dark-on-light stems look anemic (mid coverages read too light); a mild
11/// gamma boost on the coverage ramp keeps edges smooth while restoring
12/// stem weight. One-time 256-entry table.
13fn text_gamma(cov: u32) -> u32 {
14    text_gamma_lut()[cov as usize] as u32
15}
16
17/// Exact-rounding division by 255 without an integer divide.
18#[inline(always)]
19fn d255(x: u32) -> u32 {
20    let t = x + 128;
21    (t + (t >> 8)) >> 8
22}
23
24/// ROSACE's 2D drawing canvas backed by tiny-skia.
25///
26/// Replaces the placeholder `Canvas` in `rosace-core` for the Phase 1 desktop
27/// target. All drawing operations are performed on a CPU pixel buffer; no native
28/// graphics library is required.
29pub struct SkiaCanvas {
30    pixmap: Pixmap,
31    /// Device pixel ratio (e.g. 2.0 on Retina). All draw coordinates are in
32    /// logical pixels; `play_picture` multiplies them by this before writing
33    /// physical pixels, so the full HiDPI buffer is used without blurry upscaling.
34    scale: f32,
35    /// True after any draw call (other than `clear_transparent`). Used by the
36    /// platform to skip the overlay Porter-Duff blend when nothing was drawn.
37    has_drawn: bool,
38    /// True when this canvas's pixels changed since the last present. The frame
39    /// loop sets it whenever it repaints; the platform consumes it via
40    /// [`take_frame_dirty`] to skip the GPU texture upload on clean frames
41    /// (D089). Starts `true` so the first frame always uploads.
42    frame_dirty: bool,
43    /// Active clip rect in PHYSICAL pixel coordinates, stored as (x, y, right, bottom)
44    /// right-exclusive. `None` means no clipping. Managed by `play_picture`.
45    clip: Option<(i32, i32, i32, i32)>,
46    /// Rasterized clip masks for path fills (circles, rounded rects), keyed by
47    /// the clip tuple. Built lazily on first path fill under a given clip and
48    /// reused for the lifetime of the canvas (viewport clips are stable).
49    clip_masks: HashMap<(i32, i32, i32, i32), Mask>,
50    /// Blurred shadow masks keyed by (width, height, blur, corner radius) in
51    /// physical pixels. Blurred once per unique geometry, replayed as a blit.
52    shadow_cache: HashMap<(u32, u32, u32, u32), ShadowMask>,
53    /// GPU shader quads collected during `play_picture` (D109/Phase 27).
54    /// `DrawCommand::ShaderFill` has no CPU rasterization path by design —
55    /// each occurrence is recorded here (physical px, with the WIDGET clip
56    /// active at that point in the picture, never the damage clip: a GPU
57    /// quad redraws in full every present, so scoping it to this frame's
58    /// damage region would wrongly crop it) and drained by the platform via
59    /// [`take_shader_quads`] for the compositor to execute.
60    pending_shader_quads: Vec<ShaderQuadCmd>,
61    /// GPU-shapes mode (D109/Phase 27 Step 3): when true, the eight
62    /// built-in shape commands divert to built-in SDF pipelines instead of
63    /// tiny-skia, and `play_picture` partitions the stream into ordered
64    /// [`CanvasFrameItem`]s (the C1 segment executor). Enabled per-canvas
65    /// by the platform ONLY where a `GpuPresenter` exists — the base
66    /// window canvas today; scroll-content and overlay canvases stay CPU
67    /// until C2, and softbuffer/web never enable it.
68    gpu_shapes: bool,
69    /// Ordered frame items collected in GPU-shapes mode; drained via
70    /// [`take_frame_items`].
71    pending_frame_items: Vec<CanvasFrameItem>,
72    /// Bounding box (physical px, x0/y0/x1/y1) of the CPU commands
73    /// rasterized since the last segment cut — the open segment.
74    seg_bbox: Option<(f32, f32, f32, f32)>,
75}
76
77/// One glyph headed for the compositor's atlas (D109 Step 4): position and
78/// color per frame; `bitmap` is the shared cached rasterization, read only
79/// on the atlas's first sight of `key`.
80#[derive(Clone)]
81pub struct GlyphQuad {
82    /// Stable atlas key (see `font::layout_glyphs`).
83    pub key: u64,
84    /// Coverage bitmap (`w*h` bytes) — pre-gamma; the atlas upload applies
85    /// the text gamma curve once (see [`text_gamma_lut`]).
86    pub bitmap: crate::font::CachedGlyph,
87    /// Top-left, physical px.
88    pub x: f32,
89    pub y: f32,
90    pub w: u32,
91    pub h: u32,
92    /// sRGB straight-alpha text color.
93    pub color: [u8; 4],
94}
95
96// Equality/Debug skip the bitmap: `key` fully identifies it, and frame
97// diffing (skip-present) must not walk glyph bytes.
98impl PartialEq for GlyphQuad {
99    fn eq(&self, other: &Self) -> bool {
100        self.key == other.key
101            && self.x == other.x && self.y == other.y
102            && self.w == other.w && self.h == other.h
103            && self.color == other.color
104    }
105}
106impl std::fmt::Debug for GlyphQuad {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        write!(f, "GlyphQuad(key={:#x} at {},{} {}x{})", self.key, self.x, self.y, self.w, self.h)
109    }
110}
111
112/// The text-AA gamma curve as a 256-entry LUT (D109 Step 4): the CPU blit
113/// path applies it per pixel at blend time; the GPU atlas applies it ONCE
114/// at upload so the glyph shader is a pure sample×color. Exposed so the
115/// platform hands the compositor gamma'd bytes without the Layer-0
116/// compositor needing this crate.
117pub fn text_gamma_lut() -> &'static [u8; 256] {
118    use std::sync::OnceLock;
119    static LUT: OnceLock<[u8; 256]> = OnceLock::new();
120    LUT.get_or_init(|| {
121        let mut t = [0u8; 256];
122        // Bumped 1.22 -> 1.55 (2026-08-03, user-reported: body text reads
123        // thin next to native chrome even at the correct font/weight/size —
124        // this is the one central "how bold does AA coverage read" lever;
125        // stronger gamma correction darkens partially-covered edge pixels,
126        // which is exactly the classic "why does my custom renderer look
127        // thinner than CoreText" fix. EXPERIMENTAL: needs live visual
128        // confirmation, not yet locked in.
129        for (i, v) in t.iter_mut().enumerate() {
130            *v = ((i as f32 / 255.0).powf(1.0 / 1.55) * 255.0).round() as u8;
131        }
132        t
133    })
134}
135
136/// Shared image pixels headed for the compositor's texture cache. The
137/// newtype keeps `CanvasFrameItem`'s derives sane: Debug prints the length
138/// (never megabytes of bytes), equality compares Arc identity — the
139/// content-derived `key` is what real image equality is judged by.
140#[derive(Clone)]
141pub struct ImagePixels(pub std::sync::Arc<Vec<u8>>);
142
143impl PartialEq for ImagePixels {
144    fn eq(&self, other: &Self) -> bool {
145        std::sync::Arc::ptr_eq(&self.0, &other.0)
146    }
147}
148impl std::fmt::Debug for ImagePixels {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        write!(f, "ImagePixels({} bytes)", self.0.len())
151    }
152}
153
154/// Content-derived identity for a blit source (D109 image textures): FNV
155/// over dims, length, and three 32-byte windows. Cheap enough per frame;
156/// dims+len in the hash make collisions between real UI images
157/// astronomically unlikely, and unlike `Arc` pointer identity it can't
158/// suffer ABA when a cache entry is dropped and reallocated.
159pub fn blit_key(pixels: &[u8], w: u32, h: u32) -> u64 {
160    let mut hash: u64 = 0xcbf29ce484222325;
161    let mut eat = |b: u8| {
162        hash ^= b as u64;
163        hash = hash.wrapping_mul(0x100000001b3);
164    };
165    for v in [w, h, pixels.len() as u32] {
166        for b in v.to_le_bytes() { eat(b); }
167    }
168    let n = pixels.len();
169    for &start in &[0usize, n / 2, n.saturating_sub(32)] {
170        for &b in &pixels[start..(start + 32).min(n)] { eat(b); }
171    }
172    hash
173}
174
175/// One item of a GPU-mode frame, in z-order (D109 C1): a GPU shape quad, a
176/// CPU-rasterized segment (bbox-sized premultiplied-RGBA buffer cut out of
177/// the scratch pixmap), a batch of atlas glyphs (Step 4), or an image drawn
178/// from the compositor's texture cache (uploaded on first sight of `key`).
179#[derive(Debug, Clone, PartialEq)]
180pub enum CanvasFrameItem {
181    Shader(ShaderQuadCmd),
182    Segment { x: u32, y: u32, w: u32, h: u32, pixels: Vec<u8> },
183    Glyphs { glyphs: Vec<GlyphQuad>, clip: Option<(f32, f32, f32, f32)> },
184    Image {
185        key: u64,
186        pixels: ImagePixels,
187        src_w: u32,
188        src_h: u32,
189        /// Dest rect (x, y, w, h), physical px.
190        dest: (f32, f32, f32, f32),
191        opacity: f32,
192        clip: Option<(f32, f32, f32, f32)>,
193    },
194    /// Frosted-glass panel (D-DEF-012): the compositor blurs everything
195    /// drawn before this item within `rect` and draws a tinted rounded
196    /// panel over it. All physical px; `tint` is sRGB straight-alpha.
197    Backdrop {
198        rect: (f32, f32, f32, f32),
199        radius: f32,
200        blur: f32,
201        tint: [u8; 4],
202    },
203}
204
205/// One collected `DrawCommand::ShaderFill`, in PHYSICAL pixels, ready for
206/// the compositor. `clip` is the widget clip stack's intersection at record
207/// time (physical px, x/y/w/h), independent of any damage clip.
208#[derive(Debug, Clone, PartialEq)]
209pub struct ShaderQuadCmd {
210    pub pipeline_id: u64,
211    /// (x, y, w, h) in physical pixels.
212    pub rect: (f32, f32, f32, f32),
213    pub uniforms: Vec<u8>,
214    /// (x, y, w, h) in physical pixels; `None` = unclipped.
215    pub clip: Option<(f32, f32, f32, f32)>,
216    /// See [`crate::DrawCommand::ShaderFill`]: when true, the platform
217    /// patches the first 4 uniform bytes with a live clock each present
218    /// (D109 maturity) — animation without CPU repaint.
219    pub animate_time: bool,
220}
221
222/// A pre-blurred shadow coverage mask (single channel).
223struct ShadowMask {
224    w: usize,
225    h: usize,
226    /// Blur margin in pixels on each side of the nominal rect.
227    margin: i32,
228    data: Vec<u8>,
229}
230
231/// An RGBA color value.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub struct Color {
234    /// Red channel (0–255).
235    pub r: u8,
236    /// Green channel (0–255).
237    pub g: u8,
238    /// Blue channel (0–255).
239    pub b: u8,
240    /// Alpha channel (0–255).
241    pub a: u8,
242}
243
244impl Color {
245    /// Create an opaque color from red, green, and blue components.
246    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
247        Self { r, g, b, a: 255 }
248    }
249
250    /// The color as `[r, g, b, a]` bytes — what the GPU shape conversions
251    /// (`gpu_shapes`) take.
252    pub const fn rgba_bytes(self) -> [u8; 4] {
253        [self.r, self.g, self.b, self.a]
254    }
255
256    /// Create a color with explicit alpha.
257    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
258        Self { r, g, b, a }
259    }
260
261    /// Opaque white.
262    pub const WHITE: Color = Color::rgb(255, 255, 255);
263    /// Opaque black.
264    pub const BLACK: Color = Color::rgb(0, 0, 0);
265    /// Opaque red.
266    pub const RED: Color = Color::rgb(255, 0, 0);
267    /// Opaque green.
268    pub const GREEN: Color = Color::rgb(0, 255, 0);
269    /// Opaque blue.
270    pub const BLUE: Color = Color::rgb(0, 0, 255);
271    /// Fully transparent.
272    pub const TRANSPARENT: Color = Color::rgba(0, 0, 0, 0);
273}
274
275// ── Clip helpers (physical pixel space) ──────────────────────────────────────
276
277/// Intersect a rect (x, y, w, h) with a clip region (cx, cy, cr, cb).
278/// Returns the clipped (x, y, w, h) or None if fully outside.
279#[inline]
280fn clip_xywh(
281    x: f32, y: f32, w: f32, h: f32,
282    clip: (i32, i32, i32, i32),
283) -> Option<(f32, f32, f32, f32)> {
284    let (cx, cy, cr, cb) = clip;
285    let x0 = x.max(cx as f32);
286    let y0 = y.max(cy as f32);
287    let x1 = (x + w).min(cr as f32);
288    let y1 = (y + h).min(cb as f32);
289    if x1 > x0 && y1 > y0 { Some((x0, y0, x1 - x0, y1 - y0)) } else { None }
290}
291
292/// True if a rect overlaps the clip region (used for early cull on circles/rrects).
293#[inline]
294fn overlaps_clip(x: f32, y: f32, w: f32, h: f32, clip: (i32, i32, i32, i32)) -> bool {
295    let (cx, cy, cr, cb) = clip;
296    x + w > cx as f32 && y + h > cy as f32 && x < cr as f32 && y < cb as f32
297}
298
299/// Build (or fetch) the rasterized mask for `clip`, storing it in `masks`.
300///
301/// Free function (not a method) so callers can hold a `&Mask` from `masks`
302/// while mutably borrowing `pixmap` — disjoint field borrows.
303fn ensure_clip_mask(
304    masks: &mut HashMap<(i32, i32, i32, i32), Mask>,
305    clip: (i32, i32, i32, i32),
306    width: u32,
307    height: u32,
308) {
309    if masks.contains_key(&clip) {
310        return;
311    }
312    let Some(mut mask) = Mask::new(width, height) else { return };
313    let (x0, y0, x1, y1) = clip;
314    let mut pb = PathBuilder::new();
315    if let Some(r) = tiny_skia::Rect::from_ltrb(x0 as f32, y0 as f32, x1 as f32, y1 as f32) {
316        pb.push_rect(r);
317    }
318    if let Some(path) = pb.finish() {
319        mask.fill_path(&path, FillRule::Winding, false, Transform::identity());
320        masks.insert(clip, mask);
321    }
322}
323
324/// Build a rounded-rect path with proper cubic Bézier corner arcs.
325fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> Option<tiny_skia::Path> {
326    let k = KAPPA * r;
327    let (x1, y1) = (x + w, y + h);
328    let mut pb = PathBuilder::new();
329    pb.move_to(x + r, y);
330    pb.line_to(x1 - r, y);
331    pb.cubic_to(x1 - r + k, y, x1, y + r - k, x1, y + r);
332    pb.line_to(x1, y1 - r);
333    pb.cubic_to(x1, y1 - r + k, x1 - r + k, y1, x1 - r, y1);
334    pb.line_to(x + r, y1);
335    pb.cubic_to(x + r - k, y1, x, y1 - r + k, x, y1 - r);
336    pb.line_to(x, y + r);
337    pb.cubic_to(x, y + r - k, x + r - k, y, x + r, y);
338    pb.close();
339    pb.finish()
340}
341
342/// One horizontal sliding-window box-blur pass with clamp-to-edge sampling.
343fn box_blur_h(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize) {
344    let norm = (2 * r + 1) as u32;
345    for y in 0..h {
346        let row = y * w;
347        let mut acc: u32 = src[row] as u32 * r as u32;
348        for i in 0..=r {
349            acc += src[row + i.min(w - 1)] as u32;
350        }
351        for x in 0..w {
352            dst[row + x] = (acc / norm) as u8;
353            let add = src[row + (x + r + 1).min(w - 1)] as u32;
354            let sub = src[row + x.saturating_sub(r)] as u32;
355            acc = acc + add - sub;
356        }
357    }
358}
359
360/// One vertical sliding-window box-blur pass with clamp-to-edge sampling.
361fn box_blur_v(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize) {
362    let norm = (2 * r + 1) as u32;
363    for x in 0..w {
364        let mut acc: u32 = src[x] as u32 * r as u32;
365        for i in 0..=r {
366            acc += src[i.min(h - 1) * w + x] as u32;
367        }
368        for y in 0..h {
369            dst[y * w + x] = (acc / norm) as u8;
370            let add = src[(y + r + 1).min(h - 1) * w + x] as u32;
371            let sub = src[y.saturating_sub(r) * w + x] as u32;
372            acc = acc + add - sub;
373        }
374    }
375}
376
377/// Rasterize and blur a shadow coverage mask for a `w`×`h` rounded rect
378/// (corner `radius` px) at `blur` px.
379///
380/// The source shape matches the widget's rounded geometry so the blurred
381/// shadow hugs the corners instead of leaking square corner triangles.
382/// Three box-blur passes per axis approximate a Gaussian (σ ≈ blur/2).
383fn build_shadow_mask(w: u32, h: u32, blur: u32, radius: u32) -> ShadowMask {
384    let margin = (2 * blur) as i32 + 1;
385    let mw = w as usize + 2 * margin as usize;
386    let mh = h as usize + 2 * margin as usize;
387    let mut data = vec![0u8; mw * mh];
388    for row in margin as usize..margin as usize + h as usize {
389        let s = row * mw + margin as usize;
390        data[s..s + w as usize].fill(255);
391    }
392
393    // Carve the corners with distance-based coverage. Exactness is not
394    // critical — the blur softens the edge — but the corner mass must go.
395    let r = (radius as f32).min(w as f32 / 2.0).min(h as f32 / 2.0);
396    if r >= 1.0 {
397        let m = margin as f32;
398        let centers = [
399            (m + r,             m + r),
400            (m + w as f32 - r,  m + r),
401            (m + r,             m + h as f32 - r),
402            (m + w as f32 - r,  m + h as f32 - r),
403        ];
404        let corners = [
405            (m,                m,                m + r,            m + r),
406            (m + w as f32 - r, m,                m + w as f32,     m + r),
407            (m,                m + h as f32 - r, m + r,            m + h as f32),
408            (m + w as f32 - r, m + h as f32 - r, m + w as f32,     m + h as f32),
409        ];
410        for (i, &(x0, y0, x1, y1)) in corners.iter().enumerate() {
411            let (cx, cy) = centers[i];
412            for py in y0 as usize..(y1.ceil() as usize).min(mh) {
413                for px in x0 as usize..(x1.ceil() as usize).min(mw) {
414                    let dx = px as f32 + 0.5 - cx;
415                    let dy = py as f32 + 0.5 - cy;
416                    let d = (dx * dx + dy * dy).sqrt();
417                    let coverage = (r + 0.5 - d).clamp(0.0, 1.0);
418                    data[py * mw + px] = (coverage * 255.0) as u8;
419                }
420            }
421        }
422    }
423
424    let br = (blur as usize / 2).max(1);
425    let mut tmp = vec![0u8; mw * mh];
426    for _ in 0..3 {
427        box_blur_h(&data, &mut tmp, mw, mh, br);
428        box_blur_v(&tmp, &mut data, mw, mh, br);
429    }
430    ShadowMask { w: mw, h: mh, margin, data }
431}
432
433impl SkiaCanvas {
434    /// Create a canvas at physical pixel size with a device pixel ratio of 1.0.
435    pub fn new(width: u32, height: u32) -> Self {
436        Self::new_hidpi(width, height, 1.0)
437    }
438
439    /// Create a canvas for a HiDPI display.
440    ///
441    /// `phys_width` / `phys_height` are the framebuffer dimensions in physical
442    /// pixels. `scale` is the device pixel ratio (e.g. 2.0 on Retina).
443    /// All draw coordinates passed via [`play_picture`] are in logical pixels
444    /// and are multiplied by `scale` before writing to the pixmap.
445    pub fn new_hidpi(phys_width: u32, phys_height: u32, scale: f32) -> Self {
446        Self {
447            pixmap: Pixmap::new(phys_width, phys_height).expect("failed to create pixmap"),
448            scale: scale.max(1.0),
449            has_drawn: false,
450            frame_dirty: true,
451            clip: None,
452            clip_masks: HashMap::new(),
453            shadow_cache: HashMap::new(),
454            pending_shader_quads: Vec::new(),
455            gpu_shapes: false,
456            pending_frame_items: Vec::new(),
457            seg_bbox: None,
458        }
459    }
460
461    /// Drain the GPU shader quads collected by [`play_picture`] since the
462    /// last call (D109). The platform calls this once per painted frame and
463    /// retains the result across skipped (clean) frames, mirroring how
464    /// scroll layers persist through frame-skip.
465    pub fn take_shader_quads(&mut self) -> Vec<ShaderQuadCmd> {
466        std::mem::take(&mut self.pending_shader_quads)
467    }
468
469    /// Enable/disable GPU-shapes mode (D109/Phase 27 Step 3). Platform-only:
470    /// set it exactly where a `GpuPresenter` will consume
471    /// [`take_frame_items`] — a GPU-mode canvas's pixmap is a segment
472    /// scratch buffer, NOT a presentable frame.
473    pub fn set_gpu_shapes(&mut self, on: bool) {
474        self.gpu_shapes = on;
475    }
476
477    pub fn gpu_shapes(&self) -> bool {
478        self.gpu_shapes
479    }
480
481    /// Drain the ordered frame items collected in GPU-shapes mode. Same
482    /// retention contract as [`take_shader_quads`]: called on painted
483    /// frames, retained by the platform across skipped frames.
484    pub fn take_frame_items(&mut self) -> Vec<CanvasFrameItem> {
485        std::mem::take(&mut self.pending_frame_items)
486    }
487
488    /// Close the open CPU segment (GPU mode): cut its bbox out of the
489    /// scratch pixmap into an owned buffer, erase that region back to
490    /// transparent (so later segments can't re-capture it), and append the
491    /// Segment item.
492    fn cut_segment(&mut self) {
493        let Some((x0, y0, x1, y1)) = self.seg_bbox.take() else { return; };
494        let pw = self.pixmap.width() as i32;
495        let ph = self.pixmap.height() as i32;
496        let ix0 = (x0.floor() as i32).clamp(0, pw);
497        let iy0 = (y0.floor() as i32).clamp(0, ph);
498        let ix1 = (x1.ceil() as i32).clamp(0, pw);
499        let iy1 = (y1.ceil() as i32).clamp(0, ph);
500        if ix1 <= ix0 || iy1 <= iy0 { return; }
501        let (w, h) = ((ix1 - ix0) as u32, (iy1 - iy0) as u32);
502
503        let stride = pw as usize * 4;
504        let data = self.pixmap.data_mut();
505        let mut pixels = vec![0u8; (w * h * 4) as usize];
506        for row in 0..h as usize {
507            let src = (iy0 as usize + row) * stride + ix0 as usize * 4;
508            let dst = row * w as usize * 4;
509            pixels[dst..dst + w as usize * 4]
510                .copy_from_slice(&data[src..src + w as usize * 4]);
511            data[src..src + w as usize * 4].fill(0);
512        }
513        self.pending_frame_items.push(CanvasFrameItem::Segment {
514            x: ix0 as u32, y: iy0 as u32, w, h, pixels,
515        });
516    }
517
518    /// Grow the open segment's bbox (GPU mode) by a command's conservative
519    /// physical-px bounds, clipped to the active clip.
520    ///
521    /// No caller since D109 moved images (the last CPU-rasterized command)
522    /// to GPU textured quads. Kept as the CPU-fallback seam: any future
523    /// canvas command without a GPU pipeline must call this before
524    /// rasterizing into the scratch pixmap, or `cut_segment` (still wired
525    /// in `push_builtin_quad`) will silently drop its pixels.
526    #[allow(dead_code)]
527    fn grow_segment(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) {
528        let (mut x0, mut y0, mut x1, mut y1) = (x0, y0, x1, y1);
529        if let Some((cx, cy, cr, cb)) = self.clip {
530            x0 = x0.max(cx as f32);
531            y0 = y0.max(cy as f32);
532            x1 = x1.min(cr as f32);
533            y1 = y1.min(cb as f32);
534        }
535        if x1 <= x0 || y1 <= y0 { return; }
536        self.seg_bbox = Some(match self.seg_bbox {
537            Some((a, b, c, d)) => (a.min(x0), b.min(y0), c.max(x1), d.max(y1)),
538            None => (x0, y0, x1, y1),
539        });
540    }
541
542    /// Push a built-in shape quad (GPU mode), cutting any open CPU segment
543    /// first so z-order is preserved.
544    fn push_builtin_quad(
545        &mut self,
546        pipeline_id: u64,
547        quad: (f32, f32, f32, f32),
548        uniforms: Vec<u8>,
549        widget_clip: Option<(f32, f32, f32, f32)>,
550    ) {
551        self.cut_segment();
552        self.pending_frame_items.push(CanvasFrameItem::Shader(ShaderQuadCmd {
553            pipeline_id, rect: quad, uniforms, clip: widget_clip, animate_time: false,
554        }));
555    }
556
557    /// Physical pixel width of the underlying framebuffer.
558    pub fn width(&self) -> u32 {
559        self.pixmap.width()
560    }
561
562    /// Physical pixel height of the underlying framebuffer.
563    pub fn height(&self) -> u32 {
564        self.pixmap.height()
565    }
566
567    /// Logical width (physical / scale). Use this for layout calculations.
568    pub fn logical_width(&self) -> u32 {
569        (self.pixmap.width() as f32 / self.scale).round() as u32
570    }
571
572    /// Logical height (physical / scale). Use this for layout calculations.
573    pub fn logical_height(&self) -> u32 {
574        (self.pixmap.height() as f32 / self.scale).round() as u32
575    }
576
577    /// Device pixel ratio for this canvas.
578    pub fn scale(&self) -> f32 {
579        self.scale
580    }
581
582    /// True if any draw operation (other than `clear_transparent`) has been called.
583    ///
584    /// Used by the platform to skip the overlay Porter-Duff blend when the
585    /// overlay canvas has no content, avoiding O(pixels) work every frame.
586    pub fn has_drawn(&self) -> bool {
587        self.has_drawn
588    }
589
590    /// Mark this canvas's pixels as changed this frame (D089). The frame loop
591    /// calls this whenever it repaints the canvas, so the platform re-uploads
592    /// its GPU texture; clean frames leave the flag false and skip the upload.
593    pub fn mark_frame_dirty(&mut self) {
594        self.frame_dirty = true;
595    }
596
597    /// Return whether the canvas changed since the last present and reset the
598    /// flag to false (D089). Called once per frame by the platform present.
599    pub fn take_frame_dirty(&mut self) -> bool {
600        std::mem::replace(&mut self.frame_dirty, false)
601    }
602
603    /// Fill the entire canvas with a solid color.
604    pub fn clear(&mut self, color: Color) {
605        if self.gpu_shapes {
606            // GPU mode: the pixmap is segment scratch — clear it to
607            // transparent, reset this frame's items, and make the
608            // background the frame's first GPU quad (full-frame fill).
609            self.pixmap.fill(tiny_skia::Color::TRANSPARENT);
610            self.pending_frame_items.clear();
611            self.seg_bbox = None;
612            let (w, h) = (self.pixmap.width() as f32, self.pixmap.height() as f32);
613            let (quad, uniforms) = crate::gpu_shapes::fill_rrect_quad(
614                (0.0, 0.0, w, h), 0.0, [color.r, color.g, color.b, color.a],
615            );
616            self.pending_frame_items.push(CanvasFrameItem::Shader(ShaderQuadCmd {
617                pipeline_id: crate::gpu_shapes::FILL_RRECT_ID,
618                rect: quad,
619                uniforms,
620                clip: None,
621                animate_time: false,
622            }));
623            self.has_drawn = true;
624            return;
625        }
626        self.pixmap.fill(
627            tiny_skia::Color::from_rgba8(color.r, color.g, color.b, color.a),
628        );
629        self.has_drawn = true;
630    }
631
632    /// Fill the entire canvas with fully-transparent pixels (D078).
633    ///
634    /// Resets `has_drawn` so the platform can skip the overlay blend this frame.
635    pub fn clear_transparent(&mut self) {
636        self.pixmap.fill(tiny_skia::Color::TRANSPARENT);
637        self.has_drawn = false;
638    }
639
640    /// Fill a rectangle with a solid color.
641    ///
642    /// Edges are snapped to the physical pixel grid: adjacent widgets that
643    /// share a computed edge land on the same pixel column/row, so there are
644    /// no hairline seams and no sub-pixel shimmer during layout changes.
645    pub fn fill_rect(&mut self, rect: Rect, color: Color) {
646        if color.a == 0 { return; }
647        let (mut x, mut y, mut w, mut h) = (rect.origin.x, rect.origin.y, rect.size.width, rect.size.height);
648        if w < 0.5 || h < 0.5 { return; }
649
650        if let Some(clip) = self.clip {
651            match clip_xywh(x, y, w, h, clip) {
652                Some((cx, cy, cw, ch)) => { x = cx; y = cy; w = cw; h = ch; }
653                None => return,
654            }
655        }
656
657        // Snap edges (not origin+size) so both sides of a shared boundary
658        // round identically. Guarantee at least 1px after snapping.
659        let x0 = x.round();
660        let y0 = y.round();
661        let x1 = (x + w).round().max(x0 + 1.0);
662        let y1 = (y + h).round().max(y0 + 1.0);
663
664        let mut paint = Paint::default();
665        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
666        paint.anti_alias = false;
667        if let Some(r) = tiny_skia::Rect::from_ltrb(x0, y0, x1, y1) {
668            self.pixmap.fill_rect(r, &paint, Transform::identity(), None);
669        }
670        self.has_drawn = true;
671    }
672
673    /// Draw a rectangle outline with the given stroke width.
674    pub fn stroke_rect(&mut self, rect: Rect, color: Color, stroke_width: f32) {
675        // Quick cull against clip before paying tiny_skia path overhead.
676        if let Some(clip) = self.clip {
677            if !overlaps_clip(rect.origin.x, rect.origin.y, rect.size.width, rect.size.height, clip) {
678                return;
679            }
680            ensure_clip_mask(&mut self.clip_masks, clip, self.pixmap.width(), self.pixmap.height());
681        }
682        let mut paint = Paint::default();
683        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
684        paint.anti_alias = true;
685        let Some(skia_rect) = tiny_skia::Rect::from_xywh(
686            rect.origin.x,
687            rect.origin.y,
688            rect.size.width,
689            rect.size.height,
690        ) else {
691            return;
692        };
693        let path = PathBuilder::from_rect(skia_rect);
694        let stroke = tiny_skia::Stroke {
695            width: stroke_width,
696            ..Default::default()
697        };
698        let mask = self.clip.and_then(|c| self.clip_masks.get(&c));
699        self.pixmap
700            .stroke_path(&path, &paint, &stroke, Transform::identity(), mask);
701        self.has_drawn = true;
702    }
703
704    /// Draw a filled circle centered at `center` with the given `radius`.
705    pub fn fill_circle(&mut self, center: Point, radius: f32, color: Color) {
706        if color.a == 0 || radius < 0.5 { return; }
707        if let Some(clip) = self.clip {
708            if !overlaps_clip(center.x - radius, center.y - radius, radius * 2.0, radius * 2.0, clip) {
709                return;
710            }
711            ensure_clip_mask(&mut self.clip_masks, clip, self.pixmap.width(), self.pixmap.height());
712        }
713        let mut paint = Paint::default();
714        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
715        paint.anti_alias = true;
716        let mut pb = PathBuilder::new();
717        pb.push_circle(center.x, center.y, radius);
718        if let Some(path) = pb.finish() {
719            let mask = self.clip.and_then(|c| self.clip_masks.get(&c));
720            self.pixmap.fill_path(
721                &path,
722                &paint,
723                FillRule::Winding,
724                Transform::identity(),
725                mask,
726            );
727        }
728        self.has_drawn = true;
729    }
730
731    /// Draw a text placeholder at `origin`.
732    pub fn draw_text_placeholder(&mut self, text: &str, origin: Point, color: Color) {
733        let width = text.len() as f32 * 8.0;
734        let height = 16.0;
735        self.fill_rect(
736            Rect {
737                origin,
738                size: Size { width, height },
739            },
740            color,
741        );
742    }
743
744    /// Draw real text glyphs at `origin` using `font` at `px` size.
745    ///
746    /// `origin` is the top-left of the glyph bounding box. Glyph x positions
747    /// are rounded (not truncated) and kerning pairs are applied, matching
748    /// [`FontCache::measure_text`]. Blending uses an exact divide-free
749    /// source-over with a straight-store fast path for opaque pixels.
750    pub fn draw_text(&mut self, text: &str, origin: Point, color: Color, font: &crate::font::FontCache, px: f32) {
751        self.draw_text_weighted(text, origin, color, font, px, crate::font::FontWeight::Regular);
752    }
753
754    /// Weighted variant: routes each character through the bold face and the
755    /// Unicode fallback chain, applies kerning within a face, and blends
756    /// with the perceptual coverage curve.
757    pub fn draw_text_weighted(&mut self, text: &str, origin: Point, color: Color, font: &crate::font::FontCache, px: f32, weight: crate::font::FontWeight) {
758        if color.a == 0 || text.is_empty() { return; }
759
760        let canvas_w = self.pixmap.width() as i32;
761        let canvas_h = self.pixmap.height() as i32;
762        let ascender = font.ascender(px);
763
764        // Resolve clip bounds clamped to the canvas so the inner loop needs
765        // no per-pixel buffer-length check.
766        let (clip_x0, clip_y0, clip_x1, clip_y1) = match self.clip {
767            Some((cx, cy, cr, cb)) => (cx.max(0), cy.max(0), cr.min(canvas_w), cb.min(canvas_h)),
768            None                   => (0, 0, canvas_w, canvas_h),
769        };
770        if clip_x1 <= clip_x0 || clip_y1 <= clip_y0 { return; }
771
772        let _ = ascender; // baseline math lives in layout_glyphs (Step 4)
773        let color_a = color.a as u32;
774
775        // The one shared placement walk (D109 Step 4): the GPU atlas path
776        // consumes the same `layout_glyphs`, so both agree glyph-for-glyph.
777        let placed = crate::font::layout_glyphs(font, text, origin.x, origin.y, px, weight);
778
779        // Obtain a mutable slice of the pixel buffer. Because `font` is a
780        // separate argument (not a field of SkiaCanvas), holding `dst` and
781        // calling `font.glyph` in the loop has no borrow conflict.
782        let dst = self.pixmap.data_mut();
783
784        for pg in &placed {
785            let (gx, gy) = (pg.x, pg.y);
786
787            // Color-emoji glyph (Phase 32 Step 4): premultiplied RGBA
788            // source-over blend, ignoring the requested TEXT color entirely
789            // (emoji carry their own color) — the same premul-over-premul
790            // math `blit_rgba` uses for the `Image` widget, inlined here
791            // since this loop already holds the exclusive `dst` slice.
792            if let Some(cg) = &pg.color_rgba {
793                for row in 0..cg.height {
794                    let py = gy + row as i32;
795                    if py < clip_y0 || py >= clip_y1 { continue; }
796                    let row_base = (py * canvas_w) as usize * 4;
797                    let src_row = (row * cg.width) as usize * 4;
798                    for col in 0..cg.width {
799                        let px_xi = gx + col as i32;
800                        if px_xi < clip_x0 || px_xi >= clip_x1 { continue; }
801                        let si = src_row + col as usize * 4;
802                        let src_a = cg.rgba[si + 3] as u32;
803                        if src_a == 0 { continue; }
804                        let di = row_base + px_xi as usize * 4;
805                        let inv = 255 - src_a;
806                        dst[di]     = (cg.rgba[si]     as u32 + d255(dst[di]     as u32 * inv)) as u8;
807                        dst[di + 1] = (cg.rgba[si + 1] as u32 + d255(dst[di + 1] as u32 * inv)) as u8;
808                        dst[di + 2] = (cg.rgba[si + 2] as u32 + d255(dst[di + 2] as u32 * inv)) as u8;
809                        dst[di + 3] = (src_a + d255(dst[di + 3] as u32 * inv)) as u8;
810                    }
811                }
812                continue;
813            }
814
815            let (metrics, bitmap) = (&pg.glyph.0, &pg.glyph.1);
816
817            for row in 0..metrics.height {
818                let py = gy + row as i32;
819                if py < clip_y0 || py >= clip_y1 { continue; }
820                let row_base = (py * canvas_w) as usize * 4;
821                let src_row = row * metrics.width;
822
823                for col in 0..metrics.width {
824                    let coverage = text_gamma(bitmap[src_row + col] as u32);
825                    if coverage == 0 { continue; }
826
827                    let px_xi = gx + col as i32;
828                    if px_xi < clip_x0 || px_xi >= clip_x1 { continue; }
829
830                    let di = row_base + px_xi as usize * 4;
831                    if coverage == 255 && color_a == 255 {
832                        // Fully-covered opaque pixel — straight store.
833                        dst[di]     = color.r;
834                        dst[di + 1] = color.g;
835                        dst[di + 2] = color.b;
836                        dst[di + 3] = 255;
837                    } else {
838                        // Premultiplied source-over blend into the premul buffer.
839                        let src_a = d255(coverage * color_a);
840                        let inv   = 255 - src_a;
841                        dst[di]     = (d255(color.r as u32 * src_a) + d255(dst[di]     as u32 * inv)) as u8;
842                        dst[di + 1] = (d255(color.g as u32 * src_a) + d255(dst[di + 1] as u32 * inv)) as u8;
843                        dst[di + 2] = (d255(color.b as u32 * src_a) + d255(dst[di + 2] as u32 * inv)) as u8;
844                        dst[di + 3] = (src_a + d255(dst[di + 3] as u32 * inv)) as u8;
845                    }
846                }
847            }
848        }
849        self.has_drawn = true;
850    }
851
852    /// Fill a rounded rectangle as a single anti-aliased path.
853    ///
854    /// One path fill — no seams between corner and edge geometry, and
855    /// translucent colors blend exactly once per pixel.
856    pub fn fill_rrect(&mut self, rect: Rect, radius: f32, color: Color) {
857        if color.a == 0 { return; }
858        if let Some(clip) = self.clip {
859            if !overlaps_clip(rect.origin.x, rect.origin.y, rect.size.width, rect.size.height, clip) {
860                return;
861            }
862            ensure_clip_mask(&mut self.clip_masks, clip, self.pixmap.width(), self.pixmap.height());
863        }
864        let r = radius.min(rect.size.width / 2.0).min(rect.size.height / 2.0);
865        if r < 0.5 {
866            self.fill_rect(rect, color);
867            return;
868        }
869        let mut paint = Paint::default();
870        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
871        paint.anti_alias = true;
872        if let Some(path) = rounded_rect_path(
873            rect.origin.x, rect.origin.y, rect.size.width, rect.size.height, r,
874        ) {
875            let mask = self.clip.and_then(|c| self.clip_masks.get(&c));
876            self.pixmap.fill_path(
877                &path,
878                &paint,
879                FillRule::Winding,
880                Transform::identity(),
881                mask,
882            );
883        }
884        self.has_drawn = true;
885    }
886
887    /// Stroke a rounded-rectangle outline along the same path geometry as
888    /// [`SkiaCanvas::fill_rrect`], so borders hug rounded fills exactly.
889    pub fn stroke_rrect(&mut self, rect: Rect, radius: f32, color: Color, stroke_width: f32) {
890        if color.a == 0 { return; }
891        if let Some(clip) = self.clip {
892            if !overlaps_clip(rect.origin.x, rect.origin.y, rect.size.width, rect.size.height, clip) {
893                return;
894            }
895            ensure_clip_mask(&mut self.clip_masks, clip, self.pixmap.width(), self.pixmap.height());
896        }
897        let r = radius.min(rect.size.width / 2.0).min(rect.size.height / 2.0);
898        if r < 0.5 {
899            self.stroke_rect(rect, color, stroke_width);
900            return;
901        }
902        let mut paint = Paint::default();
903        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
904        paint.anti_alias = true;
905        if let Some(path) = rounded_rect_path(
906            rect.origin.x, rect.origin.y, rect.size.width, rect.size.height, r,
907        ) {
908            let stroke = tiny_skia::Stroke { width: stroke_width, ..Default::default() };
909            let mask = self.clip.and_then(|c| self.clip_masks.get(&c));
910            self.pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), mask);
911        }
912        self.has_drawn = true;
913    }
914
915    /// Draw a soft drop shadow for a rounded rect with a Gaussian-approximate
916    /// blur. `radius` must match the widget's corner radius so the shadow hugs
917    /// the rounded shape.
918    ///
919    /// The blurred coverage mask is computed once per unique
920    /// (width, height, blur, radius) and cached; draws are a tinted blit.
921    pub fn draw_shadow(&mut self, rect: Rect, radius: f32, color: Color, blur: f32) {
922        if color.a == 0 { return; }
923        let blur = blur.max(0.0);
924        if blur < 0.5 {
925            self.fill_rrect(rect, radius, color);
926            return;
927        }
928        let w = rect.size.width.round().max(1.0) as u32;
929        let h = rect.size.height.round().max(1.0) as u32;
930        let b = blur.round() as u32;
931        let rad = radius.max(0.0).round() as u32;
932        let key = (w, h, b, rad);
933        self.shadow_cache
934            .entry(key)
935            .or_insert_with(|| build_shadow_mask(w, h, b, rad));
936
937        let canvas_w = self.pixmap.width() as i32;
938        let canvas_h = self.pixmap.height() as i32;
939        let (clip_x0, clip_y0, clip_x1, clip_y1) = match self.clip {
940            Some((cx, cy, cr, cb)) => (cx.max(0), cy.max(0), cr.min(canvas_w), cb.min(canvas_h)),
941            None                   => (0, 0, canvas_w, canvas_h),
942        };
943        if clip_x1 <= clip_x0 || clip_y1 <= clip_y0 { return; }
944
945        let mask = &self.shadow_cache[&key];
946        let ox = rect.origin.x.round() as i32 - mask.margin;
947        let oy = rect.origin.y.round() as i32 - mask.margin;
948        let color_a = color.a as u32;
949        let dst = self.pixmap.data_mut();
950
951        for row in 0..mask.h {
952            let py = oy + row as i32;
953            if py < clip_y0 || py >= clip_y1 { continue; }
954            let row_base = (py * canvas_w) as usize * 4;
955            let src_row = row * mask.w;
956
957            for col in 0..mask.w {
958                let coverage = mask.data[src_row + col] as u32;
959                if coverage == 0 { continue; }
960
961                let px_xi = ox + col as i32;
962                if px_xi < clip_x0 || px_xi >= clip_x1 { continue; }
963
964                let src_a = d255(coverage * color_a);
965                if src_a == 0 { continue; }
966                let inv = 255 - src_a;
967                let di = row_base + px_xi as usize * 4;
968                dst[di]     = (d255(color.r as u32 * src_a) + d255(dst[di]     as u32 * inv)) as u8;
969                dst[di + 1] = (d255(color.g as u32 * src_a) + d255(dst[di + 1] as u32 * inv)) as u8;
970                dst[di + 2] = (d255(color.b as u32 * src_a) + d255(dst[di + 2] as u32 * inv)) as u8;
971                dst[di + 3] = (src_a + d255(dst[di + 3] as u32 * inv)) as u8;
972            }
973        }
974        self.has_drawn = true;
975    }
976
977    /// Fill a (rounded) rect with a two-stop linear gradient.
978    pub fn fill_gradient(&mut self, rect: Rect, radius: f32, from: Color, to: Color, vertical: bool) {
979        if let Some(clip) = self.clip {
980            if !overlaps_clip(rect.origin.x, rect.origin.y, rect.size.width, rect.size.height, clip) { return; }
981            ensure_clip_mask(&mut self.clip_masks, clip, self.pixmap.width(), self.pixmap.height());
982        }
983        let (x, y, w, h) = (rect.origin.x, rect.origin.y, rect.size.width, rect.size.height);
984        let (p0, p1) = if vertical {
985            (tiny_skia::Point::from_xy(x, y), tiny_skia::Point::from_xy(x, y + h))
986        } else {
987            (tiny_skia::Point::from_xy(x, y), tiny_skia::Point::from_xy(x + w, y))
988        };
989        let stops = vec![
990            GradientStop::new(0.0, tiny_skia::Color::from_rgba8(from.r, from.g, from.b, from.a)),
991            GradientStop::new(1.0, tiny_skia::Color::from_rgba8(to.r, to.g, to.b, to.a)),
992        ];
993        let Some(shader) = LinearGradient::new(p0, p1, stops, SpreadMode::Pad, Transform::identity()) else { return; };
994        let paint = Paint { shader, anti_alias: true, ..Paint::default() };
995        let mask = self.clip.and_then(|c| self.clip_masks.get(&c));
996        let r = radius.min(w / 2.0).min(h / 2.0);
997        if r < 0.5 {
998            if let Some(rr) = tiny_skia::Rect::from_xywh(x, y, w, h) {
999                self.pixmap.fill_rect(rr, &paint, Transform::identity(), mask);
1000            }
1001        } else if let Some(path) = rounded_rect_path(x, y, w, h, r) {
1002            self.pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), mask);
1003        }
1004        self.has_drawn = true;
1005    }
1006
1007    /// Draw a ring segment (progress arc / spinner) by stroking a polyline
1008    /// approximation of the arc centerline with round caps.
1009    pub fn fill_arc(&mut self, center: Point, radius: f32, thickness: f32, start_deg: f32, sweep_deg: f32, color: Color) {
1010        if color.a == 0 || radius < 0.5 || thickness < 0.3 { return; }
1011        if let Some(clip) = self.clip {
1012            let r = radius + thickness;
1013            if !overlaps_clip(center.x - r, center.y - r, r * 2.0, r * 2.0, clip) { return; }
1014            ensure_clip_mask(&mut self.clip_masks, clip, self.pixmap.width(), self.pixmap.height());
1015        }
1016        let segs = ((sweep_deg.abs() / 6.0).ceil() as usize).max(2);
1017        let mut pb = PathBuilder::new();
1018        for i in 0..=segs {
1019            let t = i as f32 / segs as f32;
1020            let a = (start_deg + sweep_deg * t).to_radians();
1021            let (px, py) = (center.x + radius * a.cos(), center.y + radius * a.sin());
1022            if i == 0 { pb.move_to(px, py); } else { pb.line_to(px, py); }
1023        }
1024        let Some(path) = pb.finish() else { return; };
1025        let mut paint = Paint::default();
1026        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
1027        paint.anti_alias = true;
1028        let stroke = Stroke { width: thickness, line_cap: tiny_skia::LineCap::Round, ..Default::default() };
1029        let mask = self.clip.and_then(|c| self.clip_masks.get(&c));
1030        self.pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), mask);
1031        self.has_drawn = true;
1032    }
1033
1034        /// Replay a [`Picture`] (display list) onto this canvas.
1035    ///
1036    /// All draw-command coordinates are in **logical pixels**. They are
1037    /// multiplied by `self.scale` before writing to the physical pixmap, so
1038    /// the full HiDPI framebuffer resolution is used and there is no
1039    /// nearest-neighbour upscaling blur.
1040    ///
1041    /// `PushClip` / `PopClip` commands maintain a clip stack so that
1042    /// `ScrollView` children are confined to their viewport.
1043    pub fn play_picture(&mut self, picture: &crate::picture::Picture, font: &crate::font::FontCache) {
1044        use crate::draw_command::DrawCommand;
1045        let s = self.scale;
1046        let sr = |r: Rect| Rect {
1047            origin: Point { x: r.origin.x * s, y: r.origin.y * s },
1048            size:   Size  { width: r.size.width * s, height: r.size.height * s },
1049        };
1050        let sp = |p: Point| Point { x: p.x * s, y: p.y * s };
1051
1052        // Clip stack — each entry is the clip that was active BEFORE the matching PushClip.
1053        let mut clip_stack: Vec<Option<(i32, i32, i32, i32)>> = Vec::new();
1054        // Save and restore the outer clip (normally None at the top level).
1055        let outer_clip = self.clip;
1056
1057        // Widget clip tracked SEPARATELY from `self.clip` (D109): `self.clip`
1058        // includes the damage clip on partial-repaint frames, which must
1059        // bound CPU pixel writes but must NOT crop GPU shader quads — a quad
1060        // redraws in full at every present. This stack holds only the
1061        // picture's own PushClip rects, in physical px (x, y, w, h).
1062        let mut widget_clip: Option<(f32, f32, f32, f32)> = None;
1063        let mut widget_clip_stack: Vec<Option<(f32, f32, f32, f32)>> = Vec::new();
1064
1065        for cmd in &picture.commands {
1066            match cmd {
1067                DrawCommand::PushClip { rect } => {
1068                    let r = sr(*rect);
1069                    let x0 = r.origin.x as i32;
1070                    let y0 = r.origin.y as i32;
1071                    let x1 = (r.origin.x + r.size.width) as i32;
1072                    let y1 = (r.origin.y + r.size.height) as i32;
1073                    let new_clip = if let Some((cx, cy, cr, cb)) = self.clip {
1074                        // Intersect with the already-active clip.
1075                        let ix0 = x0.max(cx);
1076                        let iy0 = y0.max(cy);
1077                        let ix1 = x1.min(cr);
1078                        let iy1 = y1.min(cb);
1079                        if ix1 > ix0 && iy1 > iy0 { Some((ix0, iy0, ix1, iy1)) } else { None }
1080                    } else {
1081                        if x1 > x0 && y1 > y0 { Some((x0, y0, x1, y1)) } else { None }
1082                    };
1083                    clip_stack.push(self.clip);
1084                    self.clip = new_clip;
1085
1086                    widget_clip_stack.push(widget_clip);
1087                    widget_clip = match widget_clip {
1088                        Some((wx, wy, ww, wh)) => {
1089                            let ix0 = r.origin.x.max(wx);
1090                            let iy0 = r.origin.y.max(wy);
1091                            let ix1 = (r.origin.x + r.size.width).min(wx + ww);
1092                            let iy1 = (r.origin.y + r.size.height).min(wy + wh);
1093                            if ix1 > ix0 && iy1 > iy0 {
1094                                Some((ix0, iy0, ix1 - ix0, iy1 - iy0))
1095                            } else {
1096                                // Empty intersection — degenerate zero-area
1097                                // clip so quads inside it draw nothing.
1098                                Some((ix0, iy0, 0.0, 0.0))
1099                            }
1100                        }
1101                        None => Some((r.origin.x, r.origin.y, r.size.width, r.size.height)),
1102                    };
1103                }
1104
1105                DrawCommand::PopClip => {
1106                    // pop() returns Option<Option<...>>; unwrap_or restores None on underflow.
1107                    self.clip = clip_stack.pop().unwrap_or(None);
1108                    widget_clip = widget_clip_stack.pop().unwrap_or(None);
1109                }
1110
1111                DrawCommand::FillRect { rect, color } => {
1112                    if self.gpu_shapes {
1113                        let r = sr(*rect);
1114                        let (q, u) = crate::gpu_shapes::fill_rrect_quad(
1115                            (r.origin.x, r.origin.y, r.size.width, r.size.height),
1116                            0.0, color.rgba_bytes(),
1117                        );
1118                        self.push_builtin_quad(crate::gpu_shapes::FILL_RRECT_ID, q, u, widget_clip);
1119                    } else {
1120                        self.fill_rect(sr(*rect), *color);
1121                    }
1122                }
1123                DrawCommand::StrokeRect { rect, color, width } => {
1124                    if self.gpu_shapes {
1125                        let r = sr(*rect);
1126                        let (q, u) = crate::gpu_shapes::stroke_rrect_quad(
1127                            (r.origin.x, r.origin.y, r.size.width, r.size.height),
1128                            0.0, *width * s, color.rgba_bytes(),
1129                        );
1130                        self.push_builtin_quad(crate::gpu_shapes::STROKE_RRECT_ID, q, u, widget_clip);
1131                    } else {
1132                        self.stroke_rect(sr(*rect), *color, *width * s);
1133                    }
1134                }
1135                DrawCommand::FillRRect { rect, radius, color } => {
1136                    if self.gpu_shapes {
1137                        let r = sr(*rect);
1138                        let (q, u) = crate::gpu_shapes::fill_rrect_quad(
1139                            (r.origin.x, r.origin.y, r.size.width, r.size.height),
1140                            *radius * s, color.rgba_bytes(),
1141                        );
1142                        self.push_builtin_quad(crate::gpu_shapes::FILL_RRECT_ID, q, u, widget_clip);
1143                    } else {
1144                        self.fill_rrect(sr(*rect), *radius * s, *color);
1145                    }
1146                }
1147                DrawCommand::StrokeRRect { rect, radius, color, width } => {
1148                    if self.gpu_shapes {
1149                        let r = sr(*rect);
1150                        let (q, u) = crate::gpu_shapes::stroke_rrect_quad(
1151                            (r.origin.x, r.origin.y, r.size.width, r.size.height),
1152                            *radius * s, *width * s, color.rgba_bytes(),
1153                        );
1154                        self.push_builtin_quad(crate::gpu_shapes::STROKE_RRECT_ID, q, u, widget_clip);
1155                    } else {
1156                        self.stroke_rrect(sr(*rect), *radius * s, *color, *width * s);
1157                    }
1158                }
1159                DrawCommand::FillCircle { center, radius, color } => {
1160                    if self.gpu_shapes {
1161                        // A circle is a square rrect at full corner radius.
1162                        let c = sp(*center);
1163                        let r = *radius * s;
1164                        let (q, u) = crate::gpu_shapes::fill_rrect_quad(
1165                            (c.x - r, c.y - r, r * 2.0, r * 2.0), r, color.rgba_bytes(),
1166                        );
1167                        self.push_builtin_quad(crate::gpu_shapes::FILL_RRECT_ID, q, u, widget_clip);
1168                    } else {
1169                        self.fill_circle(sp(*center), *radius * s, *color);
1170                    }
1171                }
1172                DrawCommand::FillGradient { rect, radius, from, to, vertical } => {
1173                    if self.gpu_shapes {
1174                        let r = sr(*rect);
1175                        let (q, u) = crate::gpu_shapes::gradient_quad(
1176                            (r.origin.x, r.origin.y, r.size.width, r.size.height),
1177                            *radius * s, from.rgba_bytes(), to.rgba_bytes(), *vertical,
1178                        );
1179                        self.push_builtin_quad(crate::gpu_shapes::GRADIENT_ID, q, u, widget_clip);
1180                    } else {
1181                        self.fill_gradient(sr(*rect), *radius * s, *from, *to, *vertical);
1182                    }
1183                }
1184                DrawCommand::FillArc { center, radius, thickness, start_deg, sweep_deg, color } => {
1185                    if self.gpu_shapes {
1186                        let c = sp(*center);
1187                        let (q, u) = crate::gpu_shapes::arc_quad(
1188                            (c.x, c.y), *radius * s, *thickness * s,
1189                            *start_deg, *sweep_deg, color.rgba_bytes(),
1190                        );
1191                        self.push_builtin_quad(crate::gpu_shapes::ARC_ID, q, u, widget_clip);
1192                    } else {
1193                        self.fill_arc(sp(*center), *radius * s, *thickness * s, *start_deg, *sweep_deg, *color);
1194                    }
1195                }
1196                DrawCommand::DrawShadow { rect, radius, color, blur } => {
1197                    if self.gpu_shapes {
1198                        let r = sr(*rect);
1199                        let (q, u) = crate::gpu_shapes::shadow_quad(
1200                            (r.origin.x, r.origin.y, r.size.width, r.size.height),
1201                            *radius * s, *blur * s, color.rgba_bytes(),
1202                        );
1203                        self.push_builtin_quad(crate::gpu_shapes::SHADOW_ID, q, u, widget_clip);
1204                    } else {
1205                        self.draw_shadow(sr(*rect), *radius * s, *color, *blur * s);
1206                    }
1207                }
1208                DrawCommand::DrawText { text, origin, color, px, weight } => {
1209                    let o = sp(*origin);
1210                    let pxp = *px * s;
1211                    if self.gpu_shapes {
1212                        // Step 4: text renders as atlas glyph quads — the
1213                        // SAME layout walk as the CPU path, so placement is
1214                        // identical by construction. Cut any open CPU
1215                        // segment first (z-order), then coalesce with an
1216                        // immediately-preceding Glyphs item under the same
1217                        // clip (batching without reordering).
1218                        if color.a == 0 || text.is_empty() { continue; }
1219                        let placed = crate::font::layout_glyphs(
1220                            font, text, o.x, o.y, pxp, *weight,
1221                        );
1222                        let rgba = color.rgba_bytes();
1223                        // Color-emoji glyphs (Phase 32 Step 4) don't belong in
1224                        // the coverage-atlas Glyphs batch at all — split them
1225                        // out and push each as its own image quad, reusing
1226                        // the SAME `CanvasFrameItem::Image` kind `BlitRgba`
1227                        // already uses (content-keyed, cached, zero
1228                        // re-upload once seen), rather than adding a second
1229                        // atlas page.
1230                        let (color_glyphs, plain): (Vec<_>, Vec<_>) =
1231                            placed.into_iter().partition(|pg| pg.color_rgba.is_some());
1232                        let quads = plain.into_iter().map(|pg| GlyphQuad {
1233                            key: pg.key,
1234                            x: pg.x as f32,
1235                            y: pg.y as f32,
1236                            w: pg.glyph.0.width as u32,
1237                            h: pg.glyph.0.height as u32,
1238                            bitmap: pg.glyph,
1239                            color: rgba,
1240                        });
1241                        self.cut_segment();
1242                        match self.pending_frame_items.last_mut() {
1243                            Some(CanvasFrameItem::Glyphs { glyphs, clip })
1244                                if *clip == widget_clip =>
1245                            {
1246                                glyphs.extend(quads);
1247                            }
1248                            _ => {
1249                                self.pending_frame_items.push(CanvasFrameItem::Glyphs {
1250                                    glyphs: quads.collect(),
1251                                    clip: widget_clip,
1252                                });
1253                            }
1254                        }
1255                        for pg in color_glyphs {
1256                            let cg = pg.color_rgba.unwrap();
1257                            self.pending_frame_items.push(CanvasFrameItem::Image {
1258                                key: (pg.key << 1) | 1, // distinct namespace from blit_key's content hash
1259                                pixels: ImagePixels(std::sync::Arc::clone(&cg.rgba)),
1260                                src_w: cg.width,
1261                                src_h: cg.height,
1262                                dest: (pg.x as f32, pg.y as f32, cg.width as f32, cg.height as f32),
1263                                opacity: 1.0,
1264                                clip: widget_clip,
1265                            });
1266                        }
1267                    } else {
1268                        self.draw_text_weighted(text, o, *color, font, pxp, *weight);
1269                    }
1270                }
1271                DrawCommand::BlitRgba { pixels, src_width, src_height, dest_rect, opacity } => {
1272                    let d = sr(*dest_rect);
1273                    if self.gpu_shapes {
1274                        // Image textures (D109): uploaded once per distinct
1275                        // content, drawn as a textured quad — no per-frame
1276                        // CPU copy. Keyed by content, so any blit source
1277                        // (Image widget, Hero capture, RemoteImage) gets
1278                        // caching without carrying an id.
1279                        self.cut_segment();
1280                        self.pending_frame_items.push(CanvasFrameItem::Image {
1281                            key: blit_key(pixels, *src_width, *src_height),
1282                            pixels: ImagePixels(pixels.clone()),
1283                            src_w: *src_width,
1284                            src_h: *src_height,
1285                            dest: (d.origin.x, d.origin.y, d.size.width, d.size.height),
1286                            opacity: *opacity,
1287                            clip: widget_clip,
1288                        });
1289                    } else {
1290                        self.blit_rgba(pixels, *src_width, *src_height, d, *opacity);
1291                    }
1292                }
1293                DrawCommand::BackdropBlur { rect, radius, blur, tint } => {
1294                    let r = sr(*rect);
1295                    if self.gpu_shapes {
1296                        self.cut_segment();
1297                        self.pending_frame_items.push(CanvasFrameItem::Backdrop {
1298                            rect: (r.origin.x, r.origin.y, r.size.width, r.size.height),
1299                            radius: *radius * s,
1300                            blur: *blur * s,
1301                            tint: tint.rgba_bytes(),
1302                        });
1303                    } else {
1304                        // CPU fallback: translucent tint, no blur — honest
1305                        // degradation (softbuffer/web have no backdrop pass).
1306                        let a = ((tint.a as f32 * 0.75) as u8).max(90);
1307                        self.fill_rrect(r, *radius * s, Color { r: tint.r, g: tint.g, b: tint.b, a });
1308                    }
1309                }
1310                DrawCommand::ShaderFill { pipeline_id, rect, uniforms, animate_time } => {
1311                    // No CPU rasterization by design — collect for the GPU
1312                    // compositor. Always collected, even on a damage-clipped
1313                    // replay: quads re-render in full every present.
1314                    let r = sr(*rect);
1315                    let quad = (r.origin.x, r.origin.y, r.size.width, r.size.height);
1316                    if self.gpu_shapes {
1317                        self.cut_segment();
1318                        self.pending_frame_items.push(CanvasFrameItem::Shader(ShaderQuadCmd {
1319                            pipeline_id: *pipeline_id,
1320                            rect: quad,
1321                            uniforms: uniforms.clone(),
1322                            clip: widget_clip,
1323                            animate_time: *animate_time,
1324                        }));
1325                    } else {
1326                        self.pending_shader_quads.push(ShaderQuadCmd {
1327                            pipeline_id: *pipeline_id,
1328                            rect: quad,
1329                            uniforms: uniforms.clone(),
1330                            clip: widget_clip,
1331                            animate_time: *animate_time,
1332                        });
1333                    }
1334                }
1335            }
1336        }
1337        // GPU mode: close the trailing CPU segment so the last text/blit
1338        // run of the picture is emitted.
1339        self.cut_segment();
1340
1341        // Restore clip to what it was before play_picture (handles nested calls).
1342        self.clip = outer_clip;
1343    }
1344
1345    /// Blit pre-decoded RGBA pixel data into `dest_rect`.
1346    ///
1347    /// `pixels` must be `src_width × src_height × 4` bytes (straight RGBA).
1348    /// 1:1 blits take a direct row path; scaled blits are sampled bilinearly.
1349    /// Pixels outside the canvas bounds (and current clip) are skipped.
1350    /// `opacity` (0.0-1.0) scales every source pixel's alpha before
1351    /// blending — D108/Phase 26 Step 4's image load-in fade.
1352    pub fn blit_rgba(&mut self, pixels: &[u8], src_w: u32, src_h: u32, dest: Rect, opacity: f32) {
1353        if src_w == 0 || src_h == 0 || opacity <= 0.0 { return; }
1354        let opacity = opacity.min(1.0);
1355        let cw = self.pixmap.width() as i32;
1356        let ch = self.pixmap.height() as i32;
1357
1358        let dx = dest.origin.x.round() as i32;
1359        let dy = dest.origin.y.round() as i32;
1360        let dw = dest.size.width.round() as i32;
1361        let dh = dest.size.height.round() as i32;
1362        if dw <= 0 || dh <= 0 { return; }
1363
1364        // Merge canvas bounds with active clip into a single test region.
1365        let (cx0, cy0, cx1, cy1) = match self.clip {
1366            Some((cx, cy, cr, cb)) => (cx.max(0), cy.max(0), cr.min(cw), cb.min(ch)),
1367            None                   => (0, 0, cw, ch),
1368        };
1369        if cx1 <= cx0 || cy1 <= cy0 { return; }
1370
1371        let exact = dw == src_w as i32 && dh == src_h as i32;
1372        let dst = self.pixmap.data_mut();
1373
1374        for row in 0..dh {
1375            let py = dy + row;
1376            if py < cy0 || py >= cy1 { continue; }
1377            let row_base = (py * cw) as usize * 4;
1378
1379            // Vertical source coordinate (bilinear when scaling).
1380            let (sy0, sy1, wy) = if exact {
1381                (row as usize, row as usize, 0.0f32)
1382            } else {
1383                let fy = ((row as f32 + 0.5) * src_h as f32 / dh as f32 - 0.5)
1384                    .clamp(0.0, (src_h - 1) as f32);
1385                let y0 = fy as usize;
1386                (y0, (y0 + 1).min(src_h as usize - 1), fy - y0 as f32)
1387            };
1388
1389            for col in 0..dw {
1390                let px = dx + col;
1391                if px < cx0 || px >= cx1 { continue; }
1392
1393                let (r, g, b, a) = if exact {
1394                    let si = (sy0 * src_w as usize + col as usize) * 4;
1395                    (pixels[si] as f32, pixels[si + 1] as f32, pixels[si + 2] as f32, pixels[si + 3] as f32)
1396                } else {
1397                    // Bilinear sample of the four surrounding texels.
1398                    let fx = ((col as f32 + 0.5) * src_w as f32 / dw as f32 - 0.5)
1399                        .clamp(0.0, (src_w - 1) as f32);
1400                    let x0 = fx as usize;
1401                    let x1 = (x0 + 1).min(src_w as usize - 1);
1402                    let wx = fx - x0 as f32;
1403
1404                    let idx = |sx: usize, sy: usize| (sy * src_w as usize + sx) * 4;
1405                    let (i00, i10, i01, i11) = (idx(x0, sy0), idx(x1, sy0), idx(x0, sy1), idx(x1, sy1));
1406                    let lerp2 = |c: usize| {
1407                        let top = pixels[i00 + c] as f32 * (1.0 - wx) + pixels[i10 + c] as f32 * wx;
1408                        let bot = pixels[i01 + c] as f32 * (1.0 - wx) + pixels[i11 + c] as f32 * wx;
1409                        top * (1.0 - wy) + bot * wy
1410                    };
1411                    (lerp2(0), lerp2(1), lerp2(2), lerp2(3))
1412                };
1413
1414                let a = a * opacity;
1415                let alpha = a as u32;
1416                if alpha == 0 { continue; }
1417                let inv = 255 - alpha;
1418                let di = row_base + px as usize * 4;
1419                dst[di]     = d255(r as u32 * alpha + dst[di]     as u32 * inv) as u8;
1420                dst[di + 1] = d255(g as u32 * alpha + dst[di + 1] as u32 * inv) as u8;
1421                dst[di + 2] = d255(b as u32 * alpha + dst[di + 2] as u32 * inv) as u8;
1422                dst[di + 3] = 255;
1423            }
1424        }
1425        self.has_drawn = true;
1426    }
1427
1428    /// Set (or clear) a master clip in LOGICAL pixels — used for
1429    /// damage-rect repaints: fills and replays outside it are culled.
1430    /// `play_picture` treats it as the outer clip and restores it.
1431    pub fn set_logical_clip(&mut self, r: Option<Rect>) {
1432        let s = self.scale;
1433        self.clip = r.map(|r| (
1434            (r.origin.x * s).floor() as i32,
1435            (r.origin.y * s).floor() as i32,
1436            ((r.origin.x + r.size.width) * s).ceil() as i32,
1437            ((r.origin.y + r.size.height) * s).ceil() as i32,
1438        ));
1439    }
1440
1441    /// Fill a LOGICAL-pixel rect (scaled to physical) — damage background.
1442    pub fn fill_logical_rect(&mut self, r: Rect, color: Color) {
1443        let s = self.scale;
1444        self.fill_rect(Rect {
1445            origin: Point { x: r.origin.x * s, y: r.origin.y * s },
1446            size: Size { width: r.size.width * s, height: r.size.height * s },
1447        }, color);
1448    }
1449
1450    /// Returns the raw RGBA pixel data as a byte slice.
1451    pub fn pixels(&self) -> &[u8] {
1452        self.pixmap.data()
1453    }
1454
1455    /// Returns the raw RGBA pixel data as a mutable byte slice.
1456    pub fn pixels_mut(&mut self) -> &mut [u8] {
1457        self.pixmap.data_mut()
1458    }
1459
1460    /// Encode the canvas contents as a PNG byte vector, returning `None` on error.
1461    pub fn encode_png(&self) -> Option<Vec<u8>> {
1462        self.pixmap.encode_png().ok()
1463    }
1464}