muri 0.13.9

Menu Utilities for Rust Interfaces — a cross-platform, fully-styleable tray-icon and popup-menu system (a custom-drawn muda/tray-icon replacement).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
//! The in-house CPU raster surface and its drawing primitives (ADR-0001).
//!
//! muri paints a menu with a deliberately tiny set of raster operations —
//! anti-aliased rounded-rect fills, device-snapped hairline separators, and
//! straight-alpha bitmap blits — so it does not need a general-purpose 2D crate
//! (`tiny-skia`) behind it. This module owns:
//!
//! * [`Framebuffer`] — an owned, **premultiplied-RGBA** pixel buffer, since
//!   that is what both live present paths want (macOS `CGImage`
//!   `PremultipliedLast`, Windows `UpdateLayeredWindow` premultiplied BGRA).
//! * The primitives [`fill_round_rect`], [`fill_rect`], and [`blend_pixel`],
//!   plus PNG [`decode_png`] / [`Framebuffer::encode_png`] on the lean `png`
//!   crate, so `tiny-skia`'s own crates drop out of the tree.
//!
//! Anti-aliasing uses an analytic rounded-box **signed distance field**
//! (`clamp(0.5 - sdf, 0, 1)` coverage). Every blit funnels through
//! [`blend_pixel`], which composites `over` in **linear light** (gamma-correct)
//! so AA edges match a native CoreText/Quartz menu's weight rather than the
//! heavier look naive sRGB-space blending produced (#42).

use crate::geometry::LogicalRect;
use crate::style::Rgba;

/// An owned CPU raster surface: premultiplied RGBA (`R, G, B, A`
/// byte order), row-major, `width * height * 4` bytes. This is the buffer the
/// popup window blits to its surface and the headless snapshot suite encodes to
/// PNG.
#[derive(Clone)]
pub struct Framebuffer {
    width: u32,
    height: u32,
    /// Premultiplied RGBA, row-major.
    data: Vec<u8>,
}

impl std::fmt::Debug for Framebuffer {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Framebuffer")
            .field("width", &self.width)
            .field("height", &self.height)
            .finish_non_exhaustive()
    }
}

impl Framebuffer {
    /// A fully-transparent `width × height` surface.
    pub fn new(width: u32, height: u32) -> Self {
        let (w, h) = (width.max(1), height.max(1));
        Framebuffer {
            width: w,
            height: h,
            data: vec![0u8; (w as usize) * (h as usize) * 4],
        }
    }

    /// Reset the surface to fully transparent at `width × height`, **reusing the
    /// existing pixel buffer** when the dimensions are unchanged (the common
    /// per-frame case: the popup repaints at the same size on every hover). This
    /// avoids a full-size heap alloc + free each frame — only a `memset` of the
    /// existing buffer. Reallocates only when the size actually changes.
    pub fn reset(&mut self, width: u32, height: u32) {
        let (w, h) = (width.max(1), height.max(1));
        let len = (w as usize) * (h as usize) * 4;
        if self.width == w && self.height == h {
            self.data.fill(0);
        } else {
            self.width = w;
            self.height = h;
            self.data.clear();
            self.data.resize(len, 0);
        }
    }

    /// Width in device pixels.
    pub fn width(&self) -> u32 {
        self.width
    }

    /// Height in device pixels.
    pub fn height(&self) -> u32 {
        self.height
    }

    /// The premultiplied-RGBA pixel bytes (`width * height * 4`, row-major).
    /// This is what the present paths source from.
    pub fn pixels(&self) -> &[u8] {
        &self.data
    }

    /// Mutable access to the premultiplied-RGBA pixel bytes.
    pub fn pixels_mut(&mut self) -> &mut [u8] {
        &mut self.data
    }

    /// Fill the whole surface with an opaque-or-not straight-alpha color,
    /// premultiplying as it writes. Used for a solid backdrop (test composites).
    pub fn fill(&mut self, color: Rgba) {
        let a = color.a as u32;
        let (r, g, b) = (
            (color.r as u32 * a / 255) as u8,
            (color.g as u32 * a / 255) as u8,
            (color.b as u32 * a / 255) as u8,
        );
        for px in self.data.chunks_exact_mut(4) {
            px[0] = r;
            px[1] = g;
            px[2] = b;
            px[3] = color.a;
        }
    }

    /// Alpha-composite `src` (premultiplied) over this surface with its top-left
    /// at device-pixel `(x, y)`. Used to composite a parent popup and its flyout
    /// onto one canvas (mirrors the live per-window blit).
    pub fn draw(&mut self, src: &Framebuffer, x: i32, y: i32) {
        let (dw, dh) = (self.width as i32, self.height as i32);
        let (sw, sh) = (src.width as i32, src.height as i32);
        for row in 0..sh {
            let dy = y + row;
            if dy < 0 || dy >= dh {
                continue;
            }
            for col in 0..sw {
                let dx = x + col;
                if dx < 0 || dx >= dw {
                    continue;
                }
                let s = ((row * sw + col) * 4) as usize;
                let sa = src.data[s + 3];
                if sa == 0 {
                    continue;
                }
                // `src` is premultiplied already; blend it over the destination
                // with a straight-`over` on premultiplied operands.
                let d = ((dy * dw + dx) * 4) as usize;
                let inv = 255 - sa as u32;
                for c in 0..4 {
                    let out = src.data[s + c] as u32 + self.data[d + c] as u32 * inv / 255;
                    self.data[d + c] = out.min(255) as u8;
                }
            }
        }
    }

    /// Straight-alpha (un-premultiplied) RGBA copy of the surface — what a PNG
    /// stores, and the space the golden suite compares in.
    pub fn to_straight_rgba(&self) -> Vec<u8> {
        let mut out = vec![0u8; self.data.len()];
        for (dst, src) in out.chunks_exact_mut(4).zip(self.data.chunks_exact(4)) {
            let a = src[3];
            if a == 0 {
                continue;
            }
            let a32 = a as u32;
            dst[0] = (src[0] as u32 * 255 / a32).min(255) as u8;
            dst[1] = (src[1] as u32 * 255 / a32).min(255) as u8;
            dst[2] = (src[2] as u32 * 255 / a32).min(255) as u8;
            dst[3] = a;
        }
        out
    }

    /// Encode the surface as PNG bytes (straight-alpha RGBA8).
    pub fn encode_png(&self) -> Vec<u8> {
        let straight = self.to_straight_rgba();
        let mut out = Vec::new();
        {
            let mut encoder = png::Encoder::new(&mut out, self.width, self.height);
            encoder.set_color(png::ColorType::Rgba);
            encoder.set_depth(png::BitDepth::Eight);
            let mut writer = encoder.write_header().expect("png header");
            writer.write_image_data(&straight).expect("png data");
        }
        out
    }
}

/// Encode straight-alpha RGBA8 pixels (row-major, 4 bytes per pixel) as PNG
/// bytes. Returns `None` when the dimensions are zero or `rgba.len()` does not
/// equal `width * height * 4`.
///
/// This bridges the compat facade's raw-RGBA tray icon
/// ([`Icon::from_rgba`](crate::compat::muda::Icon::from_rgba)) to muri's
/// encoded-bytes [`Icon::Png`](crate::menu::Icon::Png), since every present
/// path (Linux SNI, macOS/Windows) consumes encoded bytes, not raw RGBA.
pub(crate) fn encode_rgba_png(rgba: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
    if width == 0 || height == 0 {
        return None;
    }
    let expected = (width as usize)
        .checked_mul(height as usize)?
        .checked_mul(4)?;
    if rgba.len() != expected {
        return None;
    }
    let mut out = Vec::new();
    {
        let mut encoder = png::Encoder::new(&mut out, width, height);
        encoder.set_color(png::ColorType::Rgba);
        encoder.set_depth(png::BitDepth::Eight);
        let mut writer = encoder.write_header().ok()?;
        writer.write_image_data(rgba).ok()?;
    }
    Some(out)
}

/// sRGB-encoded byte (0..=255) → linear-light intensity (0.0..=1.0), as a 256-
/// entry lookup table. Built once on first blend.
static SRGB_TO_LINEAR: std::sync::LazyLock<[f32; 256]> = std::sync::LazyLock::new(|| {
    let mut t = [0.0f32; 256];
    for (i, v) in t.iter_mut().enumerate() {
        let c = i as f32 / 255.0;
        *v = if c <= 0.04045 {
            c / 12.92
        } else {
            ((c + 0.055) / 1.055).powf(2.4)
        };
    }
    t
});

/// Number of entries in the linear→sRGB table. The encode curve is smooth and
/// the output is only 8-bit, so 4096 buckets keep quantization error below 1 LSB.
const LIN2SRGB_N: usize = 4096;

/// Linear-light intensity (0.0..=1.0) → sRGB-encoded byte (0..=255), precomputed
/// so the blit hot path never calls `powf` per pixel (#17). Built once on first
/// blend.
static LINEAR_TO_SRGB: std::sync::LazyLock<[u8; LIN2SRGB_N]> = std::sync::LazyLock::new(|| {
    let mut t = [0u8; LIN2SRGB_N];
    for (i, v) in t.iter_mut().enumerate() {
        let l = i as f32 / (LIN2SRGB_N as f32 - 1.0);
        let c = if l <= 0.003_130_8 {
            l * 12.92
        } else {
            1.055 * l.powf(1.0 / 2.4) - 0.055
        };
        *v = (c * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
    }
    t
});

/// Linear-light intensity (0.0..=1.0) → sRGB-encoded byte (0..=255), via the
/// precomputed [`LINEAR_TO_SRGB`] table — no per-pixel `powf` (#17).
#[inline]
fn linear_to_srgb_u8(l: f32) -> u8 {
    let idx = (l.clamp(0.0, 1.0) * (LIN2SRGB_N as f32 - 1.0) + 0.5) as usize;
    LINEAR_TO_SRGB[idx.min(LIN2SRGB_N - 1)]
}

/// Font-smoothing coverage exponent applied to **light-on-dark** glyph pixels
/// (#71). `> 1` thins the anti-aliased edge coverage.
///
/// Gamma-correct linear-light AA compositing ([`blend_pixel`]) is
/// polarity-*symmetric*, but macOS text rendering is not: CoreText/Quartz apply
/// luminance-dependent font smoothing so glyph weight looks consistent in both
/// polarities. A fixed linear blend fixes dark-on-light (#42) but *overshoots*
/// light-on-dark, fattening strokes until neighbouring letters merge; thinning
/// restores per-letter separation to match a native `NSMenu`.
///
/// DEVICE-VERIFY(0.12.2): tune against a native `NSMenu` (the "8 separated ink
/// runs, not 3 merged blobs" test for light-weight `matthew` on a dark menu).
const TEXT_SMOOTHING_GAMMA: f32 = 1.45;

/// LUT of the thinned coverage `round(255 * (cov/255)^TEXT_SMOOTHING_GAMMA)` for
/// every 8-bit coverage — so the light-on-dark glyph path costs a table lookup,
/// not a per-pixel `powf`.
static LIGHT_ON_DARK_COVERAGE: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
    let mut t = [0u8; 256];
    for (i, v) in t.iter_mut().enumerate() {
        let c = (i as f32 / 255.0).powf(TEXT_SMOOTHING_GAMMA);
        *v = (c * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
    }
    t
});

/// Perceptual luminance proxy of an sRGB triple, as a scaled integer (Rec. 709
/// weights ×10000) — only ever compared against another such value, so the sRGB
/// encoding and the fixed scale cancel out. Used to decide glyph polarity.
#[inline]
fn luma_scaled(r: u8, g: u8, b: u8) -> u32 {
    2126 * r as u32 + 7152 * g as u32 + 722 * b as u32
}

/// Polarity-aware font-smoothing of a glyph mask's coverage `cov` for the pixel
/// at byte offset `off` (#71): when foreground `fg` is **lighter** than the
/// destination beneath it, coverage is thinned by [`TEXT_SMOOTHING_GAMMA`] so AA
/// edges don't overshoot vs macOS smoothing; otherwise it is unchanged. Applied
/// only to glyph masks — fills, separators, and icons keep the plain linear blend.
#[inline]
/// Test-only convenience wrapper computing the foreground luma inline; the hot
/// glyph blit uses [`smooth_glyph_coverage_fg_lum`] with the luma hoisted out.
#[cfg(test)]
pub(crate) fn smooth_glyph_coverage(cov: u8, fg: Rgba, dst: &[u8], off: usize) -> u8 {
    smooth_glyph_coverage_fg_lum(cov, fg_luma(fg), dst, off)
}

/// The foreground luma (see [`smooth_glyph_coverage`]) for a run color, computed
/// once so the per-pixel glyph blit can hoist it out of its inner loop (`fg` is
/// constant for the whole run). Perf: avoids recomputing the constant weighted
/// luma on every anti-aliased glyph pixel.
pub(crate) fn fg_luma(fg: Rgba) -> u32 {
    luma_scaled(fg.r, fg.g, fg.b)
}

/// [`smooth_glyph_coverage`] with the foreground luma precomputed by the caller
/// (via [`fg_luma`]). Only `bg_lum` varies per pixel.
pub(crate) fn smooth_glyph_coverage_fg_lum(cov: u8, fg_lum: u32, dst: &[u8], off: usize) -> u8 {
    if cov == 0 || cov == 255 {
        return cov;
    }
    let bg_lum = luma_scaled(dst[off], dst[off + 1], dst[off + 2]);
    if fg_lum > bg_lum {
        LIGHT_ON_DARK_COVERAGE[cov as usize]
    } else {
        cov
    }
}

/// Blend a straight-alpha source color, scaled by coverage `a`, over one
/// premultiplied destination pixel at byte offset `off`, **gamma-correctly**:
/// the `over` compositing is done in linear light, not directly on the sRGB-
/// encoded bytes (#42).
///
/// Naive sRGB-space blending leaves AA edge pixels too dark — dark-on-light text
/// renders heavier than a native CoreText/Quartz menu, which composites in
/// linear light. This linearizes both operands, blends in linear premultiplied
/// space, then re-encodes to premultiplied sRGB storage. Applies to every blit
/// that funnels through here (glyph masks, fills, separators, image/icon blits).
#[inline]
pub(crate) fn blend_pixel(dst: &mut [u8], off: usize, src: Rgba, a: u8) {
    if a == 0 {
        return;
    }
    if a == 255 {
        // Fully covering: the result is exactly the (premultiplied-by-1) source.
        // Skip the linear round-trip — it's an identity here, and avoids the LUT's
        // sub-LSB quantization on the common opaque-fill/separator path (#18).
        dst[off] = src.r;
        dst[off + 1] = src.g;
        dst[off + 2] = src.b;
        dst[off + 3] = 255;
        return;
    }
    if dst[off + 3] == 255 {
        // Opaque destination (`da == 1`): the general branch's divide/re-premultiply
        // steps all collapse to identities, so composite directly in linear light —
        // bit-identical to the branch below, and the common opaque-background path.
        let lut = &*SRGB_TO_LINEAR;
        let sa = a as f32 / 255.0;
        let inv = 1.0 - sa;
        let src_ch = [src.r, src.g, src.b];
        for c in 0..3 {
            let s_lin_pm = lut[src_ch[c] as usize] * sa;
            let d_lin = lut[dst[off + c] as usize];
            dst[off + c] = linear_to_srgb_u8(s_lin_pm + d_lin * inv);
        }
        dst[off + 3] = 255;
        return;
    }
    let lut = &*SRGB_TO_LINEAR;
    let sa = a as f32 / 255.0;
    let da = dst[off + 3] as f32 / 255.0;
    let inv = 1.0 - sa;
    let out_a = sa + da * inv;
    if out_a <= 0.0 {
        dst[off] = 0;
        dst[off + 1] = 0;
        dst[off + 2] = 0;
        dst[off + 3] = 0;
        return;
    }
    let src_ch = [src.r, src.g, src.b];
    for c in 0..3 {
        // Source: straight sRGB → linear, premultiplied by coverage.
        let s_lin_pm = lut[src_ch[c] as usize] * sa;
        // Destination: premultiplied sRGB → unpremultiply → linear → re-premultiply.
        let d_lin_pm = if da > 0.0 {
            let straight = (dst[off + c] as f32 / da).min(255.0);
            lut[straight.round() as usize] * da
        } else {
            0.0
        };
        // Composite in linear premultiplied space, then back to premultiplied sRGB.
        let out_lin_pm = s_lin_pm + d_lin_pm * inv;
        let out_srgb_straight = linear_to_srgb_u8(out_lin_pm / out_a) as f32;
        dst[off + c] = (out_srgb_straight * out_a).round().clamp(0.0, 255.0) as u8;
    }
    dst[off + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
}

/// Signed distance (in device pixels) from point `(px, py)` to a rounded rect
/// `[x, y, x+w, y+h]` with corner radius `r`; negative inside.
#[inline]
fn round_rect_sdf(px: f32, py: f32, x: f32, y: f32, w: f32, h: f32, r: f32) -> f32 {
    let cx = x + w / 2.0;
    let cy = y + h / 2.0;
    let qx = (px - cx).abs() - (w / 2.0 - r);
    let qy = (py - cy).abs() - (h / 2.0 - r);
    let ox = qx.max(0.0);
    let oy = qy.max(0.0);
    let outside = (ox * ox + oy * oy).sqrt();
    let inside = qx.max(qy).min(0.0);
    outside + inside - r
}

/// Fill an anti-aliased rounded rectangle (device pixels) into `fb` in the given
/// straight-alpha `color`. `r` is clamped to `min(w/2, h/2)`; `r ≈ 0` yields a
/// pixel-crisp rectangle with a 1px AA edge, matching the old fill's behavior.
pub(crate) fn fill_round_rect(
    fb: &mut Framebuffer,
    x: f32,
    y: f32,
    w: f32,
    h: f32,
    r: f32,
    color: Rgba,
) {
    if w <= 0.0 || h <= 0.0 || color.a == 0 {
        return;
    }
    let r = r.min(w / 2.0).min(h / 2.0).max(0.0);
    let (fw, fh) = (fb.width as i32, fb.height as i32);
    // Bounding box, padded 1px for the AA band, clamped to the surface.
    let x0 = ((x.floor() as i32) - 1).clamp(0, fw);
    let y0 = ((y.floor() as i32) - 1).clamp(0, fh);
    let x1 = (((x + w).ceil() as i32) + 1).clamp(0, fw);
    let y1 = (((y + h).ceil() as i32) + 1).clamp(0, fh);
    let ca = color.a as f32;
    let pixels = fb.pixels_mut();
    for py in y0..y1 {
        for px in x0..x1 {
            let d = round_rect_sdf(px as f32 + 0.5, py as f32 + 0.5, x, y, w, h, r);
            let cov = (0.5 - d).clamp(0.0, 1.0);
            if cov <= 0.0 {
                continue;
            }
            let a = (ca * cov).round() as u8;
            if a == 0 {
                continue;
            }
            blend_pixel(pixels, ((py * fw + px) * 4) as usize, color, a);
        }
    }
}

/// Fill an aliased, device-snapped rectangle (device pixels rounded to the pixel
/// grid) — the hairline separator primitive. `color` is straight-alpha.
pub(crate) fn fill_rect(fb: &mut Framebuffer, x: f32, y: f32, w: f32, h: f32, color: Rgba) {
    if color.a == 0 {
        return;
    }
    let (fw, fh) = (fb.width as i32, fb.height as i32);
    let x0 = (x.round() as i32).clamp(0, fw);
    let y0 = (y.round() as i32).clamp(0, fh);
    let x1 = ((x + w).round() as i32).clamp(0, fw);
    let y1 = ((y + h).round() as i32).clamp(0, fh);
    let pixels = fb.pixels_mut();
    for py in y0..y1 {
        for px in x0..x1 {
            blend_pixel(pixels, ((py * fw + px) * 4) as usize, color, color.a);
        }
    }
}

/// Hard cap on either PNG dimension `decode_png` will decode. muri only ever
/// decodes small menu/tray icons (16px logical, a handful of device pixels at
/// most even at very high DPI); anything beyond this is not a legitimate icon
/// and is refused *before* any pixel buffer is allocated, so a crafted PNG
/// header with huge IHDR dimensions (a malicious `Icon::Png` reachable from
/// Windows tray `HICON` decode, Linux SNI icons, and menu-row leading icons)
/// cannot force a multi-GB allocation (DoS).
const MAX_ICON_DIM: u32 = 4096;

/// Hard cap on total pixel count (`width * height`), checked alongside
/// [`MAX_ICON_DIM`] and set well below `MAX_ICON_DIM²` so a large-but-square
/// (or wide-but-short / tall-but-thin) image that individually satisfies the
/// per-dimension cap still can't force an excessive allocation — a real
/// menu/tray icon, even generously upscaled for a very high DPI, never
/// approaches this.
const MAX_ICON_PIXELS: u64 = 2048 * 2048;

/// Decode a raster PNG to straight-alpha RGBA (`R, G, B, A` per pixel), returning
/// `(rgba, width, height)` or `None` for non-PNG / undecodable bytes. Palette,
/// grayscale, and 16-bit inputs are normalized to 8-bit RGBA.
///
/// Dimensions are validated against internal size caps from the IHDR-derived
/// [`png::Info`] *before* any pixel buffer is allocated, so oversized/malicious
/// inputs are rejected cheaply.
pub fn decode_png(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
    let mut decoder = png::Decoder::new(bytes);
    decoder.set_transformations(png::Transformations::normalize_to_color8());
    let mut reader = decoder.read_info().ok()?;
    let hdr = reader.info();
    let (hw, hh) = (hdr.width, hdr.height);
    if hw == 0 || hh == 0 || hw > MAX_ICON_DIM || hh > MAX_ICON_DIM {
        return None;
    }
    if (hw as u64) * (hh as u64) > MAX_ICON_PIXELS {
        return None;
    }
    let mut buf = vec![0u8; reader.output_buffer_size()];
    let info = reader.next_frame(&mut buf).ok()?;
    let (w, h) = (info.width, info.height);
    let count = (w as usize).checked_mul(h as usize)?;
    let mut out = vec![0u8; count * 4];
    match info.color_type {
        png::ColorType::Rgba => {
            out.copy_from_slice(&buf[..count * 4]);
        }
        png::ColorType::Rgb => {
            for (dst, src) in out.chunks_exact_mut(4).zip(buf.chunks_exact(3)) {
                dst[0] = src[0];
                dst[1] = src[1];
                dst[2] = src[2];
                dst[3] = 255;
            }
        }
        png::ColorType::GrayscaleAlpha => {
            for (dst, src) in out.chunks_exact_mut(4).zip(buf.chunks_exact(2)) {
                dst[0] = src[0];
                dst[1] = src[0];
                dst[2] = src[0];
                dst[3] = src[1];
            }
        }
        png::ColorType::Grayscale => {
            for (dst, &g) in out.chunks_exact_mut(4).zip(buf.iter()) {
                dst[0] = g;
                dst[1] = g;
                dst[2] = g;
                dst[3] = 255;
            }
        }
        // `normalize_to_color8` expands Indexed to Rgb/Rgba, so it never reaches
        // here; treat anything unexpected as undecodable rather than panicking.
        png::ColorType::Indexed => return None,
    }
    Some((out, w, h))
}

/// Convenience: the device-pixel bounds of a logical rect scaled by `scale`,
/// returned as `(x, y, w, h)` in device pixels. Kept here so the drawer's scale
/// math has one home.
#[inline]
pub(crate) fn scaled(rect: LogicalRect, scale: f32) -> (f32, f32, f32, f32) {
    (
        rect.origin.x * scale,
        rect.origin.y * scale,
        rect.size.width * scale,
        rect.size.height * scale,
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    /// #71: the polarity-aware font smoothing thins glyph coverage **only** when
    /// the foreground is lighter than the pixel beneath it (light-on-dark), so
    /// light-on-dark text doesn't overshoot; dark-on-light is left untouched (its
    /// linear blend already matches native), and the 0/255 endpoints never move.
    #[test]
    fn glyph_coverage_thins_light_on_dark_only() {
        let white = Rgba::new(255, 255, 255, 255);
        let black = Rgba::new(0, 0, 0, 255);
        let dark_dst = [10u8, 10, 10, 255];
        let light_dst = [240u8, 240, 240, 255];

        // Light glyph over a dark pixel: mid coverage is thinned (strictly less).
        let thinned = smooth_glyph_coverage(128, white, &dark_dst, 0);
        assert!(
            thinned < 128,
            "light-on-dark mid coverage must thin, got {thinned}"
        );
        // Dark glyph over a light pixel: coverage unchanged.
        assert_eq!(smooth_glyph_coverage(128, black, &light_dst, 0), 128);
        // Endpoints never move, in either polarity.
        assert_eq!(smooth_glyph_coverage(0, white, &dark_dst, 0), 0);
        assert_eq!(smooth_glyph_coverage(255, white, &dark_dst, 0), 255);
        // Monotonic: heavier input coverage still yields heavier (or equal) output.
        let a = smooth_glyph_coverage(64, white, &dark_dst, 0);
        let b = smooth_glyph_coverage(192, white, &dark_dst, 0);
        assert!(a <= b, "thinning must stay monotonic ({a} !<= {b})");
    }

    #[test]
    fn blend_is_gamma_correct_not_naive_srgb() {
        // Black text at 50% coverage over opaque white: gamma-correct
        // compositing lands near sRGB 188, not the naive midpoint 128 (#42).
        let mut dst = [255u8, 255, 255, 255];
        blend_pixel(&mut dst, 0, Rgba::new(0, 0, 0, 255), 127);
        assert!(
            (185..=191).contains(&dst[0]),
            "expected gamma-correct ~188, got {} (128 would be the old naive bug)",
            dst[0]
        );
        assert_eq!(dst[0], dst[1]);
        assert_eq!(dst[1], dst[2]);
        assert_eq!(dst[3], 255, "opaque over opaque stays opaque");
    }

    #[test]
    fn blend_preserves_opaque_endpoints() {
        // Full coverage replaces the destination with the (premultiplied) source;
        // zero coverage is a no-op.
        let mut dst = [10u8, 20, 30, 255];
        blend_pixel(&mut dst, 0, Rgba::new(200, 100, 50, 255), 255);
        assert_eq!(&dst[..3], &[200, 100, 50]);
        let mut untouched = [10u8, 20, 30, 255];
        blend_pixel(&mut untouched, 0, Rgba::new(0, 0, 0, 255), 0);
        assert_eq!(untouched, [10, 20, 30, 255]);
    }

    #[test]
    fn blend_opaque_dst_fast_path_matches_reference_unpremultiply() {
        // The opaque-destination fast path must be bit-identical to the general
        // branch; replicate that math independently and sweep src × coverage over
        // opaque backgrounds so any divergence would be caught, not silently
        // shift AA pixels and move goldens.
        fn reference_general(dst: [u8; 4], src: Rgba, a: u8) -> [u8; 4] {
            if a == 0 {
                return dst;
            }
            let lut = &*SRGB_TO_LINEAR;
            let sa = a as f32 / 255.0;
            let da = dst[3] as f32 / 255.0;
            let inv = 1.0 - sa;
            let out_a = sa + da * inv;
            if out_a <= 0.0 {
                return [0, 0, 0, 0];
            }
            let mut out = dst;
            let src_ch = [src.r, src.g, src.b];
            for c in 0..3 {
                let s_lin_pm = lut[src_ch[c] as usize] * sa;
                let d_lin_pm = if da > 0.0 {
                    let straight = (dst[c] as f32 / da).min(255.0);
                    lut[straight.round() as usize] * da
                } else {
                    0.0
                };
                let out_lin_pm = s_lin_pm + d_lin_pm * inv;
                let out_srgb_straight = linear_to_srgb_u8(out_lin_pm / out_a) as f32;
                out[c] = (out_srgb_straight * out_a).round().clamp(0.0, 255.0) as u8;
            }
            out[3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
            out
        }
        for &dc in &[0u8, 1, 17, 64, 128, 200, 254, 255] {
            let dst0 = [dc, dc.wrapping_mul(3), dc ^ 0x5A, 255];
            for &(sr, sg, sb) in &[
                (0u8, 0u8, 0u8),
                (255, 255, 255),
                (200, 100, 50),
                (13, 200, 77),
            ] {
                for a in 1u8..=254 {
                    let mut got = dst0;
                    blend_pixel(&mut got, 0, Rgba::new(sr, sg, sb, 255), a);
                    let want = reference_general(dst0, Rgba::new(sr, sg, sb, 255), a);
                    assert_eq!(
                        got, want,
                        "fast path diverged at dst={dst0:?} src=({sr},{sg},{sb}) a={a}"
                    );
                }
            }
        }
    }

    #[test]
    fn blend_non_opaque_dst_is_unchanged_by_the_fast_path() {
        // The fast path must only trigger for a fully-opaque destination; a
        // translucent destination must still take the general branch. Blending
        // over a half-alpha destination should raise its alpha above the original.
        let mut dst = [10u8, 20, 30, 128];
        blend_pixel(&mut dst, 0, Rgba::new(200, 100, 50, 255), 90);
        assert!(
            dst[3] > 128,
            "translucent dst alpha should increase, got {}",
            dst[3]
        );
    }

    /// The standard PNG chunk CRC (CRC-32/ISO-HDLC over the chunk's type+data
    /// bytes), needed to hand-build a syntactically valid IHDR chunk for the
    /// oversize-dimension test below.
    fn png_crc32(bytes: &[u8]) -> u32 {
        let mut crc: u32 = 0xFFFF_FFFF;
        for &b in bytes {
            crc ^= b as u32;
            for _ in 0..8 {
                let mask = (crc & 1).wrapping_neg();
                crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
            }
        }
        !crc
    }

    /// A syntactically valid PNG byte stream containing *only* a signature +
    /// IHDR chunk declaring `w × h` (no `IDAT`/`IEND`). `decode_png`'s
    /// dimension cap is checked right after `read_info()` parses the IHDR —
    /// before any pixel buffer is allocated — so this is sufficient to drive
    /// that check without needing a real (potentially huge) encoded image.
    fn png_header_with_dims(w: u32, h: u32) -> Vec<u8> {
        let mut out = Vec::new();
        out.extend_from_slice(&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1A, b'\n']);
        let mut chunk = Vec::new();
        chunk.extend_from_slice(b"IHDR");
        chunk.extend_from_slice(&w.to_be_bytes());
        chunk.extend_from_slice(&h.to_be_bytes());
        chunk.push(8); // bit depth
        chunk.push(6); // color type: RGBA
        chunk.push(0); // compression method
        chunk.push(0); // filter method
        chunk.push(0); // interlace method
        out.extend_from_slice(&13u32.to_be_bytes()); // IHDR data length
        out.extend_from_slice(&chunk);
        out.extend_from_slice(&png_crc32(&chunk).to_be_bytes());
        out
    }

    #[test]
    fn decode_png_rejects_oversize_dimensions_before_allocating() {
        // Well past MAX_ICON_DIM; a naive alloc here would be ~14.4 GB — the
        // DoS this cap exists to prevent.
        let bytes = png_header_with_dims(60_000, 60_000);
        assert!(decode_png(&bytes).is_none());
    }

    #[test]
    fn decode_png_rejects_a_square_image_over_the_pixel_cap_but_under_the_dim_cap() {
        // 3000x3000 is under MAX_ICON_DIM individually but over MAX_ICON_PIXELS —
        // caught only by the *pixel* cap, proving it's a real, independent check.
        let bytes = png_header_with_dims(3000, 3000);
        const { assert!((3000u64 * 3000) <= MAX_ICON_DIM as u64 * MAX_ICON_DIM as u64) };
        const { assert!((3000u64 * 3000) > MAX_ICON_PIXELS) };
        assert!(decode_png(&bytes).is_none());
    }

    #[test]
    fn decode_png_rejects_malformed_bytes() {
        assert!(decode_png(&[]).is_none());
        assert!(decode_png(b"not a png at all").is_none());
        assert!(decode_png(&[0x89, b'P', b'N', b'G']).is_none()); // truncated signature
    }

    #[test]
    fn decode_png_roundtrips_a_small_real_image() {
        let mut fb = Framebuffer::new(2, 2);
        fb.fill(Rgba::opaque(255, 0, 0));
        let bytes = fb.encode_png();
        let (rgba, w, h) = decode_png(&bytes).expect("a real, small PNG decodes");
        assert_eq!((w, h), (2, 2));
        assert_eq!(rgba.len(), 2 * 2 * 4);
        assert_eq!(&rgba[0..4], &[255, 0, 0, 255]);
    }

    #[test]
    fn encode_rgba_png_roundtrips_through_decode() {
        // A 2x2 straight-alpha image with distinct, partly-transparent pixels.
        let rgba = vec![
            255, 0, 0, 255, // opaque red
            0, 255, 0, 128, // half-alpha green
            0, 0, 255, 255, // opaque blue
            9, 8, 7, 0, // fully transparent
        ];
        let png = encode_rgba_png(&rgba, 2, 2).expect("valid RGBA encodes");
        let (decoded, w, h) = decode_png(&png).expect("the encoded PNG decodes");
        assert_eq!((w, h), (2, 2));
        // A fully-transparent pixel's color channels are not preserved by PNG's
        // straight-alpha storage in every codec path, so compare only the pixels
        // with alpha, plus every alpha channel.
        assert_eq!(&decoded[0..8], &rgba[0..8]);
        assert_eq!(&decoded[8..12], &rgba[8..12]);
        assert_eq!(decoded[15], 0, "the transparent pixel stays transparent");
    }

    #[test]
    fn encode_rgba_png_rejects_bad_dimensions_and_lengths() {
        // Zero dimensions.
        assert!(encode_rgba_png(&[0, 0, 0, 0], 0, 1).is_none());
        assert!(encode_rgba_png(&[0, 0, 0, 0], 1, 0).is_none());
        // Length mismatch: 1x1 needs 4 bytes, not 3.
        assert!(encode_rgba_png(&[0, 0, 0], 1, 1).is_none());
        // Length mismatch: 2x2 needs 16 bytes, not 4.
        assert!(encode_rgba_png(&[0, 0, 0, 0], 2, 2).is_none());
    }

    #[test]
    fn fill_round_rect_corners_are_more_transparent_than_center() {
        let mut fb = Framebuffer::new(20, 20);
        fill_round_rect(&mut fb, 2.0, 2.0, 16.0, 16.0, 6.0, Rgba::opaque(10, 20, 30));
        let alpha_at = |fb: &Framebuffer, x: u32, y: u32| -> u8 {
            fb.pixels()[((y * fb.width() + x) * 4 + 3) as usize]
        };
        let center = alpha_at(&fb, 10, 10);
        let corner = alpha_at(&fb, 2, 2); // the rect's bounding-box corner
        assert_eq!(center, 255, "well inside the rect should be fully opaque");
        assert!(
            corner < center,
            "a rounded corner should be more transparent than the center (corner={corner}, center={center})"
        );
    }

    #[test]
    fn fill_round_rect_clamps_radius_to_half_the_smaller_dimension() {
        // r (1000) is absurdly larger than min(w, h)/2 (= 2.0 for an 8x4
        // rect); the fill must clamp rather than produce a nonsensical/negative
        // effective radius or panic.
        let mut fb = Framebuffer::new(10, 10);
        fill_round_rect(&mut fb, 0.0, 0.0, 8.0, 4.0, 1000.0, Rgba::opaque(1, 2, 3));
        let idx = ((2 * fb.width() + 4) * 4 + 3) as usize;
        assert_eq!(
            fb.pixels()[idx],
            255,
            "center of the clamped fill is opaque"
        );
    }
}