Skip to main content

drmkit_csd/
shadow.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Drop shadows, and not drawing the same one twice.
5//!
6//! A shadow is a blurred rounded rectangle. Blurring one is the most
7//! expensive thing a decoration does — three box passes over the whole patch,
8//! every frame, per window — and the result depends only on the patch size,
9//! the elevation, and the theme. So it is cached, and the cache is what makes
10//! decorations affordable rather than the blur being fast.
11
12use std::collections::HashMap;
13
14use crate::{Color, Theme};
15
16/// How much shadow a window casts.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
18#[non_exhaustive]
19pub enum Elevation {
20    /// Unfocused: the same blur at 70% of the theme's shadow alpha, so the
21    /// focused window reads as nearer.
22    #[default]
23    Blurred,
24    /// Focused.
25    Focused,
26}
27
28/// What identifies one cached shadow.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30#[non_exhaustive]
31pub struct ShadowKey {
32    /// Patch width in pixels.
33    pub width: u32,
34    /// Patch height in pixels.
35    pub height: u32,
36    /// Which elevation.
37    pub elevation: Elevation,
38    /// Which theme, by [`theme_id`].
39    pub theme_id: u64,
40}
41
42/// A hash of the theme fields that affect a decoration's pixels.
43///
44/// The same fields upstream hashes, and wider than the shadow alone needs —
45/// the panel, rim, title and button colors and the font size are in it too.
46/// Not of the whole theme: the name, the font stack and the animation
47/// duration cannot change a pixel, so hashing them would evict every cached
48/// shadow when a caller renamed a theme or made its animations quicker — a
49/// cache miss on every window, for nothing.
50///
51/// FNV-1a over the same bytes in the same order as upstream, so the two
52/// produce the same value for the same theme. The value is a cache key rather
53/// than a security boundary. Integers are hashed most significant byte first;
54/// `noise_amplitude` is hashed as its native-endian bytes, as upstream hashes
55/// it, so a theme with noise hashes differently on a big-endian host.
56#[must_use]
57pub fn theme_id(theme: &Theme) -> u64 {
58    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
59    const PRIME: u64 = 0x0000_0100_0000_01b3;
60
61    let mut hash = OFFSET;
62    let byte = |value: u8, hash: &mut u64| {
63        *hash ^= u64::from(value);
64        *hash = hash.wrapping_mul(PRIME);
65    };
66    let u32_field = |value: u32, hash: &mut u64| {
67        // Most significant byte first, as the reference hashes it.
68        for shift in [24, 16, 8, 0] {
69            byte(u8::try_from((value >> shift) & 0xFF).unwrap_or(0), hash);
70        }
71    };
72    let color = |c: Color, hash: &mut u64| {
73        for channel in [c.r, c.g, c.b, c.a] {
74            byte(channel, hash);
75        }
76    };
77
78    u32_field(theme.corner_radius.cast_unsigned(), &mut hash);
79    u32_field(theme.shadow_extent.cast_unsigned(), &mut hash);
80    for b in theme.noise_amplitude.to_ne_bytes() {
81        byte(b, &mut hash);
82    }
83    u32_field(theme.title_bar.height.cast_unsigned(), &mut hash);
84    u32_field(theme.title_bar.font_size.cast_unsigned(), &mut hash);
85    for c in [
86        theme.colors.panel_top,
87        theme.colors.panel_bottom,
88        theme.colors.rim_focused,
89        theme.colors.rim_blurred,
90        theme.colors.shadow,
91        theme.colors.title_text,
92        theme.colors.title_shadow,
93        theme.buttons.close.fill,
94        theme.buttons.close.hover,
95        theme.buttons.minimize.fill,
96        theme.buttons.minimize.hover,
97        theme.buttons.maximize.fill,
98        theme.buttons.maximize.hover,
99    ] {
100        color(c, &mut hash);
101    }
102    hash
103}
104
105/// Where a shadow is being blitted to.
106#[derive(Debug)]
107#[allow(
108    clippy::exhaustive_structs,
109    reason = "built by callers, with no default to fill a new field from"
110)]
111pub struct ShadowDest<'a> {
112    /// The destination pixels, `ARGB8888`.
113    pub pixels: &'a mut [u8],
114    /// Bytes between rows.
115    pub stride: u32,
116    /// Region width in pixels.
117    pub width: u32,
118    /// Region height in pixels.
119    pub height: u32,
120}
121
122/// How many shadows to keep when the caller does not say.
123pub const DEFAULT_CAPACITY: usize = 8;
124
125/// Rendered shadows, most recently used last.
126pub struct ShadowCache {
127    /// Patch pixels by key, `ARGB8888` premultiplied.
128    entries: HashMap<ShadowKey, Vec<u8>>,
129    /// Use order, oldest first. Separate from the map because a `HashMap` has
130    /// no order and the eviction needs one.
131    order: Vec<ShadowKey>,
132    capacity: usize,
133}
134
135impl std::fmt::Debug for ShadowCache {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("ShadowCache")
138            .field("cached", &self.entries.len())
139            .field("capacity", &self.capacity)
140            .finish_non_exhaustive()
141    }
142}
143
144impl Default for ShadowCache {
145    fn default() -> Self {
146        Self::new(DEFAULT_CAPACITY)
147    }
148}
149
150impl ShadowCache {
151    /// A cache holding `capacity` shadows.
152    ///
153    /// Zero is treated as [`DEFAULT_CAPACITY`]: a cache that held nothing
154    /// would re-blur every shadow every frame, which is the cost this type
155    /// exists to remove.
156    #[must_use]
157    pub fn new(capacity: usize) -> Self {
158        Self {
159            entries: HashMap::new(),
160            order: Vec::new(),
161            capacity: if capacity == 0 {
162                DEFAULT_CAPACITY
163            } else {
164                capacity
165            },
166        }
167    }
168
169    /// How many shadows are cached.
170    #[must_use]
171    pub fn len(&self) -> usize {
172        self.entries.len()
173    }
174
175    /// Whether nothing is cached.
176    #[must_use]
177    pub fn is_empty(&self) -> bool {
178        self.entries.is_empty()
179    }
180
181    /// How many it will hold.
182    #[must_use]
183    pub const fn capacity(&self) -> usize {
184        self.capacity
185    }
186
187    /// Whether this shadow is already rendered.
188    #[must_use]
189    pub fn contains(&self, key: &ShadowKey) -> bool {
190        self.entries.contains_key(key)
191    }
192
193    /// Forget everything.
194    ///
195    /// For a theme change that invalidates every shadow at once, where
196    /// evicting one at a time would blur each of them again on the way out.
197    pub fn clear(&mut self) {
198        self.entries.clear();
199        self.order.clear();
200    }
201
202    /// Composite this shadow onto `dst`, rendering it first if it is not
203    /// cached.
204    ///
205    /// Source-over, in premultiplied alpha, as the reference composites it:
206    /// what is already in `dst` shows through where the shadow is not opaque.
207    /// On a cleared buffer that is the same as a copy.
208    ///
209    /// Returns `false`, writing nothing, for a zero-sized key or destination,
210    /// and `true` otherwise — rather than failing: a window being resized
211    /// passes through zero, and a decoration that errored on the way would
212    /// flicker.
213    ///
214    /// A destination smaller than the patch is **clipped**, not refused — the
215    /// alternative is writing past the caller's buffer. Rows that do not fit
216    /// in `pixels` at `stride` are skipped too, and the call still returns
217    /// `true`.
218    pub fn blit_into(&mut self, key: ShadowKey, theme: &Theme, dst: &mut ShadowDest<'_>) -> bool {
219        if key.width == 0 || key.height == 0 || dst.width == 0 || dst.height == 0 {
220            return false;
221        }
222        self.ensure(key, theme);
223        let Some(patch) = self.entries.get(&key) else {
224            return false;
225        };
226
227        let rows = key.height.min(dst.height) as usize;
228        let cols = key.width.min(dst.width) as usize;
229        for y in 0..rows {
230            let from = y * key.width as usize * 4;
231            let to = y * dst.stride as usize;
232            let (Some(src), Some(out)) = (
233                patch.get(from..from + cols * 4),
234                dst.pixels.get_mut(to..to + cols * 4),
235            ) else {
236                // A destination whose stride does not match its own height is
237                // the caller's error, but stopping short beats writing past
238                // the end of their buffer.
239                break;
240            };
241            src_over_row(src, out);
242        }
243        true
244    }
245
246    /// Composite a blend of two shadows, `t` of the way from `a` to `b`, onto
247    /// `dst`.
248    ///
249    /// What a focus change animates through. Both patches are cached, so the
250    /// transition costs a per-pixel lerp rather than a blur per frame — given
251    /// a [`capacity`](Self::capacity) of at least two. With a capacity of
252    /// one, rendering `b` evicts `a`, and a mid-transition blend draws
253    /// nothing and returns `false`. The blend is composited source-over, as
254    /// [`blit_into`](Self::blit_into) is, and the endpoints are exactly
255    /// `blit_into` of one patch.
256    ///
257    /// `t` is clamped to `0..=1`: an animation that overshoots is a caller
258    /// bug, and extrapolating a color past its endpoints produces values
259    /// that are not a shadow at all.
260    ///
261    /// The two keys must be the same size, as the reference requires; keys
262    /// that differ write nothing and return `false`. Otherwise the return is
263    /// as for `blit_into`: `false`, writing nothing, for a zero-sized key or
264    /// destination, and `true` once the blend is composited.
265    pub fn blit_cross_fade(
266        &mut self,
267        a: ShadowKey,
268        b: ShadowKey,
269        theme: &Theme,
270        dst: &mut ShadowDest<'_>,
271        t: f32,
272    ) -> bool {
273        if a.width == 0
274            || a.height == 0
275            || a.width != b.width
276            || a.height != b.height
277            || dst.width == 0
278            || dst.height == 0
279        {
280            return false;
281        }
282        let t = t.clamp(0.0, 1.0);
283        if t <= 0.0 {
284            return self.blit_into(a, theme, dst);
285        }
286        if t >= 1.0 {
287            return self.blit_into(b, theme, dst);
288        }
289        self.ensure(a, theme);
290        self.ensure(b, theme);
291        let (Some(from), Some(to)) = (self.entries.get(&a), self.entries.get(&b)) else {
292            return false;
293        };
294
295        let rows = a.height.min(dst.height) as usize;
296        let cols = a.width.min(dst.width) as usize;
297        let mut blended = vec![0u8; cols * 4];
298        for y in 0..rows {
299            let start = y * a.width as usize * 4;
300            let to_row = y * dst.stride as usize;
301            let (Some(lhs), Some(rhs), Some(out)) = (
302                from.get(start..start + cols * 4),
303                to.get(start..start + cols * 4),
304                dst.pixels.get_mut(to_row..to_row + cols * 4),
305            ) else {
306                break;
307            };
308            for ((mixed, l), r) in blended.iter_mut().zip(lhs).zip(rhs) {
309                *mixed = to_u8(f32::from(*l) + (f32::from(*r) - f32::from(*l)) * t);
310            }
311            src_over_row(&blended, out);
312        }
313        true
314    }
315
316    /// Render this shadow if it is not cached, and mark it most recent.
317    fn ensure(&mut self, key: ShadowKey, theme: &Theme) {
318        if self.entries.contains_key(&key) {
319            // A hit still counts as a use, or the LRU would evict whatever the
320            // caller is drawing every frame and keep whatever it drew once.
321            self.order.retain(|existing| *existing != key);
322            self.order.push(key);
323            return;
324        }
325
326        while self.entries.len() >= self.capacity && !self.order.is_empty() {
327            let oldest = self.order.remove(0);
328            self.entries.remove(&oldest);
329        }
330
331        self.entries.insert(key, render_shadow(key, theme));
332        self.order.push(key);
333    }
334}
335
336/// Composite one row of premultiplied pixels source-over onto `dst`, as the
337/// reference's `src_over_row` does: each channel is the source plus the
338/// destination scaled by what the source leaves uncovered.
339fn src_over_row(src: &[u8], dst: &mut [u8]) {
340    for (s, d) in src.chunks_exact(4).zip(dst.chunks_exact_mut(4)) {
341        let uncovered = 255 - u16::from(s[3]);
342        for (out, add) in d.iter_mut().zip(s) {
343            let kept = u16::from(*out) * uncovered / 255;
344            *out = u8::try_from(u16::from(*add) + kept).unwrap_or(u8::MAX);
345        }
346    }
347}
348
349/// Round a float to a byte, clamping it to `0..=255` first.
350fn to_u8(value: f32) -> u8 {
351    // `round` rather than truncate: truncating biases every channel down by
352    // up to one, which over a whole blurred patch is a visibly lighter shadow.
353    // Stepping through the 256 byte values rather than casting with `as`
354    // keeps clippy's pedantic cast lints quiet without an `allow`; the range
355    // is fixed and tiny, so the search costs little. (A float-to-int `as`
356    // saturates on every target, so the cast would not be wrong, only noisy.)
357    let clamped = value.clamp(0.0, 255.0);
358    u8::try_from(
359        (0u16..=255)
360            .find(|step| f32::from(*step) >= clamped - 0.5)
361            .unwrap_or(255),
362    )
363    .unwrap_or(u8::MAX)
364}
365
366/// Render one shadow patch: a rounded rectangle, blurred, in the theme's
367/// shadow color.
368fn render_shadow(key: ShadowKey, theme: &Theme) -> Vec<u8> {
369    let w = key.width as usize;
370    let h = key.height as usize;
371    let extent = theme.shadow_extent.max(0);
372
373    // The mask is the shape before it is blurred: opaque inside the panel,
374    // clear outside. The blur is what turns the edge into a shadow.
375    let mut mask = vec![0u8; w * h];
376    let inset = 2 * u32::try_from(extent).unwrap_or(0);
377    let panel_w = key.width.saturating_sub(inset) as usize;
378    let panel_h = key.height.saturating_sub(inset) as usize;
379    let radius = usize::try_from(theme.corner_radius.max(0))
380        .unwrap_or(0)
381        .min(panel_w / 2)
382        .min(panel_h / 2);
383
384    if panel_w > 0 && panel_h > 0 {
385        let origin = usize::try_from(extent).unwrap_or(0);
386        for y in 0..h {
387            for x in 0..w {
388                if inside_rounded_rect(x, y, origin, origin, panel_w, panel_h, radius) {
389                    mask[y * w + x] = 0xFF;
390                }
391            }
392        }
393    }
394
395    blur3(&mut mask, w, h, usize::try_from(extent).unwrap_or(0));
396
397    // Premultiplied, because that is what a blit into an ARGB8888 buffer
398    // composites correctly without a per-pixel divide.
399    let shadow = theme.colors.shadow;
400    let intensity = if key.elevation == Elevation::Focused {
401        1.0
402    } else {
403        // An unfocused window casts a weaker shadow, which is what makes the
404        // focused one read as nearer.
405        0.7
406    };
407
408    let mut out = vec![0u8; w * h * 4];
409    for (index, coverage) in mask.iter().enumerate() {
410        let alpha = f32::from(*coverage) / 255.0 * f32::from(shadow.a) * intensity;
411        let a8 = to_u8(alpha);
412        // The product of two bytes over 255 is a byte by construction, so
413        // this cannot lose anything -- but saying so with try_from keeps the
414        // arithmetic checkable rather than asserted in a comment.
415        let premul = |channel: u8| {
416            u8::try_from((u32::from(channel) * u32::from(a8)) / 255).unwrap_or(u8::MAX)
417        };
418        let offset = index * 4;
419        out[offset] = premul(shadow.b);
420        out[offset + 1] = premul(shadow.g);
421        out[offset + 2] = premul(shadow.r);
422        out[offset + 3] = a8;
423    }
424    out
425}
426
427/// Whether a point is inside a rounded rectangle.
428fn inside_rounded_rect(
429    x: usize,
430    y: usize,
431    rect_x: usize,
432    rect_y: usize,
433    w: usize,
434    h: usize,
435    radius: usize,
436) -> bool {
437    if x < rect_x || y < rect_y || x >= rect_x + w || y >= rect_y + h {
438        return false;
439    }
440    if radius == 0 {
441        return true;
442    }
443    let (lx, ly) = (x - rect_x, y - rect_y);
444    // Only the four corner squares can be outside; everything else is in.
445    let (cx, cy) = (
446        if lx < radius {
447            radius
448        } else if lx >= w - radius {
449            w - radius - 1
450        } else {
451            return true;
452        },
453        if ly < radius {
454            radius
455        } else if ly >= h - radius {
456            h - radius - 1
457        } else {
458            return true;
459        },
460    );
461    // Integer arithmetic throughout: the distance test is exact, and a
462    // corner that rounds differently on one target than another would put a
463    // pixel of shadow in a different place per architecture.
464    let dx = lx.abs_diff(cx);
465    let dy = ly.abs_diff(cy);
466    dx * dx + dy * dy <= radius * radius
467}
468
469/// Three box blurs, which approximate a Gaussian closely enough for a shadow
470/// and cost a fraction of one.
471///
472/// Three is the standard number: one box is visibly square, two are still
473/// boxy at the corners, and the third is where the error stops being visible.
474fn blur3(buf: &mut [u8], width: usize, height: usize, total_radius: usize) {
475    if total_radius == 0 || width == 0 || height == 0 {
476        return;
477    }
478    let per_pass = (total_radius / 3).max(1);
479    let mut scratch = vec![0u8; buf.len()];
480    for _ in 0..3 {
481        box_blur_h(buf, &mut scratch, width, height, per_pass);
482        box_blur_v(&scratch, buf, width, height, per_pass);
483    }
484}
485
486/// One horizontal box pass.
487fn box_blur_h(src: &[u8], dst: &mut [u8], width: usize, height: usize, radius: usize) {
488    let window = u32::try_from(2 * radius + 1).unwrap_or(1).max(1);
489    for y in 0..height {
490        let row = y * width;
491        let mut sum: u32 = 0;
492        // Seed with the left edge clamped, so the blur does not darken at the
493        // borders as though the image were surrounded by transparency.
494        for x in 0..=radius.min(width - 1) {
495            sum += u32::from(src[row + x]);
496        }
497        sum += u32::from(src[row]) * u32::try_from(radius).unwrap_or(0);
498        for x in 0..width {
499            dst[row + x] = u8::try_from(sum / window).unwrap_or(u8::MAX);
500            let next = src[row + (x + radius + 1).min(width - 1)];
501            let gone = src[row + x.saturating_sub(radius)];
502            sum = sum + u32::from(next) - u32::from(gone);
503        }
504    }
505}
506
507/// One vertical box pass.
508fn box_blur_v(src: &[u8], dst: &mut [u8], width: usize, height: usize, radius: usize) {
509    let window = u32::try_from(2 * radius + 1).unwrap_or(1).max(1);
510    for x in 0..width {
511        let mut sum: u32 = 0;
512        for y in 0..=radius.min(height - 1) {
513            sum += u32::from(src[y * width + x]);
514        }
515        sum += u32::from(src[x]) * u32::try_from(radius).unwrap_or(0);
516        for y in 0..height {
517            dst[y * width + x] = u8::try_from(sum / window).unwrap_or(u8::MAX);
518            let next = src[(y + radius + 1).min(height - 1) * width + x];
519            let gone = src[y.saturating_sub(radius) * width + x];
520            sum = sum + u32::from(next) - u32::from(gone);
521        }
522    }
523}