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