Skip to main content

drmkit_csd/
renderer.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Drawing a decoration.
5//!
6//! # No rasterizer, deliberately
7//!
8//! Upstream draws through `Blend2D`. This does not, and takes no rendering
9//! dependency at all: a decoration is a rounded rectangle, a one-pixel rim,
10//! three circles and a blur, and every one of those is a short loop over
11//! bytes. Pulling a general rasterizer in to draw four shapes would be a
12//! large dependency for a small amount of arithmetic, and the arithmetic is
13//! the part worth being able to read.
14//!
15//! # No text
16//!
17//! The title is not drawn. Text needs a font stack — loading, shaping,
18//! hinting — which is a real dependency and a separate decision from the
19//! shapes. [`Renderer::has_font`] answers `false` and the title bar is drawn
20//! without its title, which is what upstream also does when no system font is
21//! found.
22
23use crate::{
24    Color, DecorationGeometry, Elevation, HoverButton, ShadowCache, ShadowDest, ShadowKey, Theme,
25    WindowState, decoration_geometry, theme_id,
26};
27
28/// Where a decoration is being drawn.
29#[derive(Debug)]
30#[allow(
31    clippy::exhaustive_structs,
32    reason = "built by callers, with no default to fill a new field from"
33)]
34pub struct Canvas<'a> {
35    /// `ARGB8888` pixels.
36    pub pixels: &'a mut [u8],
37    /// Bytes between rows.
38    pub stride: u32,
39    /// Width in pixels.
40    pub width: u32,
41    /// Height in pixels.
42    pub height: u32,
43}
44
45/// Why a decoration could not be drawn.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
47#[non_exhaustive]
48pub enum DrawError {
49    /// The canvas has no pixels.
50    ///
51    /// A window mid-resize passes through zero, and refusing here is better
52    /// than a loop that writes nothing and reports success — the caller would
53    /// present an unpainted buffer believing it had been drawn.
54    #[error("the canvas is empty")]
55    EmptyCanvas,
56
57    /// The canvas is smaller than its own stride says.
58    #[error("a {width}x{height} canvas at stride {stride} needs {needed} bytes, got {got}")]
59    Undersized {
60        /// Declared width.
61        width: u32,
62        /// Declared height.
63        height: u32,
64        /// Declared stride.
65        stride: u32,
66        /// What that implies.
67        needed: usize,
68        /// What was given.
69        got: usize,
70    },
71}
72
73/// How to build a renderer.
74#[derive(Debug, Clone, Default)]
75#[allow(
76    clippy::exhaustive_structs,
77    reason = "built by callers with `..Default::default()`, which a new field does not break"
78)]
79pub struct RendererConfig {
80    /// A font to load for the title.
81    ///
82    /// Accepted and unused: see the module docs. Kept so the field exists
83    /// when text arrives. The reference's `try_system_font` has no
84    /// counterpart here, since there is no font stack to search.
85    pub font_path: Option<String>,
86}
87
88/// Draws decorations.
89#[derive(Debug, Default)]
90pub struct Renderer {
91    config: RendererConfig,
92}
93
94impl Renderer {
95    /// A renderer.
96    #[must_use]
97    pub fn new(config: RendererConfig) -> Self {
98        Self { config }
99    }
100
101    /// Whether a font is loaded, and so whether the title will be drawn.
102    ///
103    /// Always `false`. Text is not implemented — see the module docs — and
104    /// answering `true` would have a caller reserve title-bar width for
105    /// something that never appears.
106    #[must_use]
107    pub const fn has_font(&self) -> bool {
108        false
109    }
110
111    /// The configured font path, whether or not anything uses it.
112    #[must_use]
113    pub fn font_path(&self) -> Option<&str> {
114        self.config.font_path.as_deref()
115    }
116
117    /// Draw a decoration for `state` onto `canvas`.
118    ///
119    /// Deterministic: the same theme, state and size produce byte-identical
120    /// output. That is what lets a caller skip a redraw it knows would change
121    /// nothing, and it is why the panel's dither is a hash of the coordinate
122    /// rather than a random number.
123    ///
124    /// # Errors
125    ///
126    /// [`DrawError::EmptyCanvas`] for a zero-sized canvas, and
127    /// [`DrawError::Undersized`] if the buffer is shorter than its own stride
128    /// and height describe.
129    pub fn draw(
130        &self,
131        theme: &Theme,
132        state: &WindowState,
133        canvas: &mut Canvas<'_>,
134        shadows: &mut ShadowCache,
135    ) -> Result<(), DrawError> {
136        if canvas.width == 0 || canvas.height == 0 || canvas.pixels.is_empty() {
137            return Err(DrawError::EmptyCanvas);
138        }
139        let needed = canvas.stride as usize * canvas.height as usize;
140        if canvas.pixels.len() < needed {
141            return Err(DrawError::Undersized {
142                width: canvas.width,
143                height: canvas.height,
144                stride: canvas.stride,
145                needed,
146                got: canvas.pixels.len(),
147            });
148        }
149
150        canvas.pixels[..needed].fill(0);
151
152        let geometry = decoration_geometry(theme, canvas.width, canvas.height);
153        // Focus drives the shadow's elevation as well as the rim: a settled
154        // window uses one patch, and a window mid-transition blends the two
155        // rather than blurring a third.
156        let focus = focus_amount(state);
157        Self::draw_shadow(theme, canvas, shadows, focus);
158        draw_panel(theme, canvas, &geometry);
159        draw_rim(theme, canvas, &geometry, focus);
160        draw_buttons(theme, canvas, &geometry, state);
161        Ok(())
162    }
163
164    /// Blit the shadow, cross-fading where focus is mid-transition.
165    fn draw_shadow(theme: &Theme, canvas: &mut Canvas<'_>, shadows: &mut ShadowCache, focus: f32) {
166        if theme.shadow_extent <= 0 || theme.colors.shadow.a == 0 {
167            return;
168        }
169        let id = theme_id(theme);
170        let key = |elevation| ShadowKey {
171            width: canvas.width,
172            height: canvas.height,
173            elevation,
174            theme_id: id,
175        };
176        let mut dest = ShadowDest {
177            pixels: canvas.pixels,
178            stride: canvas.stride,
179            width: canvas.width,
180            height: canvas.height,
181        };
182
183        // Settled either way uses one cached patch; only a transition pays the
184        // per-pixel blend, and even then both endpoints are cached.
185        if focus <= 0.0 {
186            shadows.blit_into(key(Elevation::Blurred), theme, &mut dest);
187        } else if focus >= 1.0 {
188            shadows.blit_into(key(Elevation::Focused), theme, &mut dest);
189        } else {
190            shadows.blit_cross_fade(
191                key(Elevation::Blurred),
192                key(Elevation::Focused),
193                theme,
194                &mut dest,
195                focus,
196            );
197        }
198    }
199}
200
201/// How focused the window is, `0..=1`.
202///
203/// The animator's progress where it has written one, and the boolean
204/// otherwise — a caller that never animates still gets a focused window drawn
205/// focused rather than stuck at the unset sentinel.
206fn focus_amount(state: &WindowState) -> f32 {
207    if state.focus_progress < 0.0 {
208        if state.focused { 1.0 } else { 0.0 }
209    } else {
210        state.focus_progress.clamp(0.0, 1.0)
211    }
212}
213
214/// Fill the panel: a vertical gradient, dithered.
215fn draw_panel(theme: &Theme, canvas: &mut Canvas<'_>, geometry: &DecorationGeometry) {
216    if geometry.panel_w <= 0 || geometry.panel_h <= 0 {
217        return;
218    }
219    let radius = corner_radius(theme, geometry);
220    let (top, bottom) = (theme.colors.panel_top, theme.colors.panel_bottom);
221    let height = geometry.panel_h.max(1);
222
223    for row in 0..geometry.panel_h {
224        // A vertical gradient, so the mix is per row rather than per pixel.
225        let t = f32::from(u16::try_from(row).unwrap_or(0))
226            / f32::from(u16::try_from(height).unwrap_or(1));
227        let base = lerp_color(top, bottom, t);
228
229        for column in 0..geometry.panel_w {
230            if !in_rounded_rect(column, row, geometry.panel_w, geometry.panel_h, radius) {
231                continue;
232            }
233            let x = geometry.panel_x + column;
234            let y = geometry.panel_y + row;
235            // Dither breaks the banding a flat 8-bit gradient shows across a
236            // wide title bar. Derived from the coordinate, not from a random
237            // number, so the same decoration draws identically every time.
238            let color = dither(base, x, y, theme.noise_amplitude);
239            blend_pixel(canvas, x, y, color);
240        }
241    }
242}
243
244/// Stroke the one-pixel rim, between the blurred and focused colors.
245fn draw_rim(theme: &Theme, canvas: &mut Canvas<'_>, geometry: &DecorationGeometry, focus: f32) {
246    if geometry.panel_w <= 0 || geometry.panel_h <= 0 {
247        return;
248    }
249    let radius = corner_radius(theme, geometry);
250    let color = lerp_color(theme.colors.rim_blurred, theme.colors.rim_focused, focus);
251
252    for row in 0..geometry.panel_h {
253        for column in 0..geometry.panel_w {
254            // The rim is where the shape is, but one pixel further in is not:
255            // that is the outline, without needing a second shape to subtract.
256            let inside = in_rounded_rect(column, row, geometry.panel_w, geometry.panel_h, radius);
257            if !inside {
258                continue;
259            }
260            let interior = in_rounded_rect(column, row, geometry.panel_w, geometry.panel_h, radius)
261                && in_inset_rounded_rect(column, row, geometry.panel_w, geometry.panel_h, radius);
262            if interior {
263                continue;
264            }
265            blend_pixel(
266                canvas,
267                geometry.panel_x + column,
268                geometry.panel_y + row,
269                color,
270            );
271        }
272    }
273}
274
275/// Fill the three window buttons.
276fn draw_buttons(
277    theme: &Theme,
278    canvas: &mut Canvas<'_>,
279    geometry: &DecorationGeometry,
280    state: &WindowState,
281) {
282    if geometry.title_bar_height <= 0 {
283        return;
284    }
285    let hover_amount = if state.hover_progress < 0.0 {
286        1.0
287    } else {
288        state.hover_progress.clamp(0.0, 1.0)
289    };
290
291    for (button, cx, theme_button) in [
292        (HoverButton::Close, geometry.close_cx, theme.buttons.close),
293        (
294            HoverButton::Minimize,
295            geometry.minimize_cx,
296            theme.buttons.minimize,
297        ),
298        (
299            HoverButton::Maximize,
300            geometry.maximize_cx,
301            theme.buttons.maximize,
302        ),
303    ] {
304        // Only the hovered button lights, and only as far as the animation has
305        // got. Lighting every button on any hover would be worse than lighting
306        // none.
307        let lit = if state.hover == button {
308            hover_amount
309        } else {
310            0.0
311        };
312        let color = lerp_color(theme_button.fill, theme_button.hover, lit);
313        fill_circle(
314            canvas,
315            cx,
316            geometry.button_cy,
317            geometry.button_radius,
318            color,
319        );
320    }
321}
322
323/// The corner radius, never more than half the shorter side.
324///
325/// A radius past that describes a shape whose corners overlap, which is not a
326/// rounded rectangle and which the coverage test cannot answer sensibly.
327fn corner_radius(theme: &Theme, geometry: &DecorationGeometry) -> i32 {
328    theme
329        .corner_radius
330        .max(0)
331        .min(geometry.panel_w / 2)
332        .min(geometry.panel_h / 2)
333}
334
335/// Whether a point is inside a rounded rectangle of this size.
336fn in_rounded_rect(x: i32, y: i32, w: i32, h: i32, radius: i32) -> bool {
337    if x < 0 || y < 0 || x >= w || y >= h {
338        return false;
339    }
340    if radius <= 0 {
341        return true;
342    }
343    let cx = if x < radius {
344        radius
345    } else if x >= w - radius {
346        w - radius - 1
347    } else {
348        return true;
349    };
350    let cy = if y < radius {
351        radius
352    } else if y >= h - radius {
353        h - radius - 1
354    } else {
355        return true;
356    };
357    let (dx, dy) = ((x - cx).unsigned_abs(), (y - cy).unsigned_abs());
358    dx * dx + dy * dy <= radius.unsigned_abs() * radius.unsigned_abs()
359}
360
361/// Whether a point is inside the same shape shrunk by one pixel.
362fn in_inset_rounded_rect(x: i32, y: i32, w: i32, h: i32, radius: i32) -> bool {
363    if w <= 2 || h <= 2 {
364        return false;
365    }
366    in_rounded_rect(x - 1, y - 1, w - 2, h - 2, (radius - 1).max(0))
367}
368
369/// Fill a circle.
370fn fill_circle(canvas: &mut Canvas<'_>, cx: i32, cy: i32, radius: i32, color: Color) {
371    if radius <= 0 {
372        return;
373    }
374    let r2 = radius.unsigned_abs() * radius.unsigned_abs();
375    for y in (cy - radius)..=(cy + radius) {
376        for x in (cx - radius)..=(cx + radius) {
377            let (dx, dy) = ((x - cx).unsigned_abs(), (y - cy).unsigned_abs());
378            if dx * dx + dy * dy <= r2 {
379                blend_pixel(canvas, x, y, color);
380            }
381        }
382    }
383}
384
385/// Round a float to a byte, clamping it to `0..=255` first, without a
386/// float-to-int cast.
387///
388/// A float-to-int `as` saturates on every target, so the cast would not be
389/// wrong; stepping through the byte values instead keeps clippy's pedantic
390/// cast lints quiet without an `allow`, at a cost the fixed 256-step range
391/// keeps small.
392fn round_channel(value: f64) -> u8 {
393    let clamped = value.clamp(0.0, 255.0);
394    u8::try_from(
395        (0u16..=255)
396            .find(|step| f64::from(*step) >= clamped - 0.5)
397            .unwrap_or(255),
398    )
399    .unwrap_or(u8::MAX)
400}
401
402/// Mix two colors, `t` of the way from `a` to `b`.
403fn lerp_color(a: Color, b: Color, t: f32) -> Color {
404    let t = t.clamp(0.0, 1.0);
405    let mix = |from: u8, to: u8| {
406        round_channel(f64::from(from) + (f64::from(to) - f64::from(from)) * f64::from(t))
407    };
408    Color {
409        r: mix(a.r, b.r),
410        g: mix(a.g, b.g),
411        b: mix(a.b, b.b),
412        a: mix(a.a, b.a),
413    }
414}
415
416/// Nudge a color by a coordinate-derived amount.
417///
418/// The dither. Deterministic by construction: the offset is a hash of the
419/// pixel's position, so redrawing the same decoration produces the same
420/// bytes, which is what lets a caller skip a redraw that would change nothing.
421pub(crate) fn dither(color: Color, x: i32, y: i32, amplitude: f64) -> Color {
422    if amplitude <= 0.0 {
423        return color;
424    }
425    // Clamped as the reference clamps it: a theme asking for more than full
426    // strength gets full strength, not noise that swamps the panel.
427    let amplitude = amplitude.min(1.0);
428    // A cheap integer hash of the coordinate, folded to 0..=255.
429    let mixed =
430        (x.unsigned_abs().wrapping_mul(1_664_525)) ^ (y.unsigned_abs().wrapping_mul(1_013_904_223));
431    let noise = (mixed >> 16) & 0xFF;
432    // Centered on zero, so the dither does not brighten the panel overall.
433    let offset = (f64::from(noise) - 127.5) / 127.5 * amplitude * 255.0;
434    let nudge = |channel: u8| round_channel(f64::from(channel) + offset);
435    Color {
436        r: nudge(color.r),
437        g: nudge(color.g),
438        b: nudge(color.b),
439        a: color.a,
440    }
441}
442
443/// Source-over one pixel.
444fn blend_pixel(canvas: &mut Canvas<'_>, x: i32, y: i32, color: Color) {
445    if x < 0 || y < 0 {
446        return;
447    }
448    let (Ok(x), Ok(y)) = (u32::try_from(x), u32::try_from(y)) else {
449        return;
450    };
451    if x >= canvas.width || y >= canvas.height {
452        return;
453    }
454    let offset = (y * canvas.stride + x * 4) as usize;
455    let Some(pixel) = canvas.pixels.get_mut(offset..offset + 4) else {
456        return;
457    };
458
459    let src_a = u32::from(color.a);
460    if src_a == 0 {
461        return;
462    }
463    let inverse = 255 - src_a;
464    // Source-over on straight alpha: the shadow beneath is already there, and
465    // a decoration's panel is translucent by design, so overwriting would lose
466    // the shadow the blur just cost.
467    let over = |src: u8, dst: u8| {
468        let value = (u32::from(src) * src_a + u32::from(dst) * inverse) / 255;
469        u8::try_from(value.min(255)).unwrap_or(u8::MAX)
470    };
471    pixel[0] = over(color.b, pixel[0]);
472    pixel[1] = over(color.g, pixel[1]);
473    pixel[2] = over(color.r, pixel[2]);
474    pixel[3] =
475        u8::try_from((src_a + u32::from(pixel[3]) * inverse / 255).min(255)).unwrap_or(u8::MAX);
476}